Skip to content

Commit 54cc59e

Browse files
sakitACopilot
andcommitted
feat: configurable scan depth and selective repo branching
Enhance nested repo support with two key improvements: 1. Configurable scan depth: - find_nested_git_repos() / Find-NestedGitRepos now accept a depth parameter - Uses recursive traversal instead of hardcoded 2-level nesting - New --scan-depth (Bash) / -ScanDepth (PowerShell) flag - Reads nested_repo_scan_depth from init-options.json 2. Selective branching via --repos flag: - New --repos (Bash) / -Repos (PowerShell) flag accepts comma-separated paths - Only branches repos matching the specified paths - Omit flag to branch all discovered repos (backward compatible) - Enables spec-driven branching: AI agents select relevant repos per feature Tests: 17 tests (8 new for depth + filtering), all passing. Docs: Updated specify.md with --repos, --scan-depth, and init-options.json config. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
1 parent e254f6a commit 54cc59e

File tree

5 files changed

+298
-69
lines changed

5 files changed

+298
-69
lines changed

scripts/bash/common.sh

Lines changed: 27 additions & 32 deletions
Original file line numberDiff line numberDiff line change
@@ -279,12 +279,16 @@ check_file() { [[ -f "$1" ]] && echo " ✓ $2" || echo " ✗ $2"; }
279279
check_dir() { [[ -d "$1" && -n $(ls -A "$1" 2>/dev/null) ]] && echo "$2" || echo "$2"; }
280280

