From 84a79f97ee1ba4a3c01c6135c286667256294cac Mon Sep 17 00:00:00 2001 From: fathriAbanoub Date: Thu, 9 Jul 2026 16:51:09 +0300 Subject: [PATCH 1/5] Fix: Enforce scope on exclude patterns and improve UX Security fix: - Add scope enforcement to prevent excluding files outside the selected directory/zip. Previously, the Browse picker allowed selecting arbitrary filesystem paths which would be silently converted to overly-broad globs. - Compute operation scope from selected directories only (files and zips contribute nothing since you can only exclude what's being glued). - Validate manual patterns to reject absolute paths, traversals, and out-of-scope references. - Disable Browse button for zip-only selections (contents unknown to GUI). - Point file picker initial folder at scope root, not zip's parent dir. - Show rejection summary when user picks out-of-scope files. UX improvement: - Hide exclude section entirely when only files are selected (no dirs/zips), since excludes only apply during directory recursion. Testing: - Add comprehensive tests for scope computation, pattern validation, and the prefix-separator trap in is_path_in_scope. - Tests use real filesystem paths via tmp_path fixture. Fixes: exclude-outside-selection sandbox escape --- codegluer_gui.py | 427 +++++++++++++++++++++++++++++------- tests/test_codegluer_gui.py | 57 ++++- 2 files changed, 400 insertions(+), 84 deletions(-) diff --git a/codegluer_gui.py b/codegluer_gui.py index 04fd6d3..ae26de0 100755 --- a/codegluer_gui.py +++ b/codegluer_gui.py @@ -108,6 +108,164 @@ def target_dir_of(files: list[str]) -> str: return parent if parent else "." +# ────────────────────────────────────────────────────────────────────── +# Scope enforcement (FIX 2026-07-09: exclude-outside-sel bug) +# +# The exclude feature MUST only operate on files that could actually be +# glued. Without this, the Browse picker lets you select /etc/passwd and +# silently turns it into a `--exclude passwd` glob that strips every file +# named `passwd` from your project. We now compute an explicit "scope" +# (the set of allowed root directories derived from the selection) and +# reject any exclude pattern or picked path that falls outside it. +# +# Zips are deliberately NOT scope roots — their contents are unknown to +# the GUI until the CLI extracts them, so file-picking is meaningless +# for zip inputs. The Browse button is hidden when the scope is empty. +# ────────────────────────────────────────────────────────────────────── + +def compute_scope_roots(files: list[str]) -> list[str]: + """Return the list of absolute directory paths that form the operation + scope. Only real selected directories count — zip inputs are excluded + (contents unknown to the GUI), and bare selected files contribute + nothing (you can only exclude what's actually being glued; for a + bare file, that's just the file itself — there's nothing to browse). + """ + roots: list[str] = [] + seen: set[str] = set() + for f in files: + if str(f).lower().endswith(".zip"): + continue + try: + p = os.path.realpath(os.path.expanduser(f)) + except OSError: + continue + if os.path.isdir(p) and p not in seen: + seen.add(p) + roots.append(p) + return roots + + +def is_path_in_scope(path: str, scope_roots: list[str]) -> bool: + """True if `path` resolves to a location inside one of `scope_roots`.""" + if not scope_roots: + return False + try: + resolved = os.path.realpath(os.path.expanduser(path)) + except OSError: + return False + for root in scope_roots: + try: + root_resolved = os.path.realpath(root) + except OSError: + continue + if resolved == root_resolved: + return True + # Path-is-prefix check, with explicit separator to defeat + # /home/foo vs /home/foobar style confusion. + if resolved.startswith(root_resolved + os.sep): + return True + return False + + +def relative_to_scope(path: str, scope_roots: list[str]) -> str | None: + """Return `path` made relative to whichever scope root contains it. + Returns None if the path is not in any scope root.""" + try: + resolved = os.path.realpath(os.path.expanduser(path)) + except OSError: + return None + best_root = None + best_rel = None + for root in scope_roots: + try: + root_resolved = os.path.realpath(root) + except OSError: + continue + try: + rel = os.path.relpath(resolved, root_resolved) + except ValueError: + continue + if rel == ".": + # Path is the root itself — nothing meaningful to exclude. + continue + if rel.startswith(".."): + continue + # Pick the longest root (most specific) so the chip shows the + # tightest relative path. + if best_root is None or len(root_resolved) > len(best_root): + best_root = root_resolved + best_rel = rel + return best_rel + + +def validate_exclude_pattern(pattern: str, scope_roots: list[str]) -> tuple[bool, str, str]: + """Validate a manually-typed or picked exclude pattern. + + Returns (ok, cleaned_pattern, error_message). + + Rules: + 1. Strip whitespace, leading `./`, trailing `/`. + 2. Reject empty. + 3. Reject absolute paths (starts with `/` or resolves to one). The + GUI has no business excluding absolute filesystem paths — they + can only ever be a user mistake. + 4. Reject user-home paths (`~...`). + 5. Reject `..` traversals that escape all scope roots. + 6. If the pattern is a real path inside scope, return it as a + relative path so the chip matches what the user actually picked. + 7. Otherwise (pure glob like `*.py`, `node_modules`, `**/*.log`), + accept as-is. These can only match inside the input set anyway. + """ + raw = pattern.strip() + if raw.startswith("./"): + raw = raw[2:] + if raw.endswith("/"): + raw = raw[:-1] + if not raw: + return False, "", "Pattern is empty." + + # Reject obvious absolute / home references up front. + if raw.startswith("/") or raw.startswith("~"): + return False, "", ( + f"Absolute paths are not allowed as exclude patterns " + f"(got {raw!r}). Excludes only apply to files inside the " + f"selected directory or zip." + ) + + # If the pattern contains path separators OR starts with `..`, it can + # only be meaningful as a path relative to a scope root. With no scope + # roots (zip-only selection) there's nothing to anchor against, so + # reject — the user must type a plain glob without separators instead. + if os.sep in raw or "/" in raw or raw.startswith(".."): + if not scope_roots: + return False, "", ( + f"Pattern {raw!r} contains a path separator or '..', " + f"but this selection has no browsable scope — only plain " + f"glob patterns without '/' are allowed here." + ) + # Try interpreting it as a path relative to each scope root. + # If it resolves outside all of them, reject. + in_scope = False + for root in scope_roots: + candidate = os.path.realpath(os.path.join(root, raw)) + if is_path_in_scope(candidate, [root]): + in_scope = True + break + if not in_scope: + return False, "", ( + f"Pattern {raw!r} resolves outside the selected " + f"directory or zip. Excludes can only target files " + f"inside the selection." + ) + # Normalize to forward slashes for pathspec consistency. + cleaned = raw.replace(os.sep, "/") + return True, cleaned, "" + + # Pure name/glob pattern (no separators, no `..`). Accept as-is. + return True, raw, "" + + + def should_update_default(current_text: str, target_dir: str) -> bool: """True if the output field still holds a default value (or is empty), meaning a format switch may safely update the extension. False if the @@ -297,6 +455,11 @@ def __init__(self, app): self.dry_run = dry_run self.current_theme = read_theme() + # FIX (2026-07-09): operation scope — the set of directories + # the exclude feature is allowed to operate on. Empty for + # zip-only inputs, which disables the Browse button. + self.scope_roots = compute_scope_roots(files) + # State self.format = "markdown" self.output_entry = None @@ -347,6 +510,20 @@ def _build_ui(self): info.set_use_markup(True) content.append(info) + # If the scope is empty (e.g. zip-only selection), warn the + # user that Browse is disabled and excludes must be typed. + if self.any_dir and not self.scope_roots: + scope_warn = Gtk.Label( + label=( + "Zip inputs can't be browsed — type exclude " + "patterns manually (e.g. node_modules, " + "*.log)." + ) + ) + scope_warn.set_use_markup(True) + scope_warn.set_halign(Gtk.Align.START) + content.append(scope_warn) + grid = Gtk.Grid() grid.set_row_spacing(8) grid.set_column_spacing(12) @@ -365,43 +542,48 @@ def _build_ui(self): grid.attach(self.output_entry, 1, row, 1, 1) row += 1 - # Exclude patterns - grid.attach(Gtk.Label(label="Exclude:", halign=Gtk.Align.END, valign=Gtk.Align.START), 0, row, 1, 1) - - exclude_row = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL, spacing=6) - exclude_row.set_hexpand(True) - exclude_row.set_valign(Gtk.Align.START) - - exclude_box = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=4) - exclude_box.add_css_class("chip-box") - exclude_box.set_hexpand(True) - exclude_row.append(exclude_box) - - self.chip_flowbox = Gtk.FlowBox() - self.chip_flowbox.set_selection_mode(Gtk.SelectionMode.NONE) - self.chip_flowbox.set_max_children_per_line(20) - self.chip_flowbox.set_min_children_per_line(1) - self.chip_flowbox.set_column_spacing(4) - self.chip_flowbox.set_row_spacing(4) - self.chip_flowbox.set_visible(False) - exclude_box.append(self.chip_flowbox) - - self.manual_exclude_entry = Gtk.Entry() - self.manual_exclude_entry.set_placeholder_text( - "Type a pattern and press Enter, or click Browse…" - ) - self.manual_exclude_entry.set_hexpand(True) - self.manual_exclude_entry.connect("activate", self._on_manual_exclude_activate) - exclude_box.append(self.manual_exclude_entry) - - browse_btn = Gtk.Button(label="Browse…") - browse_btn.set_tooltip_text("Select files to exclude") - browse_btn.set_valign(Gtk.Align.CENTER) - browse_btn.connect("clicked", self._on_browse_clicked) - exclude_row.append(browse_btn) - - grid.attach(exclude_row, 1, row, 1, 1) - row += 1 + # Exclude patterns — only show when directories or zips are selected + if self.any_dir: + grid.attach(Gtk.Label(label="Exclude:", halign=Gtk.Align.END, valign=Gtk.Align.START), 0, row, 1, 1) + exclude_row = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL, spacing=6) + exclude_row.set_hexpand(True) + exclude_row.set_valign(Gtk.Align.START) + exclude_box = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=4) + exclude_box.add_css_class("chip-box") + exclude_box.set_hexpand(True) + exclude_row.append(exclude_box) + self.chip_flowbox = Gtk.FlowBox() + self.chip_flowbox.set_selection_mode(Gtk.SelectionMode.NONE) + self.chip_flowbox.set_max_children_per_line(20) + self.chip_flowbox.set_min_children_per_line(1) + self.chip_flowbox.set_column_spacing(4) + self.chip_flowbox.set_row_spacing(4) + self.chip_flowbox.set_visible(False) + exclude_box.append(self.chip_flowbox) + self.manual_exclude_entry = Gtk.Entry() + self.manual_exclude_entry.set_placeholder_text( + "Type a pattern and press Enter, or click Browse…" + ) + self.manual_exclude_entry.set_hexpand(True) + self.manual_exclude_entry.connect("activate", self._on_manual_exclude_activate) + exclude_box.append(self.manual_exclude_entry) + browse_btn = Gtk.Button(label="Browse…") + browse_btn.set_tooltip_text("Select files to exclude") + browse_btn.set_valign(Gtk.Align.CENTER) + browse_btn.connect("clicked", self._on_browse_clicked) + # FIX (2026-07-09): disable Browse when there is no scope + # to browse (zip-only inputs). The picker would otherwise + # land in the zip's parent dir, which has nothing to do + # with the zip's contents. + if not self.scope_roots: + browse_btn.set_sensitive(False) + browse_btn.set_tooltip_text( + "Browse is disabled for zip-only selections — " + "type patterns manually instead." + ) + exclude_row.append(browse_btn) + grid.attach(exclude_row, 1, row, 1, 1) + row += 1 # Format dropdown grid.attach(Gtk.Label(label="Format:", halign=Gtk.Align.END), 0, row, 1, 1) @@ -514,17 +696,47 @@ def _on_glue(self, _btn): # ── Exclude chips ──────────────────────────────────────────────── - def _normalize_exclude_pattern(self, text: str) -> str: - text = text.strip() - if text.startswith('./'): - text = text[2:] - if text.endswith('/'): - text = text[:-1] - return text + def _validate_and_clean_pattern(self, text: str) -> tuple[bool, str, str]: + """Wrap validate_exclude_pattern() with this window's scope_roots.""" + return validate_exclude_pattern(text, self.scope_roots) + + def _create_and_insert_chip(self, normalized: str) -> None: + """Append `normalized` to self.excludes and insert its chip widget. + Caller is responsible for dedup check — both callers have + different dedup behavior (typed-entry logs+skips, picked-path + returns idempotent success), so dedup stays in the callers. + Raises whatever GTK raises; callers wrap in their own try/except.""" + self.excludes.append(normalized) + chip = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL, spacing=4) + chip.add_css_class("chip") + chip.set_halign(Gtk.Align.START) + chip.set_valign(Gtk.Align.CENTER) + label = Gtk.Label(label=normalized) + label.set_max_width_chars(30) + label.set_ellipsize(Pango.EllipsizeMode.END) + label.set_tooltip_text(normalized) + chip.append(label) + close_btn = Gtk.Button(label="✕") + close_btn.add_css_class("chip-close") + close_btn.set_tooltip_text(f"Remove {normalized}") + close_btn.connect( + "clicked", + lambda *_: self._remove_exclude_chip(normalized, chip), + ) + chip.append(close_btn) + self.chip_flowbox.insert(chip, -1) + self.chip_flowbox.set_visible(True) def _add_exclude_chip(self, text: str) -> None: - normalized = self._normalize_exclude_pattern(text) - debug_print(f"[CodeGluer] _add_exclude_chip({text!r}) -> normalized {normalized!r}") + ok, normalized, err = self._validate_and_clean_pattern(text) + debug_print( + f"[CodeGluer] _add_exclude_chip({text!r}) -> " + f"ok={ok}, normalized={normalized!r}, err={err!r}" + ) + if not ok: + debug_print(f"[CodeGluer] rejected: {err}") + self._show_error_dialog("Invalid exclude pattern", err) + return if not normalized: debug_print("[CodeGluer] skipped: empty after normalization") return @@ -532,33 +744,49 @@ def _add_exclude_chip(self, text: str) -> None: debug_print("[CodeGluer] skipped: duplicate") return try: - self.excludes.append(normalized) - - chip = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL, spacing=4) - chip.add_css_class("chip") - chip.set_halign(Gtk.Align.START) - chip.set_valign(Gtk.Align.CENTER) - - label = Gtk.Label(label=normalized) - label.set_max_width_chars(30) - label.set_ellipsize(Pango.EllipsizeMode.END) - label.set_tooltip_text(normalized) - chip.append(label) - - close_btn = Gtk.Button(label="✕") - close_btn.add_css_class("chip-close") - close_btn.set_tooltip_text(f"Remove {normalized}") - close_btn.connect("clicked", lambda *_: self._remove_exclude_chip(normalized, chip)) - chip.append(close_btn) - - self.chip_flowbox.insert(chip, -1) - self.chip_flowbox.set_visible(True) + self._create_and_insert_chip(normalized) debug_print(f"[CodeGluer] added. total excludes now: {len(self.excludes)}") except Exception as e: import traceback debug_print(traceback.format_exc()) self._show_error_dialog("Failed to add exclude chip", str(e)) + def _add_picked_path_as_chip(self, gfile) -> tuple[bool, str]: + """Convert a picked GFile into a scope-relative exclude chip. + + Returns (accepted, reason). When accepted, the chip is added + and the relative path is returned. When rejected, reason + explains why (path outside scope, etc.). + """ + try: + path = gfile.get_path() # absolute local path, or None + except Exception: + path = None + if not path: + # Fallback: basename only. We can't scope-check it, so be + # conservative and reject — forces the user to type the + # pattern manually so they see what they're doing. + return False, "Selected file has no local path (remote or invalid)." + if not is_path_in_scope(path, self.scope_roots): + return False, ( + f"{path} is outside the selected directory or zip. " + f"Excludes can only target files inside the selection." + ) + rel = relative_to_scope(path, self.scope_roots) + if not rel: + return False, f"{path} could not be made relative to the selection." + # We already proved scope membership with realpath, so bypass + # validate_exclude_pattern's glob-only assumption for slash-containing + # relative paths. + normalized = rel.replace(os.sep, "/") + if normalized in self.excludes: + return True, normalized # idempotent + try: + self._create_and_insert_chip(normalized) + except Exception as e: + return False, f"Failed to add chip: {e}" + return True, normalized + def _remove_exclude_chip(self, text: str, chip_widget) -> None: """Remove a chip widget and its pattern from the excludes list.""" if text in self.excludes: @@ -635,10 +863,16 @@ def _open_file_dialog(self) -> None: dialog = Gtk.FileDialog() dialog.set_title("Select files to exclude") dialog.set_modal(True) + # FIX (2026-07-09): point the picker at a scope root, not at + # the zip's parent dir. If there are multiple roots we pick + # the first; the user can still navigate to siblings from + # there. Out-of-scope picks are rejected in the response + # callback regardless. + initial_folder = self.scope_roots[0] if self.scope_roots else None try: - if (self.target_dir and os.path.isdir(self.target_dir) - and not _looks_heavy(self.target_dir)): - dialog.set_initial_folder(Gio.File.new_for_path(self.target_dir)) + if initial_folder and os.path.isdir(initial_folder) \ + and not _looks_heavy(initial_folder): + dialog.set_initial_folder(Gio.File.new_for_path(initial_folder)) except Exception: pass self._active_picker = dialog @@ -666,20 +900,36 @@ def _on_file_dialog_response(self, dialog, result) -> None: if n == 0: debug_print("[CodeGluer] no items selected — bailing") return + rejected: list[str] = [] try: for i in range(n): gfile = files.get_item(i) if gfile is None: debug_print(f"[CodeGluer] item {i} is None") continue - name = gfile.get_basename() - debug_print(f"[CodeGluer] item {i}: {name!r}") - if name: - self._add_exclude_chip(name) + ok, info = self._add_picked_path_as_chip(gfile) + if ok: + debug_print(f"[CodeGluer] item {i}: accepted as {info!r}") + else: + rejected.append(info) + debug_print(f"[CodeGluer] item {i}: rejected ({info})") except Exception as e: import traceback debug_print(traceback.format_exc()) self._show_error_dialog("Failed to process selected files", str(e)) + return + if rejected: + # Tell the user which picks were thrown out and why. + summary = "\n".join(f"• {r}" for r in rejected[:10]) + if len(rejected) > 10: + summary += f"\n… and {len(rejected) - 10} more." + self._show_error_dialog( + f"Rejected {len(rejected)} of {n} pick(s)", + ( + "Only files inside the selected directory or zip " + "can be excluded.\n\n" + summary + ), + ) def _open_file_chooser_dialog(self) -> None: dialog = Gtk.FileChooserNative.new( @@ -690,10 +940,11 @@ def _open_file_chooser_dialog(self) -> None: cancel_label="_Cancel", ) dialog.set_select_multiple(True) + initial_folder = self.scope_roots[0] if self.scope_roots else None try: - if (self.target_dir and os.path.isdir(self.target_dir) - and not _looks_heavy(self.target_dir)): - dialog.set_current_folder(Gio.File.new_for_path(self.target_dir)) + if initial_folder and os.path.isdir(initial_folder) \ + and not _looks_heavy(initial_folder): + dialog.set_current_folder(Gio.File.new_for_path(initial_folder)) except Exception: pass dialog.connect("response", self._on_file_chooser_response) @@ -721,14 +972,28 @@ def _on_file_chooser_response(self, dialog, response) -> None: try: n = files.get_n_items() debug_print(f"[CodeGluer] number of items selected: {n}") + rejected: list[str] = [] for i in range(n): gfile = files.get_item(i) if gfile is None: continue - name = gfile.get_basename() - debug_print(f"[CodeGluer] item {i}: {name!r}") - if name: - self._add_exclude_chip(name) + ok, info = self._add_picked_path_as_chip(gfile) + if ok: + debug_print(f"[CodeGluer] item {i}: accepted as {info!r}") + else: + rejected.append(info) + debug_print(f"[CodeGluer] item {i}: rejected ({info})") + if rejected: + summary = "\n".join(f"• {r}" for r in rejected[:10]) + if len(rejected) > 10: + summary += f"\n… and {len(rejected) - 10} more." + self._show_error_dialog( + f"Rejected {len(rejected)} of {n} pick(s)", + ( + "Only files inside the selected directory " + "or zip can be excluded.\n\n" + summary + ), + ) except Exception as e: import traceback debug_print(traceback.format_exc()) diff --git a/tests/test_codegluer_gui.py b/tests/test_codegluer_gui.py index 3e2ef47..5a3d447 100755 --- a/tests/test_codegluer_gui.py +++ b/tests/test_codegluer_gui.py @@ -1,5 +1,3 @@ -## `tests/test_codegluer_gui.py` - """ pytest suite for codegluer_gui logic. No GTK required. @@ -312,4 +310,57 @@ def test_should_update_default_empty_true(tmp_path): def test_should_update_default_custom_false(tmp_path): assert cg.should_update_default("Glued_Code_custom.md", str(tmp_path)) is False assert cg.should_update_default("my_output.md", str(tmp_path)) is False - assert cg.should_update_default("report.txt", str(tmp_path)) is False \ No newline at end of file + assert cg.should_update_default("report.txt", str(tmp_path)) is False + + +# ── Scope enforcement (moved from scope_selfcheck.py) ───────────── + +def test_scope_roots_only_captures_selected_dirs(tmp_path): + real_dir = tmp_path / "project" + real_dir.mkdir() + bare_file = tmp_path / "lonely.py" + bare_file.touch() + zip_file = tmp_path / "a.zip" + zip_file.write_bytes(b"PK") # fake zip header + + assert cg.compute_scope_roots([str(real_dir)]) == [str(real_dir.resolve())] + assert cg.compute_scope_roots([str(bare_file)]) == [] + assert cg.compute_scope_roots([str(zip_file)]) == [] + # Mixed: only the dir counts + assert cg.compute_scope_roots([str(real_dir), str(bare_file), str(zip_file)]) == [str(real_dir.resolve())] + # Dedup + assert cg.compute_scope_roots([str(real_dir), str(real_dir)]) == [str(real_dir.resolve())] + + +def test_validate_rejects_absolute_and_traversal(tmp_path): + scope = [str(tmp_path)] + ok, _, err = cg.validate_exclude_pattern("/etc/passwd", scope) + assert not ok and "Absolute" in err + ok, _, err = cg.validate_exclude_pattern("../outside.txt", scope) + assert not ok and "outside" in err + + +def test_validate_accepts_in_scope_patterns(tmp_path): + scope = [str(tmp_path)] + ok, p, _ = cg.validate_exclude_pattern("*.py", scope) + assert ok and p == "*.py" + ok, p, _ = cg.validate_exclude_pattern("sub/file.txt", scope) + assert ok and p == "sub/file.txt" + + +def test_validate_empty_scope_rejects_slash_patterns(): + ok, _, err = cg.validate_exclude_pattern("foo/bar.py", []) + assert not ok and "no browsable scope" in err + # Pure globs still work with empty scope + ok, p, _ = cg.validate_exclude_pattern("*.py", []) + assert ok and p == "*.py" + + +def test_is_path_in_scope_prefix_trap(tmp_path): + foo = tmp_path / "foo" + foo.mkdir() + foobar = tmp_path / "foobar" + foobar.mkdir() + # /tmp/foobar/x must NOT match scope root /tmp/foo + assert not cg.is_path_in_scope(str(foobar / "x"), [str(foo)]) + assert cg.is_path_in_scope(str(foo / "x"), [str(foo)]) \ No newline at end of file From affc6aa408c2367aea24b9fdfc7b9545fc5e260d Mon Sep 17 00:00:00 2001 From: fathriAbanoub Date: Thu, 9 Jul 2026 20:57:30 +0300 Subject: [PATCH 2/5] refactor(gui): dedupe rejection-summary + cover ~-path branch - Extract _report_rejected_picks() helper; both picker handlers now share the same 10-item truncation and wording. - Add test coverage for the ~-prefixed path rejection branch in validate_exclude_pattern (previously untested). --- codegluer_gui.py | 37 ++++++++++++++----------------------- tests/test_codegluer_gui.py | 3 +++ 2 files changed, 17 insertions(+), 23 deletions(-) diff --git a/codegluer_gui.py b/codegluer_gui.py index ae26de0..10414e7 100755 --- a/codegluer_gui.py +++ b/codegluer_gui.py @@ -850,6 +850,18 @@ def _show_error_dialog(self, message: str, detail: str = "") -> None: d.connect("response", lambda *_: d.destroy()) d.present() + def _report_rejected_picks(self, rejected: list[str], n: int) -> None: + """Show a summary dialog for picks rejected by scope enforcement.""" + if not rejected: + return + summary = "\n".join(f"• {r}" for r in rejected[:10]) + if len(rejected) > 10: + summary += f"\n… and {len(rejected) - 10} more." + self._show_error_dialog( + f"Rejected {len(rejected)} of {n} pick(s)", + "Only files inside the selected directory or zip can be excluded.\n\n" + summary, + ) + def _try_open_file_dialog(self) -> bool: gtk_version = (Gtk.get_major_version(), Gtk.get_minor_version()) if gtk_version < (4, 10): @@ -918,18 +930,7 @@ def _on_file_dialog_response(self, dialog, result) -> None: debug_print(traceback.format_exc()) self._show_error_dialog("Failed to process selected files", str(e)) return - if rejected: - # Tell the user which picks were thrown out and why. - summary = "\n".join(f"• {r}" for r in rejected[:10]) - if len(rejected) > 10: - summary += f"\n… and {len(rejected) - 10} more." - self._show_error_dialog( - f"Rejected {len(rejected)} of {n} pick(s)", - ( - "Only files inside the selected directory or zip " - "can be excluded.\n\n" + summary - ), - ) + self._report_rejected_picks(rejected, n) def _open_file_chooser_dialog(self) -> None: dialog = Gtk.FileChooserNative.new( @@ -983,17 +984,7 @@ def _on_file_chooser_response(self, dialog, response) -> None: else: rejected.append(info) debug_print(f"[CodeGluer] item {i}: rejected ({info})") - if rejected: - summary = "\n".join(f"• {r}" for r in rejected[:10]) - if len(rejected) > 10: - summary += f"\n… and {len(rejected) - 10} more." - self._show_error_dialog( - f"Rejected {len(rejected)} of {n} pick(s)", - ( - "Only files inside the selected directory " - "or zip can be excluded.\n\n" + summary - ), - ) + self._report_rejected_picks(rejected, n) except Exception as e: import traceback debug_print(traceback.format_exc()) diff --git a/tests/test_codegluer_gui.py b/tests/test_codegluer_gui.py index 5a3d447..3ad4741 100755 --- a/tests/test_codegluer_gui.py +++ b/tests/test_codegluer_gui.py @@ -338,6 +338,9 @@ def test_validate_rejects_absolute_and_traversal(tmp_path): assert not ok and "Absolute" in err ok, _, err = cg.validate_exclude_pattern("../outside.txt", scope) assert not ok and "outside" in err + # Home‑path branch + ok, _, err = cg.validate_exclude_pattern("~/secret.txt", scope) + assert not ok and "Absolute" in err def test_validate_accepts_in_scope_patterns(tmp_path): From 1eed1b16e4cd05e2f227cb48e729b75840b9f278 Mon Sep 17 00:00:00 2001 From: fathriAbanoub Date: Thu, 9 Jul 2026 22:39:29 +0300 Subject: [PATCH 3/5] fix(tests): correct output path assertions in build_command tests The assertions checking for output filenames in cmd were using substring membership on a list, which checks for exact element match. Since the output path is a single list element like '/tmp/.../Glued_Code.txt', checking 'Glued_Code.txt' in cmd would fail. Changed to iterate through cmd elements and check if the substring appears in any element, which correctly validates that the output filename is part of the full path argument. Affected tests: - test_build_command_plain_empty_output_defaults_to_txt - test_build_command_markdown_empty_output_defaults_to_md - test_build_command_custom_output_name_respected --- tests/test_codegluer_gui.py | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/tests/test_codegluer_gui.py b/tests/test_codegluer_gui.py index 3ad4741..9d4329d 100755 --- a/tests/test_codegluer_gui.py +++ b/tests/test_codegluer_gui.py @@ -63,8 +63,8 @@ def test_build_command_plain_empty_output_defaults_to_txt(tmp_path): "target_dir": str(tmp_path), } cmd = cg.build_command([str(src)], opts) - assert "Glued_Code.txt" in cmd - assert "Glued_Code.md" not in cmd + assert any("Glued_Code.txt" in arg for arg in cmd) + assert "Glued_Code.md" not in " ".join(cmd) assert "-r" in cmd assert "--format" in cmd assert "plain" in cmd @@ -83,7 +83,7 @@ def test_build_command_markdown_empty_output_defaults_to_md(tmp_path): "target_dir": str(tmp_path), } cmd = cg.build_command([str(src)], opts) - assert "Glued_Code.md" in cmd + assert any("Glued_Code.md" in arg for arg in cmd) def test_default_name_single_collision_appends_1(tmp_path): @@ -146,8 +146,8 @@ def test_build_command_custom_output_name_respected(tmp_path): "target_dir": str(tmp_path), } cmd = cg.build_command([str(src)], opts) - assert "Glued_Code_custom.md" in cmd - assert "Glued_Code_1.md" not in cmd + assert any("Glued_Code_custom.md" in arg for arg in cmd) + assert "Glued_Code_1.md" not in " ".join(cmd) def test_build_command_exclude_comma_separated(tmp_path): From 32be6d1a76c376af41fdf1ae49804d5ee727a884 Mon Sep 17 00:00:00 2001 From: fathriAbanoub Date: Thu, 9 Jul 2026 23:14:29 +0300 Subject: [PATCH 4/5] fix(tests): isolate gitignore anchoring test from default dir skip The test_gitignore_respect_anchored test was failing after the OOM/freeze guard was added to collect_files(). The guard skips directories like 'build' by default (via DEFAULT_IGNORE_DIR_NAMES), which prevented the test from verifying .gitignore anchoring behavior on a directory named 'build'. Added skip_default_ignore_dirs=False to the collect_files() call in this test to isolate the gitignore anchoring logic from the default directory skip logic. This allows the test to verify that anchored gitignore rules (/build/) work correctly without interference from the OOM guard. --- tests/test_codegluer.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/tests/test_codegluer.py b/tests/test_codegluer.py index 6f68ac2..37ae492 100644 --- a/tests/test_codegluer.py +++ b/tests/test_codegluer.py @@ -400,6 +400,7 @@ def test_include_exclude_with_pathspec(self, tmp_dir): assert len(files) == 2 assert {f.name for f in files} == {"main.py", "test.js"} + # ─── FIXED: skip_default_ignore_dirs=False to isolate gitignore anchoring ─── def test_gitignore_respect_anchored(self, tmp_dir): (tmp_dir / ".gitignore").write_text("/build/\n") (tmp_dir / "build").mkdir() @@ -410,7 +411,8 @@ def test_gitignore_respect_anchored(self, tmp_dir): (tmp_dir / "sub" / "build" / "keep.txt").write_text("keep me") files = codegluer.collect_files( - [str(tmp_dir)], recursive=True, respect_gitignore=True + [str(tmp_dir)], recursive=True, respect_gitignore=True, + skip_default_ignore_dirs=False, # isolate gitignore-anchoring from the OOM-guard dir skip ) file_names = {f.name for f in files} From bc47072e1341da92e47e5cb5d87671f7d9b82a47 Mon Sep 17 00:00:00 2001 From: fathriAbanoub Date: Thu, 9 Jul 2026 23:52:07 +0300 Subject: [PATCH 5/5] fix(gui): refine _looks_heavy() to check directory name, not contents The previous implementation flagged any directory that CONTAINED a heavy subdirectory (e.g., .git, node_modules) as 'heavy', which was too aggressive. This prevented opening the file picker in normal project directories that happen to have a .git folder. The new logic: 1. Checks if the directory ITSELF is a heavy directory (e.g., the path is /home/user/project/node_modules) 2. Falls back to checking if the directory has an excessive number of entries (>500) This allows the file picker to open in normal project directories while still preventing the freeze bug when pointed at actual heavy directories like node_modules, .git, dist, etc. The freeze prevention still works because: - Opening the picker AT node_modules/.git/etc is blocked (step 1) - Opening the picker at a directory with 500+ entries is blocked (step 2) - Normal project directories with a .git folder are now allowed --- codegluer_gui.py | 15 +++++++++------ 1 file changed, 9 insertions(+), 6 deletions(-) diff --git a/codegluer_gui.py b/codegluer_gui.py index 10414e7..a8b638c 100755 --- a/codegluer_gui.py +++ b/codegluer_gui.py @@ -54,19 +54,22 @@ def debug_print(*args, **kwargs): } +# ─── FIXED: only flag the directory ITSELF if it is a heavy hint ────── def _looks_heavy(path: str, scan_limit: int = 500) -> bool: - """Cheap heuristic, not a full walk: does this directory contain a known - dependency/build folder, or an unusually large number of direct entries? + """Cheap heuristic: is this directory itself a known heavy folder, + or does it have an unusually large number of direct entries? Opening a native file picker's initial folder inside something like node_modules is a known way to freeze GTK file choosers while they - enumerate and thumbnail everything — this just avoids that trigger.""" + enumerate and thumbnail everything — this avoids that trigger.""" try: + # Check if the directory itself is a heavy name (e.g., node_modules) + if os.path.basename(os.path.normpath(path)) in _HEAVY_DIR_HINTS: + return True + # Otherwise, scan for number of entries (avoid excessive enumeration) with os.scandir(path) as it: count = 0 - for entry in it: + for _entry in it: count += 1 - if entry.name in _HEAVY_DIR_HINTS and entry.is_dir(): - return True if count > scan_limit: return True except OSError: