Skip to content

Commit 5b2d0b2

Browse files
sakitACopilot
andcommitted
feat: hybrid nested repo discovery explicit config + .gitignore fallback
Replace hardcoded skip list with two-tier discovery: 1. Explicit paths: If nested_repos is defined in init-options.json, validate and return those paths directly (no scanning). 2. .gitignore-based scan: If no explicit config, scan directories but use git check-ignore to skip gitignored dirs instead of a hardcoded skip list. Only .git is hardcoded. Dirs with their own .git are always treated as nested repos (even if gitignored in parent). - Bash: find_nested_git_repos() accepts optional explicit paths (3rd+ args) - PowerShell: Find-NestedGitRepos accepts -ExplicitPaths param - setup-plan.sh/.ps1: reads nested_repos from init-options.json - Python fallback for JSON parsing when jq is unavailable - Windows \r line-ending handling in Python subprocess output - 18 tests: discovery, gitignore filtering, explicit paths, depth, init-options Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
1 parent 749e95b commit 5b2d0b2

File tree

5 files changed

+194
-34
lines changed

5 files changed

+194
-34
lines changed

β€Žscripts/bash/common.shβ€Ž

Lines changed: 30 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -282,29 +282,36 @@ check_dir() { [[ -d "$1" && -n $(ls -A "$1" 2>/dev/null) ]] && echo " βœ“ $2" |
282282
# Searches up to $max_depth directory levels deep for subdirectories containing
283283
# .git (directory or file, covering worktrees/submodules). Excludes the root
284284
# repo itself and common non-project directories.
285-
# Usage: find_nested_git_repos [repo_root] [max_depth]
286-
# repo_root β€” defaults to $(get_repo_root)
287-
# max_depth β€” defaults to 2
285+
# Usage: find_nested_git_repos [repo_root] [max_depth] [explicit_paths...]
286+
# repo_root β€” defaults to $(get_repo_root)
287+
# max_depth β€” defaults to 2
288+
# explicit_paths β€” if provided (3rd arg onward), validate and return these directly (no scanning)
288289
# Outputs one absolute path per line.
289290
find_nested_git_repos() {
290291
local repo_root="${1:-$(get_repo_root)}"
291292
local max_depth="${2:-2}"
292-
# Directories to skip during traversal
293-
local -a skip_dirs=(".specify" ".git" "node_modules" "vendor" ".venv" "venv"
294-
"__pycache__" ".gradle" "build" "dist" "target" ".idea"
295-
".vscode" "specs")
296293

294+
# Collect explicit paths from 3rd argument onward
295+
local -a explicit_paths=()
296+
if [ $# -ge 3 ]; then
297+
shift 2
298+
explicit_paths=("$@")
299+
fi
300+
301+
# If explicit paths are provided, validate and return them directly
302+
if [ ${#explicit_paths[@]} -gt 0 ]; then
303+
for rel_path in "${explicit_paths[@]}"; do
304+
local abs_path="$repo_root/$rel_path"
305+
if [ -e "$abs_path/.git" ] && git -C "$abs_path" rev-parse --is-inside-work-tree >/dev/null 2>&1; then
306+
echo "$abs_path"
307+
fi
308+
done
309+
return
310+
fi
311+
312+
# Fallback: scan using .gitignore-based filtering
297313
# Run in a subshell to avoid leaking helper functions into global scope
298314
(
299-
_should_skip() {
300-
local name="$1"
301-
local skip
302-
for skip in "${skip_dirs[@]}"; do
303-
[ "$name" = "$skip" ] && return 0
304-
done
305-
return 1
306-
}
307-
308315
_scan_dir() {
309316
local dir="$1"
310317
local current_depth="$2"
@@ -314,13 +321,19 @@ find_nested_git_repos() {
314321
[ -d "$child" ] || continue
315322
child="${child%/}"
316323
child_name="$(basename "$child")"
317-
_should_skip "$child_name" && continue
324+
# Always skip .git directory
325+
[ "$child_name" = ".git" ] && continue
318326

319327
if [ -e "$child/.git" ]; then
328+
# Directory has its own .git β€” it's a nested repo (even if gitignored in parent)
320329
if git -C "$child" rev-parse --is-inside-work-tree >/dev/null 2>&1; then
321330
echo "$child"
322331
fi
323332
elif [ "$current_depth" -lt "$max_depth" ]; then
333+
# Skip gitignored directories (they won't contain nested repos)
334+
if git -C "$repo_root" check-ignore -q "$child" 2>/dev/null; then
335+
continue
336+
fi
324337
_scan_dir "$child" $((current_depth + 1))
325338
fi
326339
done

β€Žscripts/bash/setup-plan.shβ€Ž

Lines changed: 28 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -76,7 +76,34 @@ fi
7676
NESTED_REPOS_JSON="[]"
7777
if [ "$HAS_GIT" = true ]; then
7878
scan_depth="${SCAN_DEPTH:-2}"
79-
nested_repos=$(find_nested_git_repos "$REPO_ROOT" "$scan_depth")
79+
INIT_OPTIONS="$REPO_ROOT/.specify/init-options.json"
80+
explicit_repos=()
81+
82+
# Read explicit nested_repos from init-options.json if available
83+
if [ -f "$INIT_OPTIONS" ]; then
84+
if has_jq; then
85+
while IFS= read -r rp; do
86+
[ -n "$rp" ] && explicit_repos+=("$rp")
87+
done < <(jq -r '.nested_repos // [] | .[]' "$INIT_OPTIONS" 2>/dev/null)
88+
else
89+
_py=$(command -v python3 2>/dev/null || command -v python 2>/dev/null || echo "")
90+
if [ -n "$_py" ]; then
91+
while IFS= read -r rp; do
92+
rp="${rp%$'\r'}"
93+
[ -n "$rp" ] && explicit_repos+=("$rp")
94+
done < <("$_py" -c "import json,sys
95+
try:
96+
[print(p) for p in json.load(open(sys.argv[1])).get('nested_repos',[])]
97+
except: pass" "$INIT_OPTIONS" 2>/dev/null)
98+
fi
99+
fi
100+
fi
101+
102+
if [ ${#explicit_repos[@]} -gt 0 ]; then
103+
nested_repos=$(find_nested_git_repos "$REPO_ROOT" "$scan_depth" "${explicit_repos[@]}")
104+
else
105+
nested_repos=$(find_nested_git_repos "$REPO_ROOT" "$scan_depth")
106+
fi
80107
if [ -n "$nested_repos" ]; then
81108
NESTED_REPOS_JSON="["
82109
first=true

β€Žscripts/powershell/common.ps1β€Ž

Lines changed: 26 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -233,32 +233,53 @@ function Test-DirHasFiles {
233233
# (directory or file, covering worktrees/submodules). Excludes the root repo
234234
# itself and common non-project directories.
235235
# Returns an array of absolute paths. Scan depth is configurable (default 2).
236+
# If ExplicitPaths are provided, validates and returns those directly (no scanning).
236237
function Find-NestedGitRepos {
237238
param(
238239
[string]$RepoRoot = (Get-RepoRoot),
239-
[int]$MaxDepth = 2
240+
[int]$MaxDepth = 2,
241+
[string[]]$ExplicitPaths = @()
240242
)
241243

242-
$skipDirs = @('.specify', '.git', 'node_modules', 'vendor', '.venv', 'venv',
243-
'__pycache__', '.gradle', 'build', 'dist', 'target', '.idea',
244-
'.vscode', 'specs')
244+
# If explicit paths are provided, validate and return them directly
245+
if ($ExplicitPaths.Count -gt 0) {
246+
$found = @()
247+
foreach ($relPath in $ExplicitPaths) {
248+
$absPath = Join-Path $RepoRoot $relPath
249+
$gitMarker = Join-Path $absPath '.git'
250+
if (Test-Path -LiteralPath $gitMarker) {
251+
try {
252+
$null = git -C $absPath rev-parse --is-inside-work-tree 2>$null
253+
if ($LASTEXITCODE -eq 0) {
254+
$found += $absPath
255+
}
256+
} catch { }
257+
}
258+
}
259+
return $found
260+
}
245261

262+
# Fallback: scan using .gitignore-based filtering
246263
function ScanDir {
247264
param([string]$Dir, [int]$CurrentDepth)
248265
$found = @()
249266
$children = Get-ChildItem -Path $Dir -Directory -ErrorAction SilentlyContinue |
250-
Where-Object { $skipDirs -notcontains $_.Name }
267+
Where-Object { $_.Name -ne '.git' }
251268

252269
foreach ($child in $children) {
253270
$gitMarker = Join-Path $child.FullName '.git'
254271
if (Test-Path -LiteralPath $gitMarker) {
272+
# Directory has its own .git β€” it's a nested repo (even if gitignored in parent)
255273
try {
256274
$null = git -C $child.FullName rev-parse --is-inside-work-tree 2>$null
257275
if ($LASTEXITCODE -eq 0) {
258276
$found += $child.FullName
259277
}
260278
} catch { }
261279
} elseif ($CurrentDepth -lt $MaxDepth) {
280+
# Skip gitignored directories (they won't contain nested repos)
281+
$null = git -C $RepoRoot check-ignore -q $child.FullName 2>$null
282+
if ($LASTEXITCODE -eq 0) { continue }
262283
$found += ScanDir -Dir $child.FullName -CurrentDepth ($CurrentDepth + 1)
263284
}
264285
}

β€Žscripts/powershell/setup-plan.ps1β€Ž

Lines changed: 18 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -48,7 +48,24 @@ if ($template -and (Test-Path $template)) {
4848
$nestedReposResult = @()
4949
if ($paths.HAS_GIT -eq 'true' -or $paths.HAS_GIT -eq $true) {
5050
$effectiveDepth = if ($ScanDepth -gt 0) { $ScanDepth } else { 2 }
51-
$nestedRepos = Find-NestedGitRepos -RepoRoot $paths.REPO_ROOT -MaxDepth $effectiveDepth
51+
$initOptions = Join-Path $paths.REPO_ROOT '.specify' 'init-options.json'
52+
$explicitPaths = @()
53+
54+
# Read explicit nested_repos from init-options.json if available
55+
if (Test-Path -LiteralPath $initOptions) {
56+
try {
57+
$opts = Get-Content $initOptions -Raw | ConvertFrom-Json
58+
if ($opts.nested_repos -and $opts.nested_repos.Count -gt 0) {
59+
$explicitPaths = @($opts.nested_repos)
60+
}
61+
} catch { }
62+
}
63+
64+
if ($explicitPaths.Count -gt 0) {
65+
$nestedRepos = Find-NestedGitRepos -RepoRoot $paths.REPO_ROOT -MaxDepth $effectiveDepth -ExplicitPaths $explicitPaths
66+
} else {
67+
$nestedRepos = Find-NestedGitRepos -RepoRoot $paths.REPO_ROOT -MaxDepth $effectiveDepth
68+
}
5269
foreach ($nestedPath in $nestedRepos) {
5370
$relPath = $nestedPath.Substring($paths.REPO_ROOT.Length).TrimStart('\', '/')
5471
$nestedReposResult += [PSCustomObject]@{ path = $relPath }

β€Žtests/test_nested_repos.pyβ€Ž

Lines changed: 92 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,8 @@
44
Tests cover:
55
- Discovery of nested git repos via find_nested_git_repos (bash)
66
- Configurable scan depth for discovery
7-
- Excluded directories are skipped
7+
- .gitignore-based directory filtering (replaces hardcoded skip list)
8+
- Explicit paths from init-options.json
89
- setup-plan.sh reports discovered nested repos in JSON output
910
- create-new-feature.sh does NOT create branches in nested repos
1011
"""
@@ -88,17 +89,21 @@ def git_repo_no_nested(tmp_path: Path) -> Path:
8889

8990

9091
@pytest.fixture
91-
def git_repo_with_excluded_dirs(tmp_path: Path) -> Path:
92-
"""Create a root git repo where git repos exist inside excluded directories."""
92+
def git_repo_with_gitignored_dirs(tmp_path: Path) -> Path:
93+
"""Create a root git repo with gitignored dirs containing git repos."""
9394
_init_git_repo(tmp_path)
9495
_setup_scripts(tmp_path)
9596

96-
# Git repo inside node_modules (should be excluded)
97+
# Add .gitignore that ignores node_modules and build
98+
(tmp_path / ".gitignore").write_text("node_modules/\nbuild/\n")
99+
subprocess.run(["git", "add", ".gitignore"], cwd=tmp_path, check=True)
100+
subprocess.run(["git", "commit", "-m", "add gitignore", "-q"], cwd=tmp_path, check=True)
101+
102+
# Non-repo dir inside gitignored path (should be skipped during traversal)
97103
nm_dir = tmp_path / "node_modules" / "some-pkg"
98104
nm_dir.mkdir(parents=True)
99-
_init_git_repo(nm_dir)
100105

101-
# Valid nested repo
106+
# Valid nested repo (not gitignored)
102107
lib_dir = tmp_path / "lib"
103108
lib_dir.mkdir(parents=True)
104109
_init_git_repo(lib_dir)
@@ -160,11 +165,11 @@ def test_no_nested_repos_returns_empty(self, git_repo_no_nested: Path):
160165
assert result.returncode == 0
161166
assert result.stdout.strip() == ""
162167

163-
def test_excludes_node_modules(self, git_repo_with_excluded_dirs: Path):
164-
"""find_nested_git_repos skips repos inside excluded directories."""
168+
def test_skips_gitignored_directories(self, git_repo_with_gitignored_dirs: Path):
169+
"""find_nested_git_repos skips traversal into gitignored directories."""
165170
result = source_and_call(
166-
f'find_nested_git_repos "{git_repo_with_excluded_dirs}"',
167-
cwd=git_repo_with_excluded_dirs,
171+
f'find_nested_git_repos "{git_repo_with_gitignored_dirs}"',
172+
cwd=git_repo_with_gitignored_dirs,
168173
)
169174
assert result.returncode == 0
170175
paths = [p.strip().rstrip("/") for p in result.stdout.strip().splitlines() if p.strip()]
@@ -192,6 +197,67 @@ def test_discovers_level1_repos(self, tmp_path: Path):
192197
assert len(paths) == 1
193198
assert os.path.basename(paths[0]) == "mylib"
194199

200+
def test_nested_repo_found_even_if_gitignored(self, tmp_path: Path):
201+
"""A directory with its own .git is reported even if it's gitignored in the parent."""
202+
_init_git_repo(tmp_path)
203+
(tmp_path / ".specify").mkdir()
204+
205+
# Gitignore the nested repo path
206+
(tmp_path / ".gitignore").write_text("nested-lib/\n")
207+
subprocess.run(["git", "add", ".gitignore"], cwd=tmp_path, check=True)
208+
subprocess.run(["git", "commit", "-m", "ignore", "-q"], cwd=tmp_path, check=True)
209+
210+
# Create nested repo at the gitignored path
211+
nested = tmp_path / "nested-lib"
212+
nested.mkdir()
213+
_init_git_repo(nested)
214+
215+
result = source_and_call(
216+
f'find_nested_git_repos "{tmp_path}"',
217+
cwd=tmp_path,
218+
)
219+
assert result.returncode == 0
220+
paths = [p.strip().rstrip("/") for p in result.stdout.strip().splitlines() if p.strip()]
221+
assert len(paths) == 1
222+
assert os.path.basename(paths[0]) == "nested-lib"
223+
224+
225+
# ── Explicit Paths Tests ─────────────────────────────────────────────────────
226+
227+
228+
class TestExplicitPaths:
229+
def test_explicit_paths_returns_only_valid(self, git_repo_with_nested: Path):
230+
"""When explicit paths are given, only valid nested repos are returned."""
231+
result = source_and_call(
232+
f'find_nested_git_repos "{git_repo_with_nested}" 2 "components/core" "nonexistent/repo"',
233+
cwd=git_repo_with_nested,
234+
)
235+
assert result.returncode == 0, result.stderr
236+
paths = [p.strip().rstrip("/") for p in result.stdout.strip().splitlines() if p.strip()]
237+
assert len(paths) == 1
238+
assert os.path.basename(paths[0]) == "core"
239+
240+
def test_explicit_paths_skips_scanning(self, git_repo_with_nested: Path):
241+
"""When explicit paths are given, only those are checked β€” no scanning."""
242+
result = source_and_call(
243+
f'find_nested_git_repos "{git_repo_with_nested}" 2 "components/core"',
244+
cwd=git_repo_with_nested,
245+
)
246+
assert result.returncode == 0, result.stderr
247+
paths = [p.strip().rstrip("/") for p in result.stdout.strip().splitlines() if p.strip()]
248+
# Only core should be returned, not api (even though it exists)
249+
assert len(paths) == 1
250+
assert os.path.basename(paths[0]) == "core"
251+
252+
def test_explicit_empty_returns_nothing(self, git_repo_with_nested: Path):
253+
"""When explicit paths are all invalid, nothing is returned."""
254+
result = source_and_call(
255+
f'find_nested_git_repos "{git_repo_with_nested}" 2 "does/not/exist"',
256+
cwd=git_repo_with_nested,
257+
)
258+
assert result.returncode == 0
259+
assert result.stdout.strip() == ""
260+
195261

196262
# ── Configurable Depth Tests ─────────────────────────────────────────────────
197263

@@ -355,3 +421,19 @@ def test_discovery_does_not_create_branches(self, git_repo_with_nested: Path):
355421
capture_output=True, text=True,
356422
)
357423
assert br.stdout.strip() != branch_name
424+
425+
def test_explicit_nested_repos_from_init_options(self, git_repo_with_nested: Path):
426+
"""setup-plan reads nested_repos from init-options.json and uses explicit paths."""
427+
self._create_feature_first(git_repo_with_nested)
428+
429+
# Write init-options.json with explicit nested_repos (only core, not api)
430+
init_options = git_repo_with_nested / ".specify" / "init-options.json"
431+
init_options.write_text(json.dumps({"nested_repos": ["components/core"]}))
432+
433+
result = run_setup_plan(git_repo_with_nested, "--json")
434+
assert result.returncode == 0, result.stderr
435+
data = parse_json_from_output(result.stdout)
436+
437+
assert "NESTED_REPOS" in data
438+
assert len(data["NESTED_REPOS"]) == 1
439+
assert data["NESTED_REPOS"][0]["path"] == "components/core"

0 commit comments

Comments
Β (0)