281281
# Discover nested independent git repositories under REPO_ROOT.
282-
# Searches up to 2 directory levels deep for subdirectories containing .git
283-
# (directory or file, covering worktrees/submodules). Excludes the root repo
284-
# itself and common non-project directories.
282+
# Searches up to $max_depth directory levels deep for subdirectories containing
283+
# .git (directory or file, covering worktrees/submodules). Excludes the root
284+
# 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
285288
# Outputs one absolute path per line.
286289
find_nested_git_repos() {
287290
local repo_root="${1:-$(get_repo_root)}"
291+
local max_depth="${2:-2}"
288292
# Directories to skip during traversal
289293
local -a skip_dirs=(".specify" ".git" "node_modules" "vendor" ".venv" "venv"
290294
"__pycache__" ".gradle" "build" "dist" "target" ".idea"
@@ -298,36 +302,27 @@ find_nested_git_repos() {
298302
return 1
299303
}
300304

301-
# Level 1
302-
for child in "$repo_root"/*/; do
303-
[ -d "$child" ] || continue
304-
child="${child%/}"
305-
local child_name
306-
child_name="$(basename "$child")"
307-
_should_skip "$child_name" && continue
308-
309-
if [ -e "$child/.git" ]; then
310-
# Verify it is a valid git work tree
311-
if git -C "$child" rev-parse --is-inside-work-tree >/dev/null 2>&1; then
312-
echo "$child"
313-
fi
314-
else
315-
# Level 2
316-
for grandchild in "$child"/*/; do
317-
[ -d "$grandchild" ] || continue
318-
grandchild="${grandchild%/}"
319-
local gc_name
320-
gc_name="$(basename "$grandchild")"
321-
_should_skip "$gc_name" && continue
322-
323-
if [ -e "$grandchild/.git" ]; then
324-
if git -C "$grandchild" rev-parse --is-inside-work-tree >/dev/null 2>&1; then
325-
echo "$grandchild"
326-
fi
305+
_scan_dir() {
306+
local dir="$1"
307+
local current_depth="$2"
308+
for child in "$dir"/*/; do
309+
[ -d "$child" ] || continue
310+
child="${child%/}"
311+
local child_name
312+
child_name="$(basename "$child")"
313+
_should_skip "$child_name" && continue
314+
315+
if [ -e "$child/.git" ]; then
316+
if git -C "$child" rev-parse --is-inside-work-tree >/dev/null 2>&1; then
317+
echo "$child"
327318
fi
328-
done
329-
fi
330-
done
319+
elif [ "$current_depth" -lt "$max_depth" ]; then
320+
_scan_dir "$child" $((current_depth + 1))
321+
fi
322+
done
323+
}
324+
325+
_scan_dir "$repo_root" 1
331326
}
332327

333328
# Resolve a template name to a file path using the priority stack:

scripts/bash/create-new-feature.sh

Lines changed: 57 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,8 @@ ALLOW_EXISTING=false
88
SHORT_NAME=""
99
BRANCH_NUMBER=""
1010
USE_TIMESTAMP=false
11+
NESTED_REPOS_FILTER=""
12+
SCAN_DEPTH=""
1113
ARGS=()
1214
i=1
1315
while [ $i -le $# ]; do
@@ -52,8 +54,34 @@ while [ $i -le $# ]; do
5254
--timestamp)
5355
USE_TIMESTAMP=true
5456
;;
57+
--repos)
58+
if [ $((i + 1)) -gt $# ]; then
59+
echo 'Error: --repos requires a value (comma-separated list of repo paths)' >&2
60+
exit 1
61+
fi
62+
i=$((i + 1))
63+
next_arg="${!i}"
64+
if [[ "$next_arg" == --* ]]; then
65+
echo 'Error: --repos requires a value (comma-separated list of repo paths)' >&2
66+
exit 1
67+
fi
68+
NESTED_REPOS_FILTER="$next_arg"
69+
;;
70+
--scan-depth)
71+
if [ $((i + 1)) -gt $# ]; then
72+
echo 'Error: --scan-depth requires a value' >&2
73+
exit 1
74+
fi
75+
i=$((i + 1))
76+
next_arg="${!i}"
77+
if [[ "$next_arg" == --* ]]; then
78+
echo 'Error: --scan-depth requires a value' >&2
79+
exit 1
80+
fi
81+
SCAN_DEPTH="$next_arg"
82+
;;
5583
--help|-h)
56-
echo "Usage: $0 [--json] [--dry-run] [--allow-existing-branch] [--short-name <name>] [--number N] [--timestamp] <feature_description>"
84+
echo "Usage: $0 [--json] [--dry-run] [--allow-existing-branch] [--short-name <name>] [--number N] [--timestamp] [--repos <paths>] [--scan-depth N] <feature_description>"
5785
echo ""
5886
echo "Options:"
5987
echo " --json Output in JSON format"
@@ -62,12 +90,15 @@ while [ $i -le $# ]; do
6290
echo " --short-name <name> Provide a custom short name (2-4 words) for the branch"
6391
echo " --number N Specify branch number manually (overrides auto-detection)"
6492
echo " --timestamp Use timestamp prefix (YYYYMMDD-HHMMSS) instead of sequential numbering"
93+
echo " --repos <paths> Comma-separated list of nested repo relative paths to branch (default: all discovered)"
94+
echo " --scan-depth N Max directory depth to scan for nested repos (default: 2)"
6595
echo " --help, -h Show this help message"
6696
echo ""
6797
echo "Examples:"
6898
echo " $0 'Add user authentication system' --short-name 'user-auth'"
6999
echo " $0 'Implement OAuth2 integration for API' --number 5"
70100
echo " $0 --timestamp --short-name 'user-auth' 'Add user authentication'"
101+
echo " $0 --repos 'components/api,components/auth' 'Add API auth support'"
71102
exit 0
72103
;;
73104
*)
@@ -384,7 +415,31 @@ fi
384415
# Create matching feature branches in nested independent git repositories
385416
NESTED_REPOS_JSON=""
386417
if [ "$HAS_GIT" = true ]; then
387-
nested_repos=$(find_nested_git_repos "$REPO_ROOT")
418+
scan_depth="${SCAN_DEPTH:-2}"
419+
nested_repos=$(find_nested_git_repos "$REPO_ROOT" "$scan_depth")
420+
421+
# Filter by --repos if specified: only branch repos in the requested list
422+
if [ -n "$NESTED_REPOS_FILTER" ] && [ -n "$nested_repos" ]; then
423+
IFS=',' read -ra requested_repos <<< "$NESTED_REPOS_FILTER"
424+
filtered=""
425+
while IFS= read -r nested_path; do
426+
[ -z "$nested_path" ] && continue
427+
nested_path="${nested_path%/}"
428+
rel_path="${nested_path#"$REPO_ROOT/"}"
429+
rel_path="${rel_path%/}"
430+
for req in "${requested_repos[@]}"; do
431+
req="$(echo "$req" | xargs)"
432+
req="${req%/}"
433+
if [ "$rel_path" = "$req" ]; then
434+
[ -n "$filtered" ] && filtered+=$'\n'
435+
filtered+="$nested_path"
436+
break
437+
fi
438+
done
439+
done <<< "$nested_repos"
440+
nested_repos="$filtered"
441+
fi
442+
388443
if [ -n "$nested_repos" ]; then
389444
NESTED_REPOS_JSON="["
390445
first=true

scripts/powershell/common.ps1

Lines changed: 23 additions & 33 deletions
Original file line numberDiff line numberDiff line change
@@ -234,48 +234,38 @@ function Test-DirHasFiles {
234234
# itself and common non-project directories.
235235
# Returns an array of absolute paths.
236236
function Find-NestedGitRepos {
237-
param([string]$RepoRoot = (Get-RepoRoot))
237+
param(
238+
[string]$RepoRoot = (Get-RepoRoot),
239+
[int]$MaxDepth = 2
240+
)
238241

239242
$skipDirs = @('.specify', '.git', 'node_modules', 'vendor', '.venv', 'venv',
240243
'__pycache__', '.gradle', 'build', 'dist', 'target', '.idea',
241244
'.vscode', 'specs')
242245

243-
$results = @()
244-
245-
# Level 1
246-
$children = Get-ChildItem -Path $RepoRoot -Directory -ErrorAction SilentlyContinue |
247-
Where-Object { $skipDirs -notcontains $_.Name }
248-
249-
foreach ($child in $children) {
250-
$gitMarker = Join-Path $child.FullName '.git'
251-
if (Test-Path -LiteralPath $gitMarker) {
252-
# Verify it is a valid git work tree
253-
try {
254-
$null = git -C $child.FullName rev-parse --is-inside-work-tree 2>$null
255-
if ($LASTEXITCODE -eq 0) {
256-
$results += $child.FullName
257-
}
258-
} catch { }
259-
} else {
260-
# Level 2
261-
$grandchildren = Get-ChildItem -Path $child.FullName -Directory -ErrorAction SilentlyContinue |
262-
Where-Object { $skipDirs -notcontains $_.Name }
263-
264-
foreach ($gc in $grandchildren) {
265-
$gcGitMarker = Join-Path $gc.FullName '.git'
266-
if (Test-Path -LiteralPath $gcGitMarker) {
267-
try {
268-
$null = git -C $gc.FullName rev-parse --is-inside-work-tree 2>$null
269-
if ($LASTEXITCODE -eq 0) {
270-
$results += $gc.FullName
271-
}
272-
} catch { }
273-
}
246+
function ScanDir {
247+
param([string]$Dir, [int]$CurrentDepth)
248+
$found = @()
249+
$children = Get-ChildItem -Path $Dir -Directory -ErrorAction SilentlyContinue |
250+
Where-Object { $skipDirs -notcontains $_.Name }
251+
252+
foreach ($child in $children) {
253+
$gitMarker = Join-Path $child.FullName '.git'
254+
if (Test-Path -LiteralPath $gitMarker) {
255+
try {
256+
$null = git -C $child.FullName rev-parse --is-inside-work-tree 2>$null
257+
if ($LASTEXITCODE -eq 0) {
258+
$found += $child.FullName
259+
}
260+
} catch { }
261+
} elseif ($CurrentDepth -lt $MaxDepth) {
262+
$found += ScanDir -Dir $child.FullName -CurrentDepth ($CurrentDepth + 1)
274263
}
275264
}
265+
return $found
276266
}
277267

278-
return $results
268+
return ScanDir -Dir $RepoRoot -CurrentDepth 1
279269
}
280270

281271
# Resolve a template name to a file path using the priority stack:

scripts/powershell/create-new-feature.ps1

Lines changed: 18 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,8 @@ param(
99
[Parameter()]
1010
[long]$Number = 0,
1111
[switch]$Timestamp,
12+
[string]$Repos,
13+
[int]$ScanDepth = 0,
1214
[switch]$Help,
1315
[Parameter(Position = 0, ValueFromRemainingArguments = $true)]
1416
[string[]]$FeatureDescription
@@ -17,7 +19,7 @@ $ErrorActionPreference = 'Stop'
1719

1820
# Show help if requested
1921
if ($Help) {
20-
Write-Host "Usage: ./create-new-feature.ps1 [-Json] [-DryRun] [-AllowExistingBranch] [-ShortName <name>] [-Number N] [-Timestamp] <feature description>"
22+
Write-Host "Usage: ./create-new-feature.ps1 [-Json] [-DryRun] [-AllowExistingBranch] [-ShortName <name>] [-Number N] [-Timestamp] [-Repos <paths>] [-ScanDepth N] <feature description>"
2123
Write-Host ""
2224
Write-Host "Options:"
2325
Write-Host " -Json Output in JSON format"
@@ -26,12 +28,15 @@ if ($Help) {
2628
Write-Host " -ShortName <name> Provide a custom short name (2-4 words) for the branch"
2729
Write-Host " -Number N Specify branch number manually (overrides auto-detection)"
2830
Write-Host " -Timestamp Use timestamp prefix (YYYYMMDD-HHMMSS) instead of sequential numbering"
31+
Write-Host " -Repos <paths> Comma-separated list of nested repo relative paths to branch (default: all discovered)"
32+
Write-Host " -ScanDepth N Max directory depth to scan for nested repos (default: 2)"
2933
Write-Host " -Help Show this help message"
3034
Write-Host ""
3135
Write-Host "Examples:"
3236
Write-Host " ./create-new-feature.ps1 'Add user authentication system' -ShortName 'user-auth'"
3337
Write-Host " ./create-new-feature.ps1 'Implement OAuth2 integration for API'"
3438
Write-Host " ./create-new-feature.ps1 -Timestamp -ShortName 'user-auth' 'Add user authentication'"
39+
Write-Host " ./create-new-feature.ps1 -Repos 'components/api,components/auth' 'Add API auth support'"
3540
exit 0
3641
}
3742

@@ -363,7 +368,18 @@ if (-not $DryRun) {
363368
# Create matching feature branches in nested independent git repositories
364369
$nestedReposResult = @()
365370
if ($hasGit) {
366-
$nestedRepos = Find-NestedGitRepos -RepoRoot $repoRoot
371+
$effectiveDepth = if ($ScanDepth -gt 0) { $ScanDepth } else { 2 }
372+
$nestedRepos = Find-NestedGitRepos -RepoRoot $repoRoot -MaxDepth $effectiveDepth
373+
374+
# Filter by -Repos if specified: only branch repos in the requested list
375+
if ($Repos) {
376+
$requestedRepos = $Repos -split ',' | ForEach-Object { $_.Trim().TrimEnd('\', '/') } | Where-Object { $_ -ne '' }
377+
$nestedRepos = $nestedRepos | Where-Object {
378+
$relPath = $_.Substring($repoRoot.Length).TrimStart('\', '/')
379+
$requestedRepos -contains $relPath
380+
}
381+
}
382+
367383
foreach ($nestedPath in $nestedRepos) {
368384
$relPath = $nestedPath.Substring($repoRoot.Length).TrimStart('\', '/')
369385
$nestedStatus = 'skipped'

0 commit comments

Comments
 (0)