Skip to content

Commit 4e28ed8

Browse files
sakitACopilot
andcommitted
feat: support coordinated feature branching across nested independent git repos
Add auto-detection of nested independent git repositories (not submodules) and create matching feature branches in each when running create-new-feature. Changes: - common.sh: Add find_nested_git_repos() - discovers nested repos up to 2 levels deep - common.ps1: Add Find-NestedGitRepos - PowerShell equivalent - create-new-feature.sh: Create feature branches in all nested repos after root - create-new-feature.ps1: Same for PowerShell - specify.md: Document NESTED_REPOS JSON output field - test_nested_repos.py: 9 tests covering discovery and branch creation Design decisions: - Auto-detect via .git presence (no configuration required) - Non-blocking: nested repo failures are warnings, not errors - JSON output extended with NESTED_REPOS array [{path, status}] - Backward-compatible: no change for single-repo workflows Resolves #2120 Related to #1050 Note: This contribution was made with AI assistance (GitHub Copilot). Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
1 parent 94ba857 commit 4e28ed8

File tree

6 files changed

+563
-5
lines changed

6 files changed

+563
-5
lines changed

scripts/bash/common.sh

Lines changed: 52 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -252,6 +252,58 @@ json_escape() {
252252
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

255+
# 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.
259+
# Outputs one absolute path per line.
260+
find_nested_git_repos() {
261+
local repo_root="${1:-$(get_repo_root)}"
262+
# Directories to skip during traversal
263+
local -a skip_dirs=(".specify" ".git" "node_modules" "vendor" ".venv" "venv"
264+
"__pycache__" ".gradle" "build" "dist" "target" ".idea"
265+
".vscode" "specs")
266+
267+
_should_skip() {
268+
local name="$1"
269+
for skip in "${skip_dirs[@]}"; do
270+
[ "$name" = "$skip" ] && return 0
271+
done
272+
return 1
273+
}
274+
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
301+
fi
302+
done
303+
fi
304+
done
305+
}
306+
255307
# Resolve a template name to a file path using the priority stack:
256308
# 1. .specify/templates/overrides/
257309
# 2. .specify/presets/<preset-id>/templates/ (sorted by priority from .registry)

scripts/bash/create-new-feature.sh

Lines changed: 73 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -368,32 +368,101 @@ if [ "$DRY_RUN" != true ]; then
368368
printf '# To persist: export SPECIFY_FEATURE=%q\n' "$BRANCH_NAME" >&2
369369
fi
370370

371+
# Create matching feature branches in nested independent git repositories
372+
NESTED_REPOS_JSON=""
373+
if [ "$HAS_GIT" = true ]; then
374+
nested_repos=$(find_nested_git_repos "$REPO_ROOT")
375+
if [ -n "$nested_repos" ]; then
376+
NESTED_REPOS_JSON="["
377+
first=true
378+
while IFS= read -r nested_path; do
379+
[ -z "$nested_path" ] && continue
380+
# Normalize: remove trailing slash
381+
nested_path="${nested_path%/}"
382+
# Compute relative path for output
383+
rel_path="${nested_path#"$REPO_ROOT/"}"
384+
rel_path="${rel_path%/}"
385+
status="skipped"
386+
387+
if [ "$DRY_RUN" = true ]; then
388+
status="dry_run"
389+
else
390+
# Attempt to create the branch in the nested repo
391+
if git -C "$nested_path" checkout -q -b "$BRANCH_NAME" 2>/dev/null; then
392+
status="created"
393+
else
394+
# Check if the branch already exists
395+
if git -C "$nested_path" branch --list "$BRANCH_NAME" 2>/dev/null | grep -q .; then
396+
if [ "$ALLOW_EXISTING" = true ]; then
397+
current_nested="$(git -C "$nested_path" rev-parse --abbrev-ref HEAD 2>/dev/null || true)"
398+
if [ "$current_nested" = "$BRANCH_NAME" ]; then
399+
status="existing"
400+
elif git -C "$nested_path" checkout -q "$BRANCH_NAME" 2>/dev/null; then
401+
status="existing"
402+
else
403+
status="failed"
404+
>&2 echo "[specify] Warning: Failed to switch nested repo '$rel_path' to branch '$BRANCH_NAME'"
405+
fi
406+
else
407+
status="existing"
408+
fi
409+
else
410+
status="failed"
411+
>&2 echo "[specify] Warning: Failed to create branch '$BRANCH_NAME' in nested repo '$rel_path'"
412+
fi
413+
fi
414+
fi
415+
416+
if [ "$first" = true ]; then
417+
first=false
418+
else
419+
NESTED_REPOS_JSON+=","
420+
fi
421+
NESTED_REPOS_JSON+="{\"path\":\"$(json_escape "$rel_path")\",\"status\":\"$status\"}"
422+
done <<< "$nested_repos"
423+
NESTED_REPOS_JSON+="]"
424+
fi
425+
fi
426+
371427
if $JSON_MODE; then
428+
# Build the nested repos portion for JSON output
429+
nested_json_field=""
430+
if [ -n "$NESTED_REPOS_JSON" ]; then
431+
nested_json_field="$NESTED_REPOS_JSON"
432+
else
433+
nested_json_field="[]"
434+
fi
435+
372436
if command -v jq >/dev/null 2>&1; then
373437
if [ "$DRY_RUN" = true ]; then
374438
jq -cn \
375439
--arg branch_name "$BRANCH_NAME" \
376440
--arg spec_file "$SPEC_FILE" \
377441
--arg feature_num "$FEATURE_NUM" \
378-
'{BRANCH_NAME:$branch_name,SPEC_FILE:$spec_file,FEATURE_NUM:$feature_num,DRY_RUN:true}'
442+
--argjson nested_repos "$nested_json_field" \
443+
'{BRANCH_NAME:$branch_name,SPEC_FILE:$spec_file,FEATURE_NUM:$feature_num,DRY_RUN:true,NESTED_REPOS:$nested_repos}'
379444
else
380445
jq -cn \
381446
--arg branch_name "$BRANCH_NAME" \
382447
--arg spec_file "$SPEC_FILE" \
383448
--arg feature_num "$FEATURE_NUM" \
384-
'{BRANCH_NAME:$branch_name,SPEC_FILE:$spec_file,FEATURE_NUM:$feature_num}'
449+
--argjson nested_repos "$nested_json_field" \
450+
'{BRANCH_NAME:$branch_name,SPEC_FILE:$spec_file,FEATURE_NUM:$feature_num,NESTED_REPOS:$nested_repos}'
385451
fi
386452
else
387453
if [ "$DRY_RUN" = true ]; then
388-
printf '{"BRANCH_NAME":"%s","SPEC_FILE":"%s","FEATURE_NUM":"%s","DRY_RUN":true}\n' "$(json_escape "$BRANCH_NAME")" "$(json_escape "$SPEC_FILE")" "$(json_escape "$FEATURE_NUM")"
454+
printf '{"BRANCH_NAME":"%s","SPEC_FILE":"%s","FEATURE_NUM":"%s","DRY_RUN":true,"NESTED_REPOS":%s}\n' "$(json_escape "$BRANCH_NAME")" "$(json_escape "$SPEC_FILE")" "$(json_escape "$FEATURE_NUM")" "$nested_json_field"
389455
else
390-
printf '{"BRANCH_NAME":"%s","SPEC_FILE":"%s","FEATURE_NUM":"%s"}\n' "$(json_escape "$BRANCH_NAME")" "$(json_escape "$SPEC_FILE")" "$(json_escape "$FEATURE_NUM")"
456+
printf '{"BRANCH_NAME":"%s","SPEC_FILE":"%s","FEATURE_NUM":"%s","NESTED_REPOS":%s}\n' "$(json_escape "$BRANCH_NAME")" "$(json_escape "$SPEC_FILE")" "$(json_escape "$FEATURE_NUM")" "$nested_json_field"
391457
fi
392458
fi
393459
else
394460
echo "BRANCH_NAME: $BRANCH_NAME"
395461
echo "SPEC_FILE: $SPEC_FILE"
396462
echo "FEATURE_NUM: $FEATURE_NUM"
463+
if [ -n "$NESTED_REPOS_JSON" ] && [ "$NESTED_REPOS_JSON" != "[]" ]; then
464+
echo "NESTED_REPOS: $NESTED_REPOS_JSON"
465+
fi
397466
if [ "$DRY_RUN" != true ]; then
398467
printf '# To persist in your shell: export SPECIFY_FEATURE=%q\n' "$BRANCH_NAME"
399468
fi

scripts/powershell/common.ps1

Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -199,6 +199,56 @@ function Test-DirHasFiles {
199199
}
200200
}
201201

