Skip to content

Commit c66f2cc

Browse files
authored
feat: .repokgignore loading + precedence tests + README (2/2) (#40)
- code.load_ignore(repo): <repo>/.repokgignore, one glob per line, # comments and blank lines skipped — same semantics as --exclude, committed so the whole team shares it - CLI unions ignore-file patterns with --exclude flags (deduped) for scan and generate; no flags needed once the file is committed - end-to-end precedence test: scan on a git-init'd tree with both sources, asserting kg.json patterns/counts and pruned modules - README: 'Excluding paths' section + roadmap checkbox Closes #4.
1 parent 606802e commit c66f2cc

4 files changed

Lines changed: 102 additions & 6 deletions

File tree

README.md

Lines changed: 26 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -72,7 +72,31 @@ timeline eras, and gotchas alongside the deterministic structure.
7272
| `repokg check [path]` | Exit 1 if the knowledge graph is stale vs `HEAD` (CI-friendly) |
7373

7474
Flags: `--out DIR` (default `<repo>/.repokg`), `--md FILE` (default `<repo>/KNOWLEDGE_GRAPH.md`),
75-
`--no-github`, `--pr-limit N`, `--diff`, `--json`.
75+
`--exclude PATTERN` (repeatable), `--no-github`, `--pr-limit N`, `--diff`, `--json`.
76+
77+
### Excluding paths
78+
79+
Common noise (`node_modules`, `.git`, build output, …) is skipped automatically.
80+
For repo-specific noise — fixtures, snapshots, vendored trees, generated docs —
81+
add globs on the command line or in a committed `.repokgignore` at the repo root:
82+
83+
```sh
84+
repokg scan --exclude '*fixtures' --exclude 'docs/gen'
85+
```
86+
87+
```
88+
# .repokgignore — one glob per line, same semantics as --exclude
89+
*fixtures
90+
*.snap
91+
packages/*/gen
92+
```
93+
94+
Patterns are matched (`fnmatch`) against repo-relative paths; matching
95+
directories are pruned wholesale and matching files dropped, so modules, import
96+
edges, and ops all inherit the exclusion. `*` crosses `/`: `*fixtures` matches
97+
at any depth, `fixtures` only at the root. CLI patterns and `.repokgignore` are
98+
unioned. Exclusions are never silent — `scan` prints what it dropped, `kg.json`
99+
records the patterns and counts, and `repokg audit` carries an uncertainty note.
76100

77101
## Honesty layer
78102

@@ -157,7 +181,7 @@ schema — everything else stays deterministic and reproducible.
157181

158182
- [x] Rust import graph
159183
- [x] Java / Kotlin import graphs
160-
- [ ] `--exclude` glob patterns
184+
- [x] `--exclude` glob patterns + `.repokgignore`
161185
- [ ] `llms.txt` emission alongside KNOWLEDGE_GRAPH.md
162186
- [x] tsconfig `paths` alias + workspace package resolution
163187
- [ ] PyPI release + prebuilt GitHub Action

src/repokg/cli.py

Lines changed: 7 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -164,7 +164,9 @@ def main(argv=None):
164164
ap.add_argument("--exclude", action="append", default=[], metavar="PATTERN",
165165
help="glob matched against repo-relative paths; matching "
166166
"dirs are pruned, matching files dropped (repeatable; "
167-
"`*` crosses `/`, so '*fixtures' matches any depth)")
167+
"`*` crosses `/`, so '*fixtures' matches any depth). "
168+
"Unioned with <repo>/.repokgignore (one glob per "
169+
"line, # comments)")
168170
ap.add_argument("--no-github", action="store_true", help="skip gh PR lookup")
169171
ap.add_argument("--pr-limit", type=int, default=1000, help="max PRs to fetch (default 1000)")
170172
ap.add_argument("--diff", action="store_true",
@@ -182,10 +184,12 @@ def main(argv=None):
182184
return 2
183185
out = args.out or os.path.join(repo, ".repokg")
184186
md = args.md or os.path.join(repo, "KNOWLEDGE_GRAPH.md")
187+
# union of CLI flags and the committed ignore file, deduped
188+
exclude = list(dict.fromkeys(args.exclude + code.load_ignore(repo)))
185189

186190
try:
187191
if args.command == "scan":
188-
scan(repo, out, args.no_github, args.pr_limit, args.exclude)
192+
scan(repo, out, args.no_github, args.pr_limit, exclude)
189193
elif args.command == "prompts":
190194
write_prompts(repo, out, md)
191195
elif args.command == "render":
@@ -199,7 +203,7 @@ def main(argv=None):
199203
elif args.command == "check":
200204
return check(repo, out, md)
201205
else: # generate
202-
scan(repo, out, args.no_github, args.pr_limit, args.exclude)
206+
scan(repo, out, args.no_github, args.pr_limit, exclude)
203207
write_prompts(repo, out, md)
204208
return render(out, md)
205209
except RuntimeError as e:

src/repokg/code.py

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -39,6 +39,23 @@
3939
MAX_FILE_BYTES = 2_000_000
4040

4141

42+
IGNORE_FILE = ".repokgignore"
43+
44+
45+
def load_ignore(repo):
46+
"""Patterns from <repo>/.repokgignore: one glob per line, # comments.
47+
48+
Same semantics as --exclude; committed so the whole team shares it.
49+
"""
50+
try:
51+
with open(os.path.join(repo, IGNORE_FILE), encoding="utf-8") as f:
52+
lines = f.read().splitlines()
53+
except OSError:
54+
return []
55+
stripped = (ln.strip() for ln in lines)
56+
return [ln for ln in stripped if ln and not ln.startswith("#")]
57+
58+
4259
def _excluded(rel, exclude):
4360
return any(fnmatch.fnmatch(rel, pat) for pat in exclude)
4461

tests/test_exclude.py

Lines changed: 52 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,12 @@
1+
import contextlib
2+
import io
3+
import json
14
import os
5+
import subprocess
26
import tempfile
37
import unittest
48

5-
from repokg.code import walk
9+
from repokg.code import load_ignore, walk
610
from repokg.findings import build
711

812

@@ -97,5 +101,52 @@ def test_no_note_without_exclude_key(self):
97101
self.assertFalse([n for n in notes if "invisible" in n])
98102

99103

104+
class TestRepokgIgnore(unittest.TestCase):
105+
def setUp(self):
106+
self.tmp = tempfile.TemporaryDirectory()
107+
self.repo = self.tmp.name
108+
109+
def tearDown(self):
110+
self.tmp.cleanup()
111+
112+
def test_missing_file_is_empty(self):
113+
self.assertEqual(load_ignore(self.repo), [])
114+
115+
def test_comments_and_blanks_skipped(self):
116+
write(self.repo, ".repokgignore",
117+
"# vendored trees\n"
118+
"*fixtures\n"
119+
"\n"
120+
" docs/gen \n"
121+
" \n"
122+
"# eof\n")
123+
self.assertEqual(load_ignore(self.repo), ["*fixtures", "docs/gen"])
124+
125+
def test_ignore_file_patterns_prune_walk(self):
126+
write(self.repo, ".repokgignore", "*fixtures\n")
127+
write(self.repo, "src/app.py")
128+
write(self.repo, "src/fixtures/big.py")
129+
seen = {rel for rel, _ in walk(self.repo, load_ignore(self.repo))}
130+
self.assertIn("src", seen)
131+
self.assertNotIn("src/fixtures", seen)
132+
133+
def test_scan_unions_cli_and_ignore_file(self):
134+
from repokg.cli import main
135+
subprocess.run(["git", "init", "-q", self.repo], check=True)
136+
write(self.repo, "src/app.py")
137+
write(self.repo, "src/fixtures/big.py")
138+
# *.snap in both places: union must dedupe
139+
write(self.repo, ".repokgignore", "# vendored\n*fixtures\n*.snap\n")
140+
with contextlib.redirect_stdout(io.StringIO()) as buf:
141+
rc = main(["scan", self.repo, "--no-github", "--exclude", "*.snap"])
142+
self.assertEqual(rc, 0)
143+
with open(os.path.join(self.repo, ".repokg", "kg.json")) as f:
144+
kg = json.load(f)
145+
self.assertEqual(kg["exclude"]["patterns"], ["*.snap", "*fixtures"])
146+
self.assertEqual(kg["exclude"]["dirs"], 1)
147+
self.assertNotIn("src/fixtures", [m["path"] for m in kg["modules"]])
148+
self.assertIn("excluded 1 dirs", buf.getvalue())
149+
150+
100151
if __name__ == "__main__":
101152
unittest.main()

0 commit comments

Comments
 (0)