Skip to content

Commit d2f08cc

Browse files
committed
fix(detect): scope memory-tree ignore to .graphifyignore provenance (#2267)
Maintainer feedback on PR #2272: the one-line guard removal was too broad — ignore_patterns includes .gitignore and $GIT_DIR/info/exclude, not just .graphifyignore. Since graphify-out/ is generated output commonly placed in .gitignore, dropping the guard silently disables the memory round-trip by default. Track pattern provenance so memory files respect ONLY .graphifyignore-sourced patterns: - Add source tag (3rd tuple element) to every ignore pattern: 'gitignore', 'graphifyignore', 'info_exclude', 'cli_exclude' - _load_dir_own_ignore: tags .gitignore vs .graphifyignore per entry - _load_graphifyignore: tags info/exclude + propagates dir tags - _is_ignored: new optional 'sources' param filters by provenance - detect(): memory tree calls _is_ignored with sources={'graphifyignore'} Regression tests: - test_gitignored_graphify_out_keeps_memory_included: graphify-out/ in .gitignore → memory files stay included (default unchanged) - test_info_exclude_does_not_drop_memory: graphify-out/ in info/exclude → memory files stay included - test_graphifyignore_excludes_memory_tree: user .graphifyignore still excludes memory tree as before
1 parent ed40a48 commit d2f08cc

2 files changed

Lines changed: 120 additions & 15 deletions

File tree

graphify/detect.py

Lines changed: 61 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -965,7 +965,15 @@ def _git_info_exclude(vcs_root: Path) -> Path | None:
965965
return exclude if exclude.is_file() else None
966966

967967

968-
def _load_dir_own_ignore(d: Path, *, gitignore: bool = True) -> list[tuple[Path, str]]:
968+
_IGNORE_SOURCE_GITIGNORE = "gitignore"
969+
_IGNORE_SOURCE_GRAPHIFYIGNORE = "graphifyignore"
970+
_IGNORE_SOURCE_INFO_EXCLUDE = "info_exclude"
971+
_IGNORE_SOURCE_CLI_EXCLUDE = "cli_exclude"
972+
973+
974+
def _load_dir_own_ignore(
975+
d: Path, *, gitignore: bool = True
976+
) -> list[tuple[Path, str, str]]:
969977
"""Read .gitignore/.graphifyignore directly inside *d* (not its ancestors).
970978
971979
Merges .gitignore and .graphifyignore for this one directory (#1363):
@@ -975,25 +983,36 @@ def _load_dir_own_ignore(d: Path, *, gitignore: bool = True) -> list[tuple[Path,
975983
.gitignore-excluded file (#945 kept: a dir with only a .gitignore still
976984
gets sensible defaults).
977985
986+
Each pattern is tagged with its source so callers can apply only a subset
987+
of origins — e.g. memory-tree files should respect ``.graphifyignore``
988+
but NOT ``.gitignore`` (generated output like ``graphify-out/`` is
989+
typically gitignored, and honouring that there would silently drop memory
990+
files).
991+
978992
Shared by `_load_graphifyignore` (ancestor chain, loaded once before the
979993
scan) and the live os.walk loop in `detect()` (called per-directory as
980994
each descendant is visited), so nested ignore files *below* the scan
981995
root are honored too — previously only the scan root and its ancestors
982996
were read, so e.g. `vendor/sub/.gitignore` was silently ignored (#1206).
983997
"""
984-
patterns: list[tuple[Path, str]] = []
985-
for fname in ((".gitignore", ".graphifyignore") if gitignore else (".graphifyignore",)):
998+
patterns: list[tuple[Path, str, str]] = []
999+
for fname, source in (
1000+
((".gitignore", _IGNORE_SOURCE_GITIGNORE),
1001+
(".graphifyignore", _IGNORE_SOURCE_GRAPHIFYIGNORE))
1002+
if gitignore
1003+
else ((".graphifyignore", _IGNORE_SOURCE_GRAPHIFYIGNORE),)
1004+
):
9861005
ignore_file = d / fname
9871006
if ignore_file.exists():
9881007
for raw in ignore_file.read_text(encoding="utf-8-sig", errors="ignore").splitlines():
9891008
line = _parse_gitignore_line(raw)
9901009
if line:
991-
patterns.append((d, line))
1010+
patterns.append((d, line, source))
9921011
return patterns
9931012

9941013

995-
def _load_graphifyignore(root: Path, *, gitignore: bool = True) -> list[tuple[Path, str]]:
996-
"""Read .graphifyignore files and return (anchor_dir, pattern) pairs.
1014+
def _load_graphifyignore(root: Path, *, gitignore: bool = True) -> list[tuple[Path, str, str]]:
1015+
"""Read .graphifyignore files and return (anchor_dir, pattern, source) triples.
9971016
9981017
Patterns are returned outer-first so that inner (closer) rules are
9991018
appended last and win via last-match-wins semantics — matching gitignore
@@ -1005,6 +1024,9 @@ def _load_graphifyignore(root: Path, *, gitignore: bool = True) -> list[tuple[Pa
10051024
Covers the scan root and its ancestors only — directories *below* the
10061025
scan root are picked up live during the os.walk in `detect()` instead,
10071026
since they aren't known until the walk reaches them (#1206).
1027+
1028+
The third element of each tuple records provenance so callers can apply
1029+
only patterns from a specific origin (see ``_IGNORE_SOURCE_*`` constants).
10081030
"""
10091031
root = root.resolve()
10101032
ceiling = _find_vcs_root(root) or root
@@ -1019,7 +1041,7 @@ def _load_graphifyignore(root: Path, *, gitignore: bool = True) -> list[tuple[Pa
10191041
current = current.parent
10201042
dirs.reverse() # ceiling first, scan root last
10211043

1022-
patterns: list[tuple[Path, str]] = []
1044+
patterns: list[tuple[Path, str, str]] = []
10231045

10241046
# $GIT_DIR/info/exclude is repo-root-scoped and, per git, ranks below every
10251047
# per-directory .gitignore/.graphifyignore — so load it first (lowest priority
@@ -1030,7 +1052,7 @@ def _load_graphifyignore(root: Path, *, gitignore: bool = True) -> list[tuple[Pa
10301052
for raw in info_exclude.read_text(encoding="utf-8-sig", errors="ignore").splitlines():
10311053
line = _parse_gitignore_line(raw)
10321054
if line:
1033-
patterns.append((ceiling, line))
1055+
patterns.append((ceiling, line, _IGNORE_SOURCE_INFO_EXCLUDE))
10341056

10351057
for d in dirs:
10361058
patterns.extend(_load_dir_own_ignore(d, gitignore=gitignore))
@@ -1068,11 +1090,12 @@ def _matches(path_idx: int, pattern_idx: int) -> bool:
10681090
def _is_ignored(
10691091
path: Path,
10701092
root: Path,
1071-
patterns: list[tuple[Path, str]],
1093+
patterns: list[tuple[Path, str, str]],
10721094
*,
10731095
_cache: dict[Path, bool] | None = None,
1096+
sources: set[str] | None = None,
10741097
) -> bool:
1075-
"""Return True if the path should be ignored per .graphifyignore patterns.
1098+
"""Return True if the path should be ignored per ignore patterns.
10761099
10771100
Uses gitignore last-match-wins semantics: all patterns are evaluated in
10781101
order; the final matching pattern determines the result. Negation patterns
@@ -1084,6 +1107,12 @@ def _is_ignored(
10841107
_cache: optional dict shared across calls within the same scan. Ancestor
10851108
directory results are memoised so files under the same subtree don't
10861109
re-evaluate the same patterns repeatedly.
1110+
1111+
sources: optional set of provenance tags to filter by. When given, only
1112+
patterns whose third element is in ``sources`` are evaluated; all others
1113+
are skipped. Used to apply ONLY ``.graphifyignore``-sourced patterns to
1114+
the memory tree (so a gitignored ``graphify-out/`` doesn't silently drop
1115+
memory files) while keeping full ``.gitignore`` respect for the code tree.
10871116
"""
10881117
if not patterns:
10891118
return False
@@ -1108,7 +1137,12 @@ def _matches(rel: str, p: str, path_relative: bool) -> bool:
11081137
return False
11091138

11101139
result = False
1111-
for anchor, pattern in patterns:
1140+
for entry in patterns:
1141+
anchor, pattern = entry[0], entry[1]
1142+
if sources is not None:
1143+
source = entry[2] if len(entry) > 2 else None
1144+
if source not in sources:
1145+
continue
11121146
negated = pattern.startswith("!")
11131147
raw = pattern[1:] if negated else pattern
11141148
directory_only = raw.endswith("/")
@@ -1231,7 +1265,7 @@ def _wc(path: Path) -> int:
12311265
for pat in extra_excludes:
12321266
line = _parse_gitignore_line(pat)
12331267
if line:
1234-
ignore_patterns.append((root, line))
1268+
ignore_patterns.append((root, line, _IGNORE_SOURCE_CLI_EXCLUDE))
12351269

12361270
# Always include graphify-out/memory/ - query results filed back into the graph
12371271
memory_dir = root / GRAPHIFY_OUT / "memory"
@@ -1334,9 +1368,21 @@ def _on_walk_error(err: OSError) -> None:
13341368
# Skip files inside our own converted/ dir (avoid re-processing sidecars)
13351369
if str(p).startswith(str(converted_dir)):
13361370
continue
1337-
if _is_ignored(p, root, ignore_patterns, _cache=ignore_cache):
1338-
ignored.append(str(p))
1339-
continue
1371+
# Memory-tree files respect ONLY .graphifyignore patterns — not
1372+
# .gitignore or $GIT_DIR/info/exclude. ``graphify-out/`` is generated
1373+
# output and is commonly gitignored; applying .gitignore to memory
1374+
# would silently drop every memory file (#2267).
1375+
if in_memory:
1376+
if _is_ignored(
1377+
p, root, ignore_patterns, _cache=ignore_cache,
1378+
sources={_IGNORE_SOURCE_GRAPHIFYIGNORE},
1379+
):
1380+
ignored.append(str(p))
1381+
continue
1382+
else:
1383+
if _is_ignored(p, root, ignore_patterns, _cache=ignore_cache):
1384+
ignored.append(str(p))
1385+
continue
13401386
if not _resolves_under_root(p, root):
13411387
skipped_sensitive.append(str(p) + " [symlink target outside scan root]")
13421388
continue

tests/test_detect.py

Lines changed: 59 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2596,3 +2596,62 @@ def test_graphifyignore_excludes_memory_tree(tmp_path):
25962596
memory_hits = [f for f in all_files if "graphify-out" in f.replace("\\", "/")]
25972597
assert not memory_hits, \
25982598
"memory tree must be excludable via .graphifyignore, but got: %s" % memory_hits
2599+
2600+
2601+
def test_gitignored_graphify_out_keeps_memory_included(tmp_path):
2602+
"""#2267 regression: a gitignored graphify-out/ must NOT drop memory files.
2603+
2604+
``graphify-out/`` is generated output and is commonly placed in
2605+
``.gitignore``. The memory-tree ignore check is scoped to
2606+
``.graphifyignore``-sourced patterns only, so a gitignored
2607+
``graphify-out/`` still keeps memory files included — the default
2608+
memory round-trip stays intact.
2609+
"""
2610+
(tmp_path / "src").mkdir()
2611+
(tmp_path / "src" / "calc.py").write_text("def add(a, b):\n return a + b\n")
2612+
2613+
# First detect seeds graphify-out/memory/ (the feedback loop).
2614+
detect(tmp_path)
2615+
2616+
memory = tmp_path / "graphify-out" / "memory"
2617+
memory.mkdir(parents=True, exist_ok=True)
2618+
(memory / "query_1.md").write_text("# Query\nThe calc module adds numbers.\n")
2619+
2620+
# graphify-out/ is in .gitignore (common practice for generated output).
2621+
(tmp_path / ".gitignore").write_text("graphify-out/\n")
2622+
2623+
result = detect(tmp_path)
2624+
all_files = [f for files in result["files"].values() for f in files]
2625+
memory_hits = [f for f in all_files if "graphify-out" in f.replace("\\", "/")]
2626+
assert memory_hits, \
2627+
"memory files must stay included even when graphify-out/ is gitignored"
2628+
2629+
2630+
def test_info_exclude_does_not_drop_memory(tmp_path):
2631+
"""#2267 regression: $GIT_DIR/info/exclude must NOT drop memory files.
2632+
2633+
Same rationale as .gitignore: info/exclude is a git-sourced ignore,
2634+
not a user's explicit .graphifyignore. Memory files must survive.
2635+
"""
2636+
(tmp_path / "src").mkdir()
2637+
(tmp_path / "src" / "calc.py").write_text("def add(a, b):\n return a + b\n")
2638+
2639+
# First detect seeds graphify-out/memory/ (the feedback loop).
2640+
detect(tmp_path)
2641+
2642+
memory = tmp_path / "graphify-out" / "memory"
2643+
memory.mkdir(parents=True, exist_ok=True)
2644+
(memory / "query_1.md").write_text("# Query\nThe calc module adds numbers.\n")
2645+
2646+
# Stage a git repo so $GIT_DIR/info/exclude exists.
2647+
git_dir = tmp_path / ".git"
2648+
(git_dir / "info").mkdir(parents=True, exist_ok=True)
2649+
(git_dir / "info" / "exclude").write_text("graphify-out/\n")
2650+
# Minimal HEAD so _git_info_exclude recognises this as a git dir.
2651+
(git_dir / "HEAD").write_text("ref: refs/heads/main\n")
2652+
2653+
result = detect(tmp_path)
2654+
all_files = [f for files in result["files"].values() for f in files]
2655+
memory_hits = [f for f in all_files if "graphify-out" in f.replace("\\", "/")]
2656+
assert memory_hits, \
2657+
"memory files must stay included even when graphify-out/ is in info/exclude"

0 commit comments

Comments
 (0)