Skip to content

Commit 5a80b81

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 4e28ed8 commit 5a80b81

File tree

6 files changed

+308
-72
lines changed

6 files changed

+308
-72
lines changed

scripts/bash/common.sh

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

255255
# Discover nested independent git repositories under REPO_ROOT.
256-
# Searches up to 2 directory levels deep for subdirectories containing .git
257-
# (directory or file, covering worktrees/submodules). Excludes the root repo
258-
# itself and common non-project directories.
256+
# Searches up to $max_depth directory levels deep for subdirectories containing
257+
# .git (directory or file, covering worktrees/submodules). Excludes the root
258+
# repo itself and common non-project directories.
259+
# Usage: find_nested_git_repos [repo_root] [max_depth]
260+
# repo_root — defaults to $(get_repo_root)
261+
# max_depth — defaults to 2
259262
# Outputs one absolute path per line.
260263
find_nested_git_repos() {
261264
local repo_root="${1:-$(get_repo_root)}"
265+
local max_depth="${2:-2}"
262266
# Directories to skip during traversal
263267
local -a skip_dirs=(".specify" ".git" "node_modules" "vendor" ".venv" "venv"
264268
"__pycache__" ".gradle" "build" "dist" "target" ".idea"
@@ -272,36 +276,27 @@ find_nested_git_repos() {
272276
return 1
273277
}
274278

275-
# Level 1
276-
for child in "$repo_root"/*/; do
277-
[ -d "$child" ] || continue
278-
child="${child%/}"
279-
local child_name
280-
child_name="$(basename "$child")"
281-
_should_skip "$child_name" && continue
282-
283-
if [ -e "$child/.git" ]; then
284-
# Verify it is a valid git work tree
285-
if git -C "$child" rev-parse --is-inside-work-tree >/dev/null 2>&1; then
286-
echo "$child"
287-
fi
288-
else
289-
# Level 2
290-
for grandchild in "$child"/*/; do
291-
[ -d "$grandchild" ] || continue
292-
grandchild="${grandchild%/}"
293-
local gc_name
294-
gc_name="$(basename "$grandchild")"
295-
_should_skip "$gc_name" && continue
296-
297-
if [ -e "$grandchild/.git" ]; then
298-
if git -C "$grandchild" rev-parse --is-inside-work-tree >/dev/null 2>&1; then
299-
echo "$grandchild"
300-
fi
279+
_scan_dir() {
280+
local dir="$1"
281+
local current_depth="$2"
282+
for child in "$dir"/*/; do
283+
[ -d "$child" ] || continue
284+
child="${child%/}"
285+
local child_name
286+
child_name="$(basename "$child")"
287+
_should_skip "$child_name" && continue
288+
289+
if [ -e "$child/.git" ]; then
290+
if git -C "$child" rev-parse --is-inside-work-tree >/dev/null 2>&1; then
291+
echo "$child"
301292
fi
302-
done
303-
fi
304-
done
293+
elif [ "$current_depth" -lt "$max_depth" ]; then
294+
_scan_dir "$child" $((current_depth + 1))
295+
fi
296+
done
297+
}
298+
299+
_scan_dir "$repo_root" 1
305300
}
306301

307302
# 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
*)
@@ -371,7 +402,31 @@ fi
371402
# Create matching feature branches in nested independent git repositories
372403
NESTED_REPOS_JSON=""
373404
if [ "$HAS_GIT" = true ]; then
374-
nested_repos=$(find_nested_git_repos "$REPO_ROOT")
405+
scan_depth="${SCAN_DEPTH:-2}"
406+
nested_repos=$(find_nested_git_repos "$REPO_ROOT" "$scan_depth")
407+
408+
# Filter by --repos if specified: only branch repos in the requested list
409+
if [ -n "$NESTED_REPOS_FILTER" ] && [ -n "$nested_repos" ]; then
410+
IFS=',' read -ra requested_repos <<< "$NESTED_REPOS_FILTER"
411+
filtered=""
412+
while IFS= read -r nested_path; do
413+
[ -z "$nested_path" ] && continue
414+
nested_path="${nested_path%/}"
415+
rel_path="${nested_path#"$REPO_ROOT/"}"
416+
rel_path="${rel_path%/}"
417+
for req in "${requested_repos[@]}"; do
418+
req="$(echo "$req" | xargs)"
419+
req="${req%/}"
420+
if [ "$rel_path" = "$req" ]; then
421+
[ -n "$filtered" ] && filtered+=$'\n'
422+
filtered+="$nested_path"
423+
break
424+
fi
425+
done
426+
done <<< "$nested_repos"
427+
nested_repos="$filtered"
428+
fi
429+
375430
if [ -n "$nested_repos" ]; then
376431
NESTED_REPOS_JSON="["
377432
first=true

scripts/powershell/common.ps1

Lines changed: 23 additions & 33 deletions
Original file line numberDiff line numberDiff line change
@@ -205,48 +205,38 @@ function Test-DirHasFiles {
205205
# itself and common non-project directories.
206206
# Returns an array of absolute paths.
207207
function Find-NestedGitRepos {
208-
param([string]$RepoRoot = (Get-RepoRoot))
208+
param(
209+
[string]$RepoRoot = (Get-RepoRoot),
210+
[int]$MaxDepth = 2
211+
)
209212

210213
$skipDirs = @('.specify', '.git', 'node_modules', 'vendor', '.venv', 'venv',
211214
'__pycache__', '.gradle', 'build', 'dist', 'target', '.idea',
212215
'.vscode', 'specs')
213216

214-
$results = @()
215-
216-
# Level 1
217-
$children = Get-ChildItem -Path $RepoRoot -Directory -ErrorAction SilentlyContinue |
218-
Where-Object { $skipDirs -notcontains $_.Name }
219-
220-
foreach ($child in $children) {
221-
$gitMarker = Join-Path $child.FullName '.git'
222-
if (Test-Path -LiteralPath $gitMarker) {
223-
# Verify it is a valid git work tree
224-
try {
225-
$null = git -C $child.FullName rev-parse --is-inside-work-tree 2>$null
226-
if ($LASTEXITCODE -eq 0) {
227-
$results += $child.FullName
228-
}
229-
} catch { }
230-
} else {
231-
# Level 2
232-
$grandchildren = Get-ChildItem -Path $child.FullName -Directory -ErrorAction SilentlyContinue |
233-
Where-Object { $skipDirs -notcontains $_.Name }
234-
235-
foreach ($gc in $grandchildren) {
236-
$gcGitMarker = Join-Path $gc.FullName '.git'
237-
if (Test-Path -LiteralPath $gcGitMarker) {
238-
try {
239-
$null = git -C $gc.FullName rev-parse --is-inside-work-tree 2>$null
240-
if ($LASTEXITCODE -eq 0) {
241-
$results += $gc.FullName
242-
}
243-
} catch { }
244-
}
217+
function ScanDir {
218+
param([string]$Dir, [int]$CurrentDepth)
219+
$found = @()
220+
$children = Get-ChildItem -Path $Dir -Directory -ErrorAction SilentlyContinue |
221+
Where-Object { $skipDirs -notcontains $_.Name }
222+
223+
foreach ($child in $children) {
224+
$gitMarker = Join-Path $child.FullName '.git'
225+
if (Test-Path -LiteralPath $gitMarker) {
226+
try {
227+
$null = git -C $child.FullName rev-parse --is-inside-work-tree 2>$null
228+
if ($LASTEXITCODE -eq 0) {
229+
$found += $child.FullName
230+
}
231+
} catch { }
232+
} elseif ($CurrentDepth -lt $MaxDepth) {
233+
$found += ScanDir -Dir $child.FullName -CurrentDepth ($CurrentDepth + 1)
245234
}
246235
}
236+
return $found
247237
}
248238

249-
return $results
239+
return ScanDir -Dir $RepoRoot -CurrentDepth 1
250240
}
251241

252242
# 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

@@ -347,7 +352,18 @@ if (-not $DryRun) {
347352
# Create matching feature branches in nested independent git repositories
348353
$nestedReposResult = @()
349354
if ($hasGit) {
350-
$nestedRepos = Find-NestedGitRepos -RepoRoot $repoRoot
355+
$effectiveDepth = if ($ScanDepth -gt 0) { $ScanDepth } else { 2 }
356+
$nestedRepos = Find-NestedGitRepos -RepoRoot $repoRoot -MaxDepth $effectiveDepth
357+
358+
# Filter by -Repos if specified: only branch repos in the requested list
359+
if ($Repos) {
360+
$requestedRepos = $Repos -split ',' | ForEach-Object { $_.Trim().TrimEnd('\', '/') } | Where-Object { $_ -ne '' }
361+
$nestedRepos = $nestedRepos | Where-Object {
362+
$relPath = $_.Substring($repoRoot.Length).TrimStart('\', '/')
363+
$requestedRepos -contains $relPath
364+
}
365+
}
366+
351367
foreach ($nestedPath in $nestedRepos) {
352368
$relPath = $nestedPath.Substring($repoRoot.Length).TrimStart('\', '/')
353369
$nestedStatus = 'skipped'

templates/commands/specify.md

Lines changed: 10 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -79,10 +79,17 @@ Given that feature description, do this:
7979
- If `"timestamp"`, add `--timestamp` (Bash) or `-Timestamp` (PowerShell) to the script invocation
8080
- If `"sequential"` or absent, do not add any extra flag (default behavior)
8181
82+
**Nested repo configuration**: Check `.specify/init-options.json` for nested repository settings:
83+
- `nested_repo_scan_depth` (integer): Max depth to scan for nested git repos (default: 2). Pass via `--scan-depth` (Bash) or `-ScanDepth` (PowerShell)
84+
- `nested_repos` (array of strings): If present, lists the relative paths of known nested repos. Use this to determine which repos are relevant to the feature being created
85+
86+
**Selective branching**: Analyze the feature description to determine which nested repos (if any) are relevant. Pass only relevant repos via `--repos` (Bash) or `-Repos` (PowerShell) as a comma-separated list. If the feature affects all repos, omit the flag to branch all discovered repos.
87+
8288
- Bash example: `{SCRIPT} --json --short-name "user-auth" "Add user authentication"`
83-
- Bash (timestamp): `{SCRIPT} --json --timestamp --short-name "user-auth" "Add user authentication"`
89+
- Bash (selective repos): `{SCRIPT} --json --repos "components/auth,components/core" --short-name "user-auth" "Add user authentication"`
90+
- Bash (custom depth): `{SCRIPT} --json --scan-depth 3 --short-name "user-auth" "Add user authentication"`
8491
- PowerShell example: `{SCRIPT} -Json -ShortName "user-auth" "Add user authentication"`
85-
- PowerShell (timestamp): `{SCRIPT} -Json -Timestamp -ShortName "user-auth" "Add user authentication"`
92+
- PowerShell (selective repos): `{SCRIPT} -Json -Repos "components/auth,components/core" -ShortName "user-auth" "Add user authentication"`
8693
8794
**IMPORTANT**:
8895
- Do NOT pass `--number` — the script determines the correct next number automatically
@@ -91,7 +98,7 @@ Given that feature description, do this:
9198
- The JSON is provided in the terminal as output - always refer to it to get the actual content you're looking for
9299
- The JSON output will contain BRANCH_NAME, SPEC_FILE paths, and NESTED_REPOS (an array of nested independent git repositories where the feature branch was also created)
93100
- For single quotes in args like "I'm Groot", use escape syntax: e.g 'I'\''m Groot' (or double-quote if possible: "I'm Groot")
94-
- **Nested git repos**: The script auto-detects subdirectories with their own `.git` (up to 2 levels deep) and creates the same feature branch in each. The JSON output includes a `NESTED_REPOS` array with `path` and `status` (`created`, `existing`, `dry_run`, or `failed`) for each nested repo. Failures in nested repos are warnings and do not block the main workflow.
101+
- **Nested git repos**: The script auto-detects subdirectories with their own `.git` (configurable depth via `--scan-depth`, default 2). Use `--repos` to branch only specific repos relevant to the feature. The JSON output includes a `NESTED_REPOS` array with `path` and `status` (`created`, `existing`, `dry_run`, or `failed`) for each nested repo. Failures in nested repos are warnings and do not block the main workflow.
95102
96103
3. Load `templates/spec-template.md` to understand required sections.
97104

0 commit comments

Comments
 (0)