Merge .graphifyignore with .gitignore instead of replacing it (#1363)

A .graphifyignore made graphify skip that directory's .gitignore entirely, so a
file excluded only by .gitignore (including neutrally-named secrets the
sensitive-file heuristic misses) got indexed into the graph and could leak into
committed graph artifacts. Read .gitignore first and .graphifyignore last so
their patterns merge and graphifyignore negations still win.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Safi
2026-06-18 01:15:24 +01:00
co-authored by Claude Opus 4.8
parent 9e0b8766f4
commit b2a1722903
4 changed files with 46 additions and 18 deletions
+2
View File
@@ -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)
+1 -1
View File
@@ -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
+17 -10
View File
@@ -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
+26 -7
View File
@@ -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