202+
# Discover nested independent git repositories under RepoRoot.
203+
# Searches up to 2 directory levels deep for subdirectories containing .git
204+
# (directory or file, covering worktrees/submodules). Excludes the root repo
205+
# itself and common non-project directories.
206+
# Returns an array of absolute paths.
207+
function Find-NestedGitRepos {
208+
param([string]$RepoRoot = (Get-RepoRoot))
209+
210+
$skipDirs = @('.specify', '.git', 'node_modules', 'vendor', '.venv', 'venv',
211+
'__pycache__', '.gradle', 'build', 'dist', 'target', '.idea',
212+
'.vscode', 'specs')
213+
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+
}
245+
}
246+
}
247+
}
248+
249+
return $results
250+
}
251+
202252
# Resolve a template name to a file path using the priority stack:
203253
# 1. .specify/templates/overrides/
204254
# 2. .specify/presets/<preset-id>/templates/ (sorted by priority from .registry)

scripts/powershell/create-new-feature.ps1

Lines changed: 64 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -344,12 +344,70 @@ if (-not $DryRun) {
344344
$env:SPECIFY_FEATURE = $branchName
345345
}
346346

347+
# Create matching feature branches in nested independent git repositories
348+
$nestedReposResult = @()
349+
if ($hasGit) {
350+
$nestedRepos = Find-NestedGitRepos -RepoRoot $repoRoot
351+
foreach ($nestedPath in $nestedRepos) {
352+
$relPath = $nestedPath.Substring($repoRoot.Length).TrimStart('\', '/')
353+
$nestedStatus = 'skipped'
354+
355+
if ($DryRun) {
356+
$nestedStatus = 'dry_run'
357+
} else {
358+
try {
359+
git -C $nestedPath checkout -q -b $branchName 2>$null | Out-Null
360+
if ($LASTEXITCODE -eq 0) {
361+
$nestedStatus = 'created'
362+
} else {
363+
throw "branch creation failed"
364+
}
365+
} catch {
366+
# Check if branch already exists
367+
$existingNested = git -C $nestedPath branch --list $branchName 2>$null
368+
if ($existingNested) {
369+
if ($AllowExistingBranch) {
370+
$currentNested = git -C $nestedPath rev-parse --abbrev-ref HEAD 2>$null
371+
if ($currentNested -eq $branchName) {
372+
$nestedStatus = 'existing'
373+
} else {
374+
try {
375+
git -C $nestedPath checkout -q $branchName 2>$null | Out-Null
376+
if ($LASTEXITCODE -eq 0) {
377+
$nestedStatus = 'existing'
378+
} else {
379+
$nestedStatus = 'failed'
380+
Write-Warning "[specify] Failed to switch nested repo '$relPath' to branch '$branchName'"
381+
}
382+
} catch {
383+
$nestedStatus = 'failed'
384+
Write-Warning "[specify] Failed to switch nested repo '$relPath' to branch '$branchName'"
385+
}
386+
}
387+
} else {
388+
$nestedStatus = 'existing'
389+
}
390+
} else {
391+
$nestedStatus = 'failed'
392+
Write-Warning "[specify] Failed to create branch '$branchName' in nested repo '$relPath'"
393+
}
394+
}
395+
}
396+
397+
$nestedReposResult += [PSCustomObject]@{
398+
path = $relPath
399+
status = $nestedStatus
400+
}
401+
}
402+
}
403+
347404
if ($Json) {
348405
$obj = [PSCustomObject]@{
349406
BRANCH_NAME = $branchName
350407
SPEC_FILE = $specFile
351408
FEATURE_NUM = $featureNum
352409
HAS_GIT = $hasGit
410+
NESTED_REPOS = $nestedReposResult
353411
}
354412
if ($DryRun) {
355413
$obj | Add-Member -NotePropertyName 'DRY_RUN' -NotePropertyValue $true
@@ -360,6 +418,12 @@ if ($Json) {
360418
Write-Output "SPEC_FILE: $specFile"
361419
Write-Output "FEATURE_NUM: $featureNum"
362420
Write-Output "HAS_GIT: $hasGit"
421+
if ($nestedReposResult.Count -gt 0) {
422+
Write-Output "NESTED_REPOS:"
423+
foreach ($nr in $nestedReposResult) {
424+
Write-Output " $($nr.path): $($nr.status)"
425+
}
426+
}
363427
if (-not $DryRun) {
364428
Write-Output "SPECIFY_FEATURE environment variable set to: $branchName"
365429
}

templates/commands/specify.md

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -89,8 +89,9 @@ Given that feature description, do this:
8989
- Always include the JSON flag (`--json` for Bash, `-Json` for PowerShell) so the output can be parsed reliably
9090
- You must only ever run this script once per feature
9191
- The JSON is provided in the terminal as output - always refer to it to get the actual content you're looking for
92-
- The JSON output will contain BRANCH_NAME and SPEC_FILE paths
92+
- 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)
9393
- 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.
9495
9596
3. Load `templates/spec-template.md` to understand required sections.
9697

0 commit comments

Comments
 (0)