Skip to content

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:

gitignore handling settled on the pathspec library (top-level .gitignore only) rather than shelling out to git check-ignore. Trade-off: works on bare directory trees and test fixtures without a git init step, but nested .gitignore files are not consulted. Acceptable for the haystack-narrowing role the scanner plays; a future scope expansion (deep .gitignore semantics, .git/info/exclude, globally-configured excludes) would warrant a swap to git check-ignore.”

So today: top-level .gitignore is consulted; nested .gitignore, .git/info/exclude, and globally-configured excludes are not.

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.

  1. Add a _git_check_ignore_filter(paths, root) helper that shells out to git -C <root> check-ignore --stdin and returns the set of paths git would ignore. Handle the documented exit codes (0 = ignored, 1 = not ignored, 128 = error).
  2. Branch on (root / ".git").exists() in the main walker: use the new helper when truthy, fall back to pathspec otherwise.
  3. Add a fixture: a tree with a nested .gitignore that ignores a would-be candidate. Assert the candidate appears under the pathspec path and disappears under the git path.
  4. Keep the existing gitignore-respect fixture green under both code paths (parametrize the eval case).
  • 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 existing gitignore-respect case to cover both code paths.
  • AC-1: scan_planning_candidates.py against a repo with a nested .gitignore excluding subdir/notes.md does NOT emit subdir/notes.md in its candidates list.
  • AC-2: The existing top-level .gitignore behavior remains correct on a fixture that has no .git/ directory (the pathspec fallback still works).
  • AC-3: run_evals.py reports the new fixture passing in addition to the existing four (5/5 minimum).
  • none

Spawned by /sdlc:task-work post-mortem of T-DHGL-build-import-planning-skill on 2026-05-19.

Captured by /sdlc:task-work on 2026-05-19. PR: pending.

  • AC-1: auto — nested-gitignore eval case asserts subdir/ignored-by-nested.md is excluded from candidates when scanning a tree whose subdir/.gitignore lists it. Also asserts a .git/info/exclude entry is honoured.
  • AC-2: auto — the existing gitignore-respect case 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.py reports 6/6 eval case(s) passed (mixed, nothing-planning, milestone-vs-task, gitignore-respect, gitignore-respect-git, nested-gitignore).
  • The existing eval-runner shape (fixture-per-case, copy-to-tmp, JSON-assertion) extended cleanly: adding git_init + git_info_exclude knobs to the Case dataclass and one subprocess.run(["git", "init", ...]) line covered both code paths without inventing a new harness.
  • Reusing the gitignore-respect fixture under two cases (one with git_init=False, one with git_init=True) gave the “parametrize the existing case” coverage the task spec asked for at near-zero fixture cost. Case.fixture overriding Case.name made the cross-reference explicit.
  • Batching paths through one git check-ignore --stdin -z call kept the cost O(1) in invocations regardless of how many candidates the walk produces. NUL-delimited mode is the only path-safe form.
  • git check-ignore exit 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.
  • fish shell mangled the initial git commit -m "$(cat <<EOF...EOF)" invocation — same heredoc-in-substitution pattern that bit the parent task. Fell back to git commit -F /tmp/<file>. Already tracked under T-UEU9-document-commit-message-pattern.
  • The eval runner now silently depends on git being 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.
  • none
  • none

← Back to Tasks