Skip to content

Commit e254f6a

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 aa2282e commit e254f6a

File tree

5 files changed

+561
-4
lines changed

5 files changed

+561
-4
lines changed

scripts/bash/common.sh

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

281+
# 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.
285+
# Outputs one absolute path per line.
286+
find_nested_git_repos() {
287+
local repo_root="${1:-$(get_repo_root)}"
288+
# Directories to skip during traversal
289+
local -a skip_dirs=(".specify" ".git" "node_modules" "vendor" ".venv" "venv"
290+
"__pycache__" ".gradle" "build" "dist" "target" ".idea"
291+
".vscode" "specs")
292+
293+
_should_skip() {
294+
local name="$1"
295+
for skip in "${skip_dirs[@]}"; do
296+
[ "$name" = "$skip" ] && return 0
297+
done
298+
return 1
299+
}
300+
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
327+
fi
328+
done
329+
fi
330+
done
331+
}
332+
281333
# Resolve a template name to a file path using the priority stack:
282334
# 1. .specify/templates/overrides/
283335
# 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
@@ -381,32 +381,101 @@ if [ "$DRY_RUN" != true ]; then
381381
printf '# To persist: export SPECIFY_FEATURE=%q\n' "$BRANCH_NAME" >&2
382382
fi
383383

384+
# Create matching feature branches in nested independent git repositories
385+
NESTED_REPOS_JSON=""
386+
if [ "$HAS_GIT" = true ]; then
387+
nested_repos=$(find_nested_git_repos "$REPO_ROOT")
388+
if [ -n "$nested_repos" ]; then
389+
NESTED_REPOS_JSON="["
390+
first=true
391+
while IFS= read -r nested_path; do
392+
[ -z "$nested_path" ] && continue
393+
# Normalize: remove trailing slash
394+
nested_path="${nested_path%/}"
395+
# Compute relative path for output
396+
rel_path="${nested_path#"$REPO_ROOT/"}"
397+
rel_path="${rel_path%/}"
398+
status="skipped"
399+
400+
if [ "$DRY_RUN" = true ]; then
401+
status="dry_run"
402+
else
403+
# Attempt to create the branch in the nested repo
404+
if git -C "$nested_path" checkout -q -b "$BRANCH_NAME" 2>/dev/null; then
405+
status="created"
406+
else
407+
# Check if the branch already exists
408+
if git -C "$nested_path" branch --list "$BRANCH_NAME" 2>/dev/null | grep -q .; then
409+
if [ "$ALLOW_EXISTING" = true ]; then
410+
current_nested="$(git -C "$nested_path" rev-parse --abbrev-ref HEAD 2>/dev/null || true)"
411+
if [ "$current_nested" = "$BRANCH_NAME" ]; then
412+
status="existing"
413+
elif git -C "$nested_path" checkout -q "$BRANCH_NAME" 2>/dev/null; then
414+
status="existing"
415+
else
416+
status="failed"
417+
>&2 echo "[specify] Warning: Failed to switch nested repo '$rel_path' to branch '$BRANCH_NAME'"
418+
fi
419+
else
420+
status="existing"
421+
fi
422+
else
423+
status="failed"
424+
>&2 echo "[specify] Warning: Failed to create branch '$BRANCH_NAME' in nested repo '$rel_path'"
425+
fi
426+
fi
427+
fi
428+
429+
if [ "$first" = true ]; then
430+
first=false
431+
else
432+
NESTED_REPOS_JSON+=","
433+
fi
434+
NESTED_REPOS_JSON+="{\"path\":\"$(json_escape "$rel_path")\",\"status\":\"$status\"}"
435+
done <<< "$nested_repos"
436+
NESTED_REPOS_JSON+="]"
437+
fi
438+
fi
439+
384440
if $JSON_MODE; then
441+
# Build the nested repos portion for JSON output
442+
nested_json_field=""
443+
if [ -n "$NESTED_REPOS_JSON" ]; then
444+
nested_json_field="$NESTED_REPOS_JSON"
445+
else
446+
nested_json_field="[]"
447+
fi
448+
385449
if command -v jq >/dev/null 2>&1; then
386450
if [ "$DRY_RUN" = true ]; then
387451
jq -cn \
388452
--arg branch_name "$BRANCH_NAME" \
389453
--arg spec_file "$SPEC_FILE" \
390454
--arg feature_num "$FEATURE_NUM" \
391-
'{BRANCH_NAME:$branch_name,SPEC_FILE:$spec_file,FEATURE_NUM:$feature_num,DRY_RUN:true}'
455+
--argjson nested_repos "$nested_json_field" \
456+
'{BRANCH_NAME:$branch_name,SPEC_FILE:$spec_file,FEATURE_NUM:$feature_num,DRY_RUN:true,NESTED_REPOS:$nested_repos}'
392457
else
393458
jq -cn \
394459
--arg branch_name "$BRANCH_NAME" \
395460
--arg spec_file "$SPEC_FILE" \
396461
--arg feature_num "$FEATURE_NUM" \
397-
'{BRANCH_NAME:$branch_name,SPEC_FILE:$spec_file,FEATURE_NUM:$feature_num}'
462+
--argjson nested_repos "$nested_json_field" \
463+
'{BRANCH_NAME:$branch_name,SPEC_FILE:$spec_file,FEATURE_NUM:$feature_num,NESTED_REPOS:$nested_repos}'
398464
fi
399465
else
400466
if [ "$DRY_RUN" = true ]; then
401-
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")"
467+
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"
402468
else
403-
printf '{"BRANCH_NAME":"%s","SPEC_FILE":"%s","FEATURE_NUM":"%s"}\n' "$(json_escape "$BRANCH_NAME")" "$(json_escape "$SPEC_FILE")" "$(json_escape "$FEATURE_NUM")"
469+
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"
404470
fi
405471
fi
406472
else
407473
echo "BRANCH_NAME: $BRANCH_NAME"
408474
echo "SPEC_FILE: $SPEC_FILE"
409475
echo "FEATURE_NUM: $FEATURE_NUM"
476+
if [ -n "$NESTED_REPOS_JSON" ] && [ "$NESTED_REPOS_JSON" != "[]" ]; then
477+
echo "NESTED_REPOS: $NESTED_REPOS_JSON"
478+
fi
410479
if [ "$DRY_RUN" != true ]; then
411480
printf '# To persist in your shell: export SPECIFY_FEATURE=%q\n' "$BRANCH_NAME"
412481
fi

