Skip to content

Commit 1e8d148

Browse files
sakitACopilot
andcommitted
Address review: fix comments, add nested_repo_scan_depth, test gitignored parents
- Update function header comments in common.sh and common.ps1 to accurately describe gitignore-based filtering (no hardcoded skip list) - Document that scanning will NOT descend into gitignored parent directories; use init-options.json nested_repos for those - Implement nested_repo_scan_depth from init-options.json in both setup-plan.sh and setup-plan.ps1 (priority: CLI > config > default 2) - Fix PowerShell -ScanDepth param: default 2, ValidateRange(1, MaxValue), use PSBoundParameters to detect explicit CLI usage - Add returncode assertion before branch-name comparison in tests - Add test: repo under gitignored parent not discovered by scan - Add test: nested_repo_scan_depth from init-options.json Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
1 parent 5346099 commit 1e8d148

File tree

6 files changed

+132
-11
lines changed

6 files changed

+132
-11
lines changed

.claude/settings.local.json

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
{
2+
"permissions": {
3+
"allow": [
4+
"Bash(gh api:*)"
5+
]
6+
}
7+
}

scripts/bash/common.sh

Lines changed: 13 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -287,6 +287,17 @@ check_dir() { [[ -d "$1" && -n $(ls -A "$1" 2>/dev/null) ]] && echo " ✓ $2" |
287287
# max_depth — defaults to 2
288288
# explicit_paths — if provided (3rd arg onward), validate and return these directly (no scanning)
289289
# Outputs one absolute path per line.
290+
#
291+
# Discovery modes:
292+
# Explicit — validates paths from init-options.json `nested_repos`; no scanning.
293+
# Scan — recursively searches child directories up to max_depth.
294+
# Skips .git directories. Uses `git check-ignore` to prune
295+
# gitignored directories during traversal. A directory with
296+
# its own .git marker is always reported (even if gitignored).
297+
#
298+
# Note: Scanning will NOT descend into gitignored parent directories, so a
299+
# nested repo beneath one (e.g., vendor/foo/.git when vendor/ is ignored)
300+
# will not be discovered. Use init-options.json `nested_repos` for those.
290301
find_nested_git_repos() {
291302
local repo_root="${1:-$(get_repo_root)}"
292303
local max_depth="${2:-2}"
@@ -334,7 +345,8 @@ find_nested_git_repos() {
334345
echo "$child"
335346
fi
336347
elif [ "$current_depth" -lt "$max_depth" ]; then
337-
# Skip gitignored directories (they won't contain nested repos)
348+
# Skip gitignored directories — won't descend into them.
349+
# Repos under gitignored parents require explicit init-options config.
338350
if git -C "$repo_root" check-ignore -q "$child" 2>/dev/null; then
339351
continue
340352
fi

scripts/bash/setup-plan.sh

Lines changed: 14 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -75,16 +75,18 @@ fi
7575
# Discover nested independent git repositories (for AI agent to analyze)
7676
NESTED_REPOS_JSON="[]"
7777
if [ "$HAS_GIT" = true ]; then
78-
scan_depth="${SCAN_DEPTH:-2}"
7978
INIT_OPTIONS="$REPO_ROOT/.specify/init-options.json"
8079
explicit_repos=()
80+
config_depth=""
8181

82-
# Read explicit nested_repos from init-options.json if available
82+
# Read explicit nested_repos and nested_repo_scan_depth from init-options.json if available
8383
if [ -f "$INIT_OPTIONS" ]; then
8484
if has_jq; then
8585
while IFS= read -r rp; do
8686
[ -n "$rp" ] && explicit_repos+=("$rp")
8787
done < <(jq -r '.nested_repos // [] | .[]' "$INIT_OPTIONS" 2>/dev/null)
88+
_cd=$(jq -r '.nested_repo_scan_depth // empty' "$INIT_OPTIONS" 2>/dev/null)
89+
[ -n "$_cd" ] && config_depth="$_cd"
8890
else
8991
_py=$(command -v python3 2>/dev/null || command -v python 2>/dev/null || echo "")
9092
if [ -n "$_py" ]; then
@@ -95,10 +97,20 @@ if [ "$HAS_GIT" = true ]; then
9597
try:
9698
[print(p) for p in json.load(open(sys.argv[1])).get('nested_repos',[])]
9799
except: pass" "$INIT_OPTIONS" 2>/dev/null)
100+
_cd=$("$_py" -c "import json,sys
101+
try:
102+
v=json.load(open(sys.argv[1])).get('nested_repo_scan_depth')
103+
if v is not None: print(v)
104+
except: pass" "$INIT_OPTIONS" 2>/dev/null)
105+
_cd="${_cd%$'\r'}"
106+
[ -n "$_cd" ] && config_depth="$_cd"
98107
fi
99108
fi
100109
fi
101110

111+
# Priority: CLI --scan-depth > init-options nested_repo_scan_depth > default 2
112+
scan_depth="${SCAN_DEPTH:-${config_depth:-2}}"
113+
102114
if [ ${#explicit_repos[@]} -gt 0 ]; then
103115
nested_repos=$(find_nested_git_repos "$REPO_ROOT" "$scan_depth" "${explicit_repos[@]}")
104116
else

scripts/powershell/common.ps1

Lines changed: 13 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -229,11 +229,19 @@ function Test-DirHasFiles {
229229
}
230230

231231
# Discover nested independent git repositories under RepoRoot.
232-
# Searches up to 2 directory levels deep for subdirectories containing .git
233-
# (directory or file, covering worktrees/submodules). Excludes the root repo
234-
# itself and common non-project directories.
235232
# Returns an array of absolute paths. Scan depth is configurable (default 2).
236233
# If ExplicitPaths are provided, validates and returns those directly (no scanning).
234+
#
235+
# Discovery modes:
236+
# Explicit — validates paths from init-options.json `nested_repos`; no scanning.
237+
# Scan — recursively searches child directories up to MaxDepth.
238+
# Skips .git directories. Uses `git check-ignore` to prune
239+
# gitignored directories during traversal. A directory with
240+
# its own .git marker is always reported (even if gitignored).
241+
#
242+
# Note: Scanning will NOT descend into gitignored parent directories, so a
243+
# nested repo beneath one (e.g., vendor/foo/.git when vendor/ is ignored)
244+
# will not be discovered. Use init-options.json `nested_repos` for those.
237245
function Find-NestedGitRepos {
238246
param(
239247
[string]$RepoRoot = (Get-RepoRoot),
@@ -278,7 +286,8 @@ function Find-NestedGitRepos {
278286
}
279287
} catch { }
280288
} elseif ($CurrentDepth -lt $MaxDepth) {
281-
# Skip gitignored directories (they won't contain nested repos)
289+
# Skip gitignored directories — won't descend into them.
290+
# Repos under gitignored parents require explicit init-options config.
282291
$null = git -C $RepoRoot check-ignore -q $child.FullName 2>$null
283292
if ($LASTEXITCODE -eq 0) { continue }
284293
$found += ScanDir -Dir $child.FullName -CurrentDepth ($CurrentDepth + 1)

scripts/powershell/setup-plan.ps1

Lines changed: 10 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -4,8 +4,8 @@
44
[CmdletBinding()]
55
param(
66
[switch]$Json,
7-
[ValidateRange(0, [int]::MaxValue)]
8-
[int]$ScanDepth = 0,
7+
[ValidateRange(1, [int]::MaxValue)]
8+
[int]$ScanDepth,
99
[switch]$Help
1010
)
1111

@@ -48,20 +48,26 @@ if ($template -and (Test-Path $template)) {
4848
# Discover nested independent git repositories (for AI agent to analyze)
4949
$nestedReposResult = @()
5050
if ($paths.HAS_GIT -eq 'true' -or $paths.HAS_GIT -eq $true) {
51-
$effectiveDepth = if ($ScanDepth -gt 0) { $ScanDepth } else { 2 }
5251
$initOptions = Join-Path $paths.REPO_ROOT '.specify' 'init-options.json'
5352
$explicitPaths = @()
53+
$configDepth = $null
5454

55-
# Read explicit nested_repos from init-options.json if available
55+
# Read explicit nested_repos and nested_repo_scan_depth from init-options.json if available
5656
if (Test-Path -LiteralPath $initOptions) {
5757
try {
5858
$opts = Get-Content $initOptions -Raw | ConvertFrom-Json
5959
if ($opts.nested_repos -and $opts.nested_repos.Count -gt 0) {
6060
$explicitPaths = @($opts.nested_repos)
6161
}
62+
if ($null -ne $opts.nested_repo_scan_depth) {
63+
$configDepth = [int]$opts.nested_repo_scan_depth
64+
}
6265
} catch { }
6366
}
6467

68+
# Priority: CLI -ScanDepth > init-options nested_repo_scan_depth > default 2
69+
$effectiveDepth = if ($PSBoundParameters.ContainsKey('ScanDepth')) { $ScanDepth } elseif ($configDepth) { $configDepth } else { 2 }
70+
6571
if ($explicitPaths.Count -gt 0) {
6672
$nestedRepos = Find-NestedGitRepos -RepoRoot $paths.REPO_ROOT -MaxDepth $effectiveDepth -ExplicitPaths $explicitPaths
6773
} else {

tests/test_nested_repos.py

Lines changed: 75 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -221,6 +221,48 @@ def test_nested_repo_found_even_if_gitignored(self, tmp_path: Path):
221221
assert len(paths) == 1
222222
assert os.path.basename(paths[0]) == "nested-lib"
223223

224+
def test_repo_under_gitignored_parent_not_discovered_by_scan(self, tmp_path: Path):
225+
"""A nested repo beneath a gitignored parent dir is NOT found by scanning.
226+
227+
When vendor/ is gitignored and vendor/foo/.git exists, the scan will not
228+
descend into vendor/ so vendor/foo won't be discovered. Users should use
229+
init-options.json `nested_repos` for this case.
230+
"""
231+
_init_git_repo(tmp_path)
232+
(tmp_path / ".specify").mkdir()
233+
scripts_dir = tmp_path / "scripts" / "bash"
234+
scripts_dir.mkdir(parents=True)
235+
shutil.copy(COMMON_SH, scripts_dir / "common.sh")
236+
237+
# Gitignore vendor/
238+
(tmp_path / ".gitignore").write_text("vendor/\n")
239+
subprocess.run(["git", "add", ".gitignore"], cwd=tmp_path, check=True)
240+
subprocess.run(["git", "commit", "-m", "ignore vendor", "-q"], cwd=tmp_path, check=True)
241+
242+
# Create nested repo under gitignored parent: vendor/foo
243+
vendor_foo = tmp_path / "vendor" / "foo"
244+
vendor_foo.mkdir(parents=True)
245+
_init_git_repo(vendor_foo)
246+
247+
# Scan with depth 3 — should still not find it because vendor/ is pruned
248+
result = source_and_call(
249+
f'find_nested_git_repos "{tmp_path}" 3',
250+
cwd=tmp_path,
251+
)
252+
assert result.returncode == 0
253+
paths = [p.strip().rstrip("/") for p in result.stdout.strip().splitlines() if p.strip()]
254+
assert len(paths) == 0
255+
256+
# But explicit paths mode WILL find it
257+
result2 = source_and_call(
258+
f'find_nested_git_repos "{tmp_path}" 3 "vendor/foo"',
259+
cwd=tmp_path,
260+
)
261+
assert result2.returncode == 0
262+
paths2 = [p.strip().rstrip("/") for p in result2.stdout.strip().splitlines() if p.strip()]
263+
assert len(paths2) == 1
264+
assert os.path.basename(paths2[0]) == "foo"
265+
224266

225267
# ── Explicit Paths Tests ─────────────────────────────────────────────────────
226268

@@ -340,6 +382,7 @@ def test_nested_repos_not_branched(self, git_repo_with_nested: Path):
340382
cwd=git_repo_with_nested / subdir,
341383
capture_output=True, text=True,
342384
)
385+
assert br.returncode == 0, br.stderr
343386
assert br.stdout.strip() != branch_name
344387

345388

@@ -437,3 +480,35 @@ def test_explicit_nested_repos_from_init_options(self, git_repo_with_nested: Pat
437480
assert "NESTED_REPOS" in data
438481
assert len(data["NESTED_REPOS"]) == 1
439482
assert data["NESTED_REPOS"][0]["path"] == "components/core"
483+
484+
def test_scan_depth_from_init_options(self, tmp_path: Path):
485+
"""setup-plan reads nested_repo_scan_depth from init-options.json as default."""
486+
_init_git_repo(tmp_path)
487+
_setup_scripts(tmp_path)
488+
489+
# Level 3 repo
490+
deep_dir = tmp_path / "services" / "backend" / "auth"
491+
deep_dir.mkdir(parents=True)
492+
_init_git_repo(deep_dir)
493+
494+
# Create feature branch first
495+
run_create_feature(
496+
tmp_path, "--json", "--short-name", "cfg-depth", "Config depth test",
497+
)
498+
499+
# Without config: default depth 2, should not find level-3 repo
500+
result = run_setup_plan(tmp_path, "--json")
501+
assert result.returncode == 0, result.stderr
502+
data = parse_json_from_output(result.stdout)
503+
assert data["NESTED_REPOS"] == []
504+
505+
# Add nested_repo_scan_depth=3 in init-options.json
506+
init_options = tmp_path / ".specify" / "init-options.json"
507+
init_options.write_text(json.dumps({"nested_repo_scan_depth": 3}))
508+
509+
# Now should discover the level-3 repo
510+
result2 = run_setup_plan(tmp_path, "--json")
511+
assert result2.returncode == 0, result2.stderr
512+
data2 = parse_json_from_output(result2.stdout)
513+
assert len(data2["NESTED_REPOS"]) == 1
514+
assert data2["NESTED_REPOS"][0]["path"] == "services/backend/auth"

0 commit comments

Comments
 (0)