diff --git a/CHANGELOG.md b/CHANGELOG.md index 78ff440..1827df2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,8 @@ Full release notes with details on each version: [GitHub Releases](https://githu ## Unreleased +- Security fix: `.graphifyignore` and `.gitignore` are now **merged** per directory instead of `.graphifyignore` silently replacing that directory's `.gitignore`. Previously, adding a `.graphifyignore` (e.g. to exclude media) disabled the dir's `.gitignore` entirely, so a file excluded only by `.gitignore` — including neutrally-named secrets like `prod-dump.sql` or `customer-data.json` that the sensitive-file heuristic doesn't catch — got indexed into the graph, whose artifacts embed file contents and are routinely committed. `.gitignore` is read first and `.graphifyignore` last, so `.graphifyignore` patterns (including `!` negations) still win on conflict; adding one can only ever exclude more, never re-include a `.gitignore`-excluded file. (#1363) + ## 0.8.41 (2026-06-17) - Fix: OpenAI-compatible backends (`ollama`, `openai`, `deepseek`, `kimi`) now honour their configured `16384` output-token cap instead of silently falling back to `8192`. The dispatch read a `max_completion_tokens` config key that only `gemini` defines; the others set `max_tokens`, so their advertised cap was dead and deep-mode JSON truncated mid-string (recovered by the adaptive bisect, but noisy and slower). The dispatch now reads either key, and the `openai` config gained an explicit cap. `GRAPHIFY_MAX_OUTPUT_TOKENS` still overrides. (#1365) diff --git a/README.md b/README.md index 99cf525..918271b 100644 --- a/README.md +++ b/README.md @@ -304,7 +304,7 @@ See the [full command reference](#full-command-reference) below. Create a `.graphifyignore` in your project root — same syntax as `.gitignore`, including `!` negation. -**`.gitignore` is respected automatically.** If no `.graphifyignore` is present in a directory, graphify falls back to the `.gitignore` in that directory. If both exist, `.graphifyignore` takes priority. Subdirectory scoping works the same way as git — an ignore file only affects its own subtree. +**`.gitignore` is respected automatically.** graphify reads the `.gitignore` in each directory. If a `.graphifyignore` is also present, the two are **merged** — `.graphifyignore` patterns are evaluated last, so they win on conflicts (including `!` negations). Adding a `.graphifyignore` only ever excludes more; it never re-includes a file your `.gitignore` already excluded. Subdirectory scoping works the same way as git — an ignore file only affects its own subtree. ``` # .graphifyignore diff --git a/graphify/detect.py b/graphify/detect.py index 78330c4..ca8bb85 100644 --- a/graphify/detect.py +++ b/graphify/detect.py @@ -757,16 +757,23 @@ def _load_graphifyignore(root: Path) -> list[tuple[Path, str]]: patterns: list[tuple[Path, str]] = [] for d in dirs: - # Prefer .graphifyignore; fall back to .gitignore so projects that already - # maintain a .gitignore get sensible defaults without duplicating it (#945). - ignore_file = d / ".graphifyignore" - if not ignore_file.exists(): - ignore_file = d / ".gitignore" - if ignore_file.exists(): - for raw in ignore_file.read_text(encoding="utf-8", errors="ignore").splitlines(): - line = _parse_gitignore_line(raw) - if line: - patterns.append((d, line)) + # Merge .gitignore and .graphifyignore for this dir (#1363). Previously + # the presence of a .graphifyignore made graphify skip that dir's + # .gitignore entirely, so a file excluded only by .gitignore (e.g. a + # neutrally-named secret like prod-dump.sql) silently got indexed into + # the graph — whose artifacts embed file contents and are often + # committed. .gitignore is read first and .graphifyignore last, so + # .graphifyignore patterns (including `!` negations) win on conflict via + # last-match-wins; adding a .graphifyignore can only ever exclude MORE, + # never re-include a .gitignore-excluded file (#945 kept: a project with + # only a .gitignore still gets sensible defaults). + for fname in (".gitignore", ".graphifyignore"): + ignore_file = d / fname + if ignore_file.exists(): + for raw in ignore_file.read_text(encoding="utf-8", errors="ignore").splitlines(): + line = _parse_gitignore_line(raw) + if line: + patterns.append((d, line)) return patterns diff --git a/tests/test_detect.py b/tests/test_detect.py index fb8b18d..76282f2 100644 --- a/tests/test_detect.py +++ b/tests/test_detect.py @@ -922,19 +922,38 @@ def test_gitignore_fallback_when_no_graphifyignore(tmp_path): assert not any("generated" in f for f in code) -def test_graphifyignore_takes_precedence_over_gitignore(tmp_path): - """When both exist, .graphifyignore is used and .gitignore is ignored (#945).""" +def test_graphifyignore_and_gitignore_are_merged(tmp_path): + """When both exist, their patterns are MERGED — a file excluded only by + .gitignore stays excluded even though .graphifyignore says nothing about it + (#1363). Previously the presence of a .graphifyignore silently disabled the + dir's .gitignore, leaking gitignore-only secrets into the graph.""" (tmp_path / ".git").mkdir() - # .gitignore would exclude main.py; .graphifyignore excludes only other.py - (tmp_path / ".gitignore").write_text("main.py\n") - (tmp_path / ".graphifyignore").write_text("other.py\n") + (tmp_path / ".gitignore").write_text("main.py\n") # gitignore-only exclusion + (tmp_path / ".graphifyignore").write_text("other.py\n") # says nothing about main.py (tmp_path / "main.py").write_text("x = 1") (tmp_path / "other.py").write_text("x = 2") + (tmp_path / "keep.py").write_text("x = 3") result = detect(tmp_path) code = result["files"]["code"] - assert any("main.py" in f for f in code) # gitignore NOT applied - assert not any("other.py" in f for f in code) # graphifyignore IS applied + assert not any("main.py" in f for f in code) # gitignore STILL applied (merged) + assert not any("other.py" in f for f in code) # graphifyignore applied + assert any("keep.py" in f for f in code) # neither excludes it + + +def test_graphifyignore_negation_overrides_gitignore(tmp_path): + """.graphifyignore is evaluated after .gitignore, so a `!` negation in it can + re-include a file the .gitignore excluded (last-match-wins, #1363).""" + (tmp_path / ".git").mkdir() + (tmp_path / ".gitignore").write_text("*.py\n") # exclude all .py + (tmp_path / ".graphifyignore").write_text("!keep.py\n") # but rescue keep.py + (tmp_path / "main.py").write_text("x = 1") + (tmp_path / "keep.py").write_text("x = 2") + + result = detect(tmp_path) + code = result["files"]["code"] + assert any("keep.py" in f for f in code) # rescued by graphifyignore negation + assert not any("main.py" in f for f in code) # still excluded # Regression tests for #947 - .worktrees/ skipped and --exclude flag