scripts/powershell/common.ps1

Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -228,6 +228,56 @@ function Test-DirHasFiles {
228228
}
229229
}
230230

231+
# 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.
235+
# Returns an array of absolute paths.
236+
function Find-NestedGitRepos {
237+
param([string]$RepoRoot = (Get-RepoRoot))
238+
239+
$skipDirs = @('.specify', '.git', 'node_modules', 'vendor', '.venv', 'venv',
240+
'__pycache__', '.gradle', 'build', 'dist', 'target', '.idea',
241+
'.vscode', 'specs')
242+
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+
}
274+
}
275+
}
276+
}
277+
278+
return $results
279+
}
280+
231281
# Resolve a template name to a file path using the priority stack:
232282
# 1. .specify/templates/overrides/
233283
# 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
@@ -360,12 +360,70 @@ if (-not $DryRun) {
360360
$env:SPECIFY_FEATURE = $branchName
361361
}
362362

363+
# Create matching feature branches in nested independent git repositories
364+
$nestedReposResult = @()
365+
if ($hasGit) {
366+
$nestedRepos = Find-NestedGitRepos -RepoRoot $repoRoot
367+
foreach ($nestedPath in $nestedRepos) {
368+
$relPath = $nestedPath.Substring($repoRoot.Length).TrimStart('\', '/')
369+
$nestedStatus = 'skipped'
370+
371+
if ($DryRun) {
372+
$nestedStatus = 'dry_run'
373+
} else {
374+
try {
375+
git -C $nestedPath checkout -q -b $branchName 2>$null | Out-Null
376+
if ($LASTEXITCODE -eq 0) {
377+
$nestedStatus = 'created'
378+
} else {
379+
throw "branch creation failed"
380+
}
381+
} catch {
382+
# Check if branch already exists
383+
$existingNested = git -C $nestedPath branch --list $branchName 2>$null
384+
if ($existingNested) {
385+
if ($AllowExistingBranch) {
386+
$currentNested = git -C $nestedPath rev-parse --abbrev-ref HEAD 2>$null
387+
if ($currentNested -eq $branchName) {
388+
$nestedStatus = 'existing'
389+
} else {
390+
try {
391+
git -C $nestedPath checkout -q $branchName 2>$null | Out-Null
392+
if ($LASTEXITCODE -eq 0) {
393+
$nestedStatus = 'existing'
394+
} else {
395+
$nestedStatus = 'failed'
396+
Write-Warning "[specify] Failed to switch nested repo '$relPath' to branch '$branchName'"
397+
}
398+
} catch {
399+
$nestedStatus = 'failed'
400+
Write-Warning "[specify] Failed to switch nested repo '$relPath' to branch '$branchName'"
401+
}
402+
}
403+
} else {
404+
$nestedStatus = 'existing'
405+
}
406+
} else {
407+
$nestedStatus = 'failed'
408+
Write-Warning "[specify] Failed to create branch '$branchName' in nested repo '$relPath'"
409+
}
410+
}
411+
}
412+
413+
$nestedReposResult += [PSCustomObject]@{
414+
path = $relPath
415+
status = $nestedStatus
416+
}
417+
}
418+
}
419+
363420
if ($Json) {
364421
$obj = [PSCustomObject]@{
365422
BRANCH_NAME = $branchName
366423
SPEC_FILE = $specFile
367424
FEATURE_NUM = $featureNum
368425
HAS_GIT = $hasGit
426+
NESTED_REPOS = $nestedReposResult
369427
}
370428
if ($DryRun) {
371429
$obj | Add-Member -NotePropertyName 'DRY_RUN' -NotePropertyValue $true
@@ -376,6 +434,12 @@ if ($Json) {
376434
Write-Output "SPEC_FILE: $specFile"
377435
Write-Output "FEATURE_NUM: $featureNum"
378436
Write-Output "HAS_GIT: $hasGit"
437+
if ($nestedReposResult.Count -gt 0) {
438+
Write-Output "NESTED_REPOS:"
439+
foreach ($nr in $nestedReposResult) {
440+
Write-Output " $($nr.path): $($nr.status)"
441+
}
442+
}
379443
if (-not $DryRun) {
380444
Write-Output "SPECIFY_FEATURE environment variable set to: $branchName"
381445
}

0 commit comments

Comments
 (0)