T-K603-scan-candidates-deep-gitignore
Status: closed/done · Impact: low · Complexity: medium
Auto-generated from a /sdlc:task-work post-mortem. Review and
promote to ready before picking up.
plugin/scripts/scan_planning_candidates.py currently uses the
pathspec library against the top-level .gitignore only. Repos
with nested .gitignore files (common in monorepos) or globally-
configured excludes will have paths leak through the scanner that
git check-ignore would correctly exclude. Switching to a
git check-ignore shell-out (only when inside a real git repo) closes
that gap and matches user intuition about what “respects .gitignore”
means.
From the T-DHGL-build-import-planning-skill post-mortem:
“
gitignorehandling settled on thepathspeclibrary (top-level.gitignoreonly) rather than shelling out togit check-ignore. Trade-off: works on bare directory trees and test fixtures without agit initstep, but nested.gitignorefiles are not consulted. Acceptable for the haystack-narrowing role the scanner plays; a future scope expansion (deep.gitignoresemantics,.git/info/exclude, globally-configured excludes) would warrant a swap togit check-ignore.”
So today: top-level .gitignore is consulted; nested .gitignore,
.git/info/exclude, and globally-configured excludes are not.
Proposed
Section titled “Proposed”scan_planning_candidates.py probes for a .git directory at scope
root. If present, it batches candidate paths through
git check-ignore --stdin --no-index (or per-path git check-ignore)
to filter them. If .git is absent (bare directory tree, fixture),
fall back to the existing pathspec-against-top-level path.
This preserves the “works on bare directory trees” property used by
the eval fixtures while delivering correct nested-.gitignore
semantics for real repos.
Approach
Section titled “Approach”- Add a
_git_check_ignore_filter(paths, root)helper that shells out togit -C <root> check-ignore --stdinand returns the set of paths git would ignore. Handle the documented exit codes (0 = ignored, 1 = not ignored, 128 = error). - Branch on
(root / ".git").exists()in the main walker: use the new helper when truthy, fall back topathspecotherwise. - Add a fixture: a tree with a nested
.gitignorethat ignores a would-be candidate. Assert the candidate appears under the pathspec path and disappears under the git path. - Keep the existing
gitignore-respectfixture green under both code paths (parametrize the eval case).
Files to touch
Section titled “Files to touch”plugin/scripts/scan_planning_candidates.py— add helper + branch.plugin/skills/import-planning/tests/fixtures/nested-gitignore/(new) — fixture tree with a nested.gitignore.plugin/skills/import-planning/tests/run_evals.py— add the new case; parametrize the existinggitignore-respectcase to cover both code paths.
Acceptance criteria
Section titled “Acceptance criteria”- AC-1:
scan_planning_candidates.pyagainst a repo with a nested.gitignoreexcludingsubdir/notes.mddoes NOT emitsubdir/notes.mdin its candidates list. - AC-2: The existing top-level
.gitignorebehavior remains correct on a fixture that has no.git/directory (the pathspec fallback still works). - AC-3:
run_evals.pyreports the new fixture passing in addition to the existing four (5/5 minimum).
Dependencies
Section titled “Dependencies”- none
Discovery context
Section titled “Discovery context”Spawned by /sdlc:task-work post-mortem of T-DHGL-build-import-planning-skill on 2026-05-19.
Post-mortem
Section titled “Post-mortem”Captured by /sdlc:task-work on 2026-05-19. PR: pending.
Acceptance criteria coverage
Section titled “Acceptance criteria coverage”- AC-1: auto —
nested-gitignoreeval case assertssubdir/ignored-by-nested.mdis excluded from candidates when scanning a tree whosesubdir/.gitignorelists it. Also asserts a.git/info/excludeentry is honoured. - AC-2: auto — the existing
gitignore-respectcase still runs against the fixture as-is (no.git/dir) and passes; the pathspec fallback is preserved when_has_git_dir(project_root)is false. - AC-3: auto —
plugin/skills/import-planning/tests/run_evals.pyreports6/6 eval case(s) passed(mixed, nothing-planning, milestone-vs-task, gitignore-respect, gitignore-respect-git, nested-gitignore).
What worked
Section titled “What worked”- The existing eval-runner shape (fixture-per-case, copy-to-tmp, JSON-assertion) extended cleanly:
adding
git_init+git_info_excludeknobs to theCasedataclass and onesubprocess.run(["git", "init", ...])line covered both code paths without inventing a new harness. - Reusing the
gitignore-respectfixture under two cases (one withgit_init=False, one withgit_init=True) gave the “parametrize the existing case” coverage the task spec asked for at near-zero fixture cost.Case.fixtureoverridingCase.namemade the cross-reference explicit. - Batching paths through one
git check-ignore --stdin -zcall kept the cost O(1) in invocations regardless of how many candidates the walk produces. NUL-delimited mode is the only path-safe form.
Friction and automation gaps
Section titled “Friction and automation gaps”git check-ignoreexit code 128 is overloaded (“not in a repo” and “fatal error” both yield it). The implementation chose to treat 128 as “nothing ignored” so the scanner gracefully degrades rather than silently dropping the whole walk. This is the conservative call but means a misconfigured repo would emit candidates that git would otherwise hide. Worth surfacing in user-facing docs if/when the scanner ever reports stats.fishshell mangled the initialgit commit -m "$(cat <<EOF...EOF)"invocation — same heredoc-in-substitution pattern that bit the parent task. Fell back togit commit -F /tmp/<file>. Already tracked under T-UEU9-document-commit-message-pattern.- The eval runner now silently depends on
gitbeing on PATH. Acceptable (every contributor environment has it), but worth a one-line check at runner top if we ever vendor this skill into a sandbox without git.
Spawned follow-up tasks
Section titled “Spawned follow-up tasks”- none
Out of scope
Section titled “Out of scope”- none