From e254f6abbd5beb2fa790e080226508f508a776d7 Mon Sep 17 00:00:00 2001 From: Sakit Atakishiyev Date: Wed, 8 Apr 2026 00:37:03 -0700 Subject: [PATCH 1/8] 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> --- scripts/bash/common.sh | 52 ++++ scripts/bash/create-new-feature.sh | 77 +++++- scripts/powershell/common.ps1 | 50 ++++ scripts/powershell/create-new-feature.ps1 | 64 +++++ tests/test_nested_repos.py | 322 ++++++++++++++++++++++ 5 files changed, 561 insertions(+), 4 deletions(-) create mode 100644 tests/test_nested_repos.py diff --git a/scripts/bash/common.sh b/scripts/bash/common.sh index 04af7d794f..a52edb692d 100644 --- a/scripts/bash/common.sh +++ b/scripts/bash/common.sh @@ -278,6 +278,58 @@ json_escape() { check_file() { [[ -f "$1" ]] && echo " ✓ $2" || echo " ✗ $2"; } check_dir() { [[ -d "$1" && -n $(ls -A "$1" 2>/dev/null) ]] && echo " ✓ $2" || echo " ✗ $2"; } +# Discover nested independent git repositories under REPO_ROOT. +# Searches up to 2 directory levels deep for subdirectories containing .git +# (directory or file, covering worktrees/submodules). Excludes the root repo +# itself and common non-project directories. +# Outputs one absolute path per line. +find_nested_git_repos() { + local repo_root="${1:-$(get_repo_root)}" + # Directories to skip during traversal + local -a skip_dirs=(".specify" ".git" "node_modules" "vendor" ".venv" "venv" + "__pycache__" ".gradle" "build" "dist" "target" ".idea" + ".vscode" "specs") + + _should_skip() { + local name="$1" + for skip in "${skip_dirs[@]}"; do + [ "$name" = "$skip" ] && return 0 + done + return 1 + } + + # Level 1 + for child in "$repo_root"/*/; do + [ -d "$child" ] || continue + child="${child%/}" + local child_name + child_name="$(basename "$child")" + _should_skip "$child_name" && continue + + if [ -e "$child/.git" ]; then + # Verify it is a valid git work tree + if git -C "$child" rev-parse --is-inside-work-tree >/dev/null 2>&1; then + echo "$child" + fi + else + # Level 2 + for grandchild in "$child"/*/; do + [ -d "$grandchild" ] || continue + grandchild="${grandchild%/}" + local gc_name + gc_name="$(basename "$grandchild")" + _should_skip "$gc_name" && continue + + if [ -e "$grandchild/.git" ]; then + if git -C "$grandchild" rev-parse --is-inside-work-tree >/dev/null 2>&1; then + echo "$grandchild" + fi + fi + done + fi + done +} + # Resolve a template name to a file path using the priority stack: # 1. .specify/templates/overrides/ # 2. .specify/presets//templates/ (sorted by priority from .registry) diff --git a/scripts/bash/create-new-feature.sh b/scripts/bash/create-new-feature.sh index 1879647026..5e4cf9a19c 100644 --- a/scripts/bash/create-new-feature.sh +++ b/scripts/bash/create-new-feature.sh @@ -381,32 +381,101 @@ if [ "$DRY_RUN" != true ]; then printf '# To persist: export SPECIFY_FEATURE=%q\n' "$BRANCH_NAME" >&2 fi +# Create matching feature branches in nested independent git repositories +NESTED_REPOS_JSON="" +if [ "$HAS_GIT" = true ]; then + nested_repos=$(find_nested_git_repos "$REPO_ROOT") + if [ -n "$nested_repos" ]; then + NESTED_REPOS_JSON="[" + first=true + while IFS= read -r nested_path; do + [ -z "$nested_path" ] && continue + # Normalize: remove trailing slash + nested_path="${nested_path%/}" + # Compute relative path for output + rel_path="${nested_path#"$REPO_ROOT/"}" + rel_path="${rel_path%/}" + status="skipped" + + if [ "$DRY_RUN" = true ]; then + status="dry_run" + else + # Attempt to create the branch in the nested repo + if git -C "$nested_path" checkout -q -b "$BRANCH_NAME" 2>/dev/null; then + status="created" + else + # Check if the branch already exists + if git -C "$nested_path" branch --list "$BRANCH_NAME" 2>/dev/null | grep -q .; then + if [ "$ALLOW_EXISTING" = true ]; then + current_nested="$(git -C "$nested_path" rev-parse --abbrev-ref HEAD 2>/dev/null || true)" + if [ "$current_nested" = "$BRANCH_NAME" ]; then + status="existing" + elif git -C "$nested_path" checkout -q "$BRANCH_NAME" 2>/dev/null; then + status="existing" + else + status="failed" + >&2 echo "[specify] Warning: Failed to switch nested repo '$rel_path' to branch '$BRANCH_NAME'" + fi + else + status="existing" + fi + else + status="failed" + >&2 echo "[specify] Warning: Failed to create branch '$BRANCH_NAME' in nested repo '$rel_path'" + fi + fi + fi + + if [ "$first" = true ]; then + first=false + else + NESTED_REPOS_JSON+="," + fi + NESTED_REPOS_JSON+="{\"path\":\"$(json_escape "$rel_path")\",\"status\":\"$status\"}" + done <<< "$nested_repos" + NESTED_REPOS_JSON+="]" + fi +fi + if $JSON_MODE; then + # Build the nested repos portion for JSON output + nested_json_field="" + if [ -n "$NESTED_REPOS_JSON" ]; then + nested_json_field="$NESTED_REPOS_JSON" + else + nested_json_field="[]" + fi + if command -v jq >/dev/null 2>&1; then if [ "$DRY_RUN" = true ]; then jq -cn \ --arg branch_name "$BRANCH_NAME" \ --arg spec_file "$SPEC_FILE" \ --arg feature_num "$FEATURE_NUM" \ - '{BRANCH_NAME:$branch_name,SPEC_FILE:$spec_file,FEATURE_NUM:$feature_num,DRY_RUN:true}' + --argjson nested_repos "$nested_json_field" \ + '{BRANCH_NAME:$branch_name,SPEC_FILE:$spec_file,FEATURE_NUM:$feature_num,DRY_RUN:true,NESTED_REPOS:$nested_repos}' else jq -cn \ --arg branch_name "$BRANCH_NAME" \ --arg spec_file "$SPEC_FILE" \ --arg feature_num "$FEATURE_NUM" \ - '{BRANCH_NAME:$branch_name,SPEC_FILE:$spec_file,FEATURE_NUM:$feature_num}' + --argjson nested_repos "$nested_json_field" \ + '{BRANCH_NAME:$branch_name,SPEC_FILE:$spec_file,FEATURE_NUM:$feature_num,NESTED_REPOS:$nested_repos}' fi else if [ "$DRY_RUN" = true ]; then - 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")" + 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" else - printf '{"BRANCH_NAME":"%s","SPEC_FILE":"%s","FEATURE_NUM":"%s"}\n' "$(json_escape "$BRANCH_NAME")" "$(json_escape "$SPEC_FILE")" "$(json_escape "$FEATURE_NUM")" + 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" fi fi else echo "BRANCH_NAME: $BRANCH_NAME" echo "SPEC_FILE: $SPEC_FILE" echo "FEATURE_NUM: $FEATURE_NUM" + if [ -n "$NESTED_REPOS_JSON" ] && [ "$NESTED_REPOS_JSON" != "[]" ]; then + echo "NESTED_REPOS: $NESTED_REPOS_JSON" + fi if [ "$DRY_RUN" != true ]; then printf '# To persist in your shell: export SPECIFY_FEATURE=%q\n' "$BRANCH_NAME" fi diff --git a/scripts/powershell/common.ps1 b/scripts/powershell/common.ps1 index 35ed884f0f..c0d5e853c8 100644 --- a/scripts/powershell/common.ps1 +++ b/scripts/powershell/common.ps1 @@ -228,6 +228,56 @@ function Test-DirHasFiles { } } +# Discover nested independent git repositories under RepoRoot. +# Searches up to 2 directory levels deep for subdirectories containing .git +# (directory or file, covering worktrees/submodules). Excludes the root repo +# itself and common non-project directories. +# Returns an array of absolute paths. +function Find-NestedGitRepos { + param([string]$RepoRoot = (Get-RepoRoot)) + + $skipDirs = @('.specify', '.git', 'node_modules', 'vendor', '.venv', 'venv', + '__pycache__', '.gradle', 'build', 'dist', 'target', '.idea', + '.vscode', 'specs') + + $results = @() + + # Level 1 + $children = Get-ChildItem -Path $RepoRoot -Directory -ErrorAction SilentlyContinue | + Where-Object { $skipDirs -notcontains $_.Name } + + foreach ($child in $children) { + $gitMarker = Join-Path $child.FullName '.git' + if (Test-Path -LiteralPath $gitMarker) { + # Verify it is a valid git work tree + try { + $null = git -C $child.FullName rev-parse --is-inside-work-tree 2>$null + if ($LASTEXITCODE -eq 0) { + $results += $child.FullName + } + } catch { } + } else { + # Level 2 + $grandchildren = Get-ChildItem -Path $child.FullName -Directory -ErrorAction SilentlyContinue | + Where-Object { $skipDirs -notcontains $_.Name } + + foreach ($gc in $grandchildren) { + $gcGitMarker = Join-Path $gc.FullName '.git' + if (Test-Path -LiteralPath $gcGitMarker) { + try { + $null = git -C $gc.FullName rev-parse --is-inside-work-tree 2>$null + if ($LASTEXITCODE -eq 0) { + $results += $gc.FullName + } + } catch { } + } + } + } + } + + return $results +} + # Resolve a template name to a file path using the priority stack: # 1. .specify/templates/overrides/ # 2. .specify/presets//templates/ (sorted by priority from .registry) diff --git a/scripts/powershell/create-new-feature.ps1 b/scripts/powershell/create-new-feature.ps1 index 2f23283fc4..ac5e626a3d 100644 --- a/scripts/powershell/create-new-feature.ps1 +++ b/scripts/powershell/create-new-feature.ps1 @@ -360,12 +360,70 @@ if (-not $DryRun) { $env:SPECIFY_FEATURE = $branchName } +# Create matching feature branches in nested independent git repositories +$nestedReposResult = @() +if ($hasGit) { + $nestedRepos = Find-NestedGitRepos -RepoRoot $repoRoot + foreach ($nestedPath in $nestedRepos) { + $relPath = $nestedPath.Substring($repoRoot.Length).TrimStart('\', '/') + $nestedStatus = 'skipped' + + if ($DryRun) { + $nestedStatus = 'dry_run' + } else { + try { + git -C $nestedPath checkout -q -b $branchName 2>$null | Out-Null + if ($LASTEXITCODE -eq 0) { + $nestedStatus = 'created' + } else { + throw "branch creation failed" + } + } catch { + # Check if branch already exists + $existingNested = git -C $nestedPath branch --list $branchName 2>$null + if ($existingNested) { + if ($AllowExistingBranch) { + $currentNested = git -C $nestedPath rev-parse --abbrev-ref HEAD 2>$null + if ($currentNested -eq $branchName) { + $nestedStatus = 'existing' + } else { + try { + git -C $nestedPath checkout -q $branchName 2>$null | Out-Null + if ($LASTEXITCODE -eq 0) { + $nestedStatus = 'existing' + } else { + $nestedStatus = 'failed' + Write-Warning "[specify] Failed to switch nested repo '$relPath' to branch '$branchName'" + } + } catch { + $nestedStatus = 'failed' + Write-Warning "[specify] Failed to switch nested repo '$relPath' to branch '$branchName'" + } + } + } else { + $nestedStatus = 'existing' + } + } else { + $nestedStatus = 'failed' + Write-Warning "[specify] Failed to create branch '$branchName' in nested repo '$relPath'" + } + } + } + + $nestedReposResult += [PSCustomObject]@{ + path = $relPath + status = $nestedStatus + } + } +} + if ($Json) { $obj = [PSCustomObject]@{ BRANCH_NAME = $branchName SPEC_FILE = $specFile FEATURE_NUM = $featureNum HAS_GIT = $hasGit + NESTED_REPOS = $nestedReposResult } if ($DryRun) { $obj | Add-Member -NotePropertyName 'DRY_RUN' -NotePropertyValue $true @@ -376,6 +434,12 @@ if ($Json) { Write-Output "SPEC_FILE: $specFile" Write-Output "FEATURE_NUM: $featureNum" Write-Output "HAS_GIT: $hasGit" + if ($nestedReposResult.Count -gt 0) { + Write-Output "NESTED_REPOS:" + foreach ($nr in $nestedReposResult) { + Write-Output " $($nr.path): $($nr.status)" + } + } if (-not $DryRun) { Write-Output "SPECIFY_FEATURE environment variable set to: $branchName" } diff --git a/tests/test_nested_repos.py b/tests/test_nested_repos.py new file mode 100644 index 0000000000..4779f5df79 --- /dev/null +++ b/tests/test_nested_repos.py @@ -0,0 +1,322 @@ +""" +Pytest tests for nested independent git repository support in create-new-feature scripts. + +Tests cover: +- Discovery of nested git repos via find_nested_git_repos (bash) / Find-NestedGitRepos (PS) +- Branch creation in nested repos during feature creation +- JSON output includes NESTED_REPOS field +- --dry-run reports nested repos without creating branches +- --allow-existing-branch propagates to nested repos +- Excluded directories are skipped +- Graceful handling when nested repos cannot be branched +""" + +import json +import os +import platform +import shutil +import subprocess +from pathlib import Path + +import pytest + +PROJECT_ROOT = Path(__file__).resolve().parent.parent +CREATE_FEATURE = PROJECT_ROOT / "scripts" / "bash" / "create-new-feature.sh" +COMMON_SH = PROJECT_ROOT / "scripts" / "bash" / "common.sh" + +# On Windows, prefer Git Bash over WSL bash +if platform.system() == "Windows": + _GIT_BASH = Path(r"C:\Program Files\Git\bin\bash.exe") + BASH = str(_GIT_BASH) if _GIT_BASH.exists() else "bash" +else: + BASH = "bash" + + +def _init_git_repo(path: Path) -> None: + """Initialize a git repo at the given path with an initial commit.""" + subprocess.run(["git", "init", "-q"], cwd=path, check=True) + subprocess.run( + ["git", "config", "user.email", "test@example.com"], cwd=path, check=True + ) + subprocess.run( + ["git", "config", "user.name", "Test User"], cwd=path, check=True + ) + subprocess.run( + ["git", "commit", "--allow-empty", "-m", "init", "-q"], + cwd=path, + check=True, + ) + + +@pytest.fixture +def git_repo_with_nested(tmp_path: Path) -> Path: + """Create a root git repo with nested independent git repos.""" + # Root repo + _init_git_repo(tmp_path) + scripts_dir = tmp_path / "scripts" / "bash" + scripts_dir.mkdir(parents=True) + shutil.copy(CREATE_FEATURE, scripts_dir / "create-new-feature.sh") + shutil.copy(COMMON_SH, scripts_dir / "common.sh") + (tmp_path / ".specify" / "templates").mkdir(parents=True) + + # Nested repo at level 1: components/core + core_dir = tmp_path / "components" / "core" + core_dir.mkdir(parents=True) + _init_git_repo(core_dir) + + # Nested repo at level 1: components/api + api_dir = tmp_path / "components" / "api" + api_dir.mkdir(parents=True) + _init_git_repo(api_dir) + + return tmp_path + + +@pytest.fixture +def git_repo_no_nested(tmp_path: Path) -> Path: + """Create a root git repo with no nested git repos.""" + _init_git_repo(tmp_path) + scripts_dir = tmp_path / "scripts" / "bash" + scripts_dir.mkdir(parents=True) + shutil.copy(CREATE_FEATURE, scripts_dir / "create-new-feature.sh") + shutil.copy(COMMON_SH, scripts_dir / "common.sh") + (tmp_path / ".specify" / "templates").mkdir(parents=True) + + # Regular subdirectory without .git + (tmp_path / "components" / "core").mkdir(parents=True) + return tmp_path + + +@pytest.fixture +def git_repo_with_excluded_dirs(tmp_path: Path) -> Path: + """Create a root git repo where git repos exist inside excluded directories.""" + _init_git_repo(tmp_path) + scripts_dir = tmp_path / "scripts" / "bash" + scripts_dir.mkdir(parents=True) + shutil.copy(CREATE_FEATURE, scripts_dir / "create-new-feature.sh") + shutil.copy(COMMON_SH, scripts_dir / "common.sh") + (tmp_path / ".specify" / "templates").mkdir(parents=True) + + # Git repo inside node_modules (should be excluded) + nm_dir = tmp_path / "node_modules" / "some-pkg" + nm_dir.mkdir(parents=True) + _init_git_repo(nm_dir) + + # Valid nested repo + lib_dir = tmp_path / "lib" + lib_dir.mkdir(parents=True) + _init_git_repo(lib_dir) + + return tmp_path + + +def run_script(cwd: Path, *args: str) -> subprocess.CompletedProcess: + """Run create-new-feature.sh with given args.""" + cmd = [BASH, "scripts/bash/create-new-feature.sh", *args] + return subprocess.run( + cmd, + cwd=cwd, + capture_output=True, + text=True, + ) + + +def source_and_call(func_call: str, cwd: Path | None = None, env: dict | None = None) -> subprocess.CompletedProcess: + """Source common.sh and call a function.""" + cmd = f'source "{COMMON_SH}" && {func_call}' + return subprocess.run( + [BASH, "-c", cmd], + cwd=cwd, + capture_output=True, + text=True, + env={**os.environ, **(env or {})}, + ) + + +# ── Discovery Tests ────────────────────────────────────────────────────────── + + +class TestFindNestedGitRepos: + def test_discovers_nested_repos(self, git_repo_with_nested: Path): + """find_nested_git_repos discovers nested repos at level 2.""" + result = source_and_call( + f'find_nested_git_repos "{git_repo_with_nested}"', + cwd=git_repo_with_nested, + ) + assert result.returncode == 0, result.stderr + paths = [p.strip().rstrip("/") for p in result.stdout.strip().splitlines() if p.strip()] + assert len(paths) == 2 + basenames = sorted(os.path.basename(p) for p in paths) + assert basenames == ["api", "core"] + + def test_no_nested_repos_returns_empty(self, git_repo_no_nested: Path): + """find_nested_git_repos returns empty when no nested repos exist.""" + result = source_and_call( + f'find_nested_git_repos "{git_repo_no_nested}"', + cwd=git_repo_no_nested, + ) + assert result.returncode == 0 + assert result.stdout.strip() == "" + + def test_excludes_node_modules(self, git_repo_with_excluded_dirs: Path): + """find_nested_git_repos skips repos inside excluded directories.""" + result = source_and_call( + f'find_nested_git_repos "{git_repo_with_excluded_dirs}"', + cwd=git_repo_with_excluded_dirs, + ) + assert result.returncode == 0 + paths = [p.strip().rstrip("/") for p in result.stdout.strip().splitlines() if p.strip()] + assert len(paths) == 1 + assert os.path.basename(paths[0]) == "lib" + + def test_discovers_level1_repos(self, tmp_path: Path): + """find_nested_git_repos discovers repos directly under root (level 1).""" + _init_git_repo(tmp_path) + (tmp_path / ".specify").mkdir() + + # Level 1 nested repo + nested = tmp_path / "mylib" + nested.mkdir() + _init_git_repo(nested) + + scripts_dir = tmp_path / "scripts" / "bash" + scripts_dir.mkdir(parents=True) + shutil.copy(COMMON_SH, scripts_dir / "common.sh") + + result = source_and_call( + f'find_nested_git_repos "{tmp_path}"', + cwd=tmp_path, + ) + assert result.returncode == 0 + paths = [p.strip().rstrip("/") for p in result.stdout.strip().splitlines() if p.strip()] + assert len(paths) == 1 + assert os.path.basename(paths[0]) == "mylib" + + +# ── Branch Creation Tests ──────────────────────────────────────────────────── + + +class TestNestedRepoBranchCreation: + def test_creates_branch_in_nested_repos(self, git_repo_with_nested: Path): + """Feature branch is created in all nested repos.""" + result = run_script( + git_repo_with_nested, + "--json", + "--short-name", "my-feat", + "Add a feature", + ) + assert result.returncode == 0, result.stderr + data = json.loads(result.stdout.strip()) + + # Verify root branch + assert data["BRANCH_NAME"] == "001-my-feat" + + # Verify nested repos + assert "NESTED_REPOS" in data + nested = sorted(data["NESTED_REPOS"], key=lambda x: x["path"]) + assert len(nested) == 2 + assert nested[0]["path"] == "components/api" + assert nested[0]["status"] == "created" + assert nested[1]["path"] == "components/core" + assert nested[1]["status"] == "created" + + # Verify branches actually exist in nested repos + for subdir in ["components/core", "components/api"]: + branch_result = subprocess.run( + ["git", "rev-parse", "--abbrev-ref", "HEAD"], + cwd=git_repo_with_nested / subdir, + capture_output=True, + text=True, + ) + assert branch_result.stdout.strip() == "001-my-feat" + + def test_no_nested_repos_returns_empty_array(self, git_repo_no_nested: Path): + """JSON output has empty NESTED_REPOS when no nested repos exist.""" + result = run_script( + git_repo_no_nested, + "--json", + "--short-name", "solo-feat", + "Solo feature", + ) + assert result.returncode == 0, result.stderr + data = json.loads(result.stdout.strip()) + assert data["NESTED_REPOS"] == [] + + def test_dry_run_does_not_create_branches(self, git_repo_with_nested: Path): + """--dry-run reports nested repos but does not create branches.""" + result = run_script( + git_repo_with_nested, + "--json", + "--dry-run", + "--short-name", "dry-feat", + "Dry run feature", + ) + assert result.returncode == 0, result.stderr + data = json.loads(result.stdout.strip()) + assert data.get("DRY_RUN") is True + + nested = data.get("NESTED_REPOS", []) + assert len(nested) == 2 + for entry in nested: + assert entry["status"] == "dry_run" + + # Verify branches were NOT created in nested repos + for subdir in ["components/core", "components/api"]: + branch_result = subprocess.run( + ["git", "rev-parse", "--abbrev-ref", "HEAD"], + cwd=git_repo_with_nested / subdir, + capture_output=True, + text=True, + ) + # Should still be on the default branch (main or master) + assert branch_result.stdout.strip() != "001-dry-feat" + + def test_allow_existing_branch_in_nested(self, git_repo_with_nested: Path): + """--allow-existing-branch works for nested repos where branch already exists.""" + # First, create the branch in one nested repo manually + subprocess.run( + ["git", "checkout", "-b", "001-existing-feat"], + cwd=git_repo_with_nested / "components" / "core", + check=True, + capture_output=True, + ) + # Switch back so the create script can still create in root + subprocess.run( + ["git", "checkout", "-"], + cwd=git_repo_with_nested / "components" / "core", + check=True, + capture_output=True, + ) + + result = run_script( + git_repo_with_nested, + "--json", + "--allow-existing-branch", + "--number", "1", + "--short-name", "existing-feat", + "Existing feature", + ) + assert result.returncode == 0, result.stderr + data = json.loads(result.stdout.strip()) + + nested = {e["path"]: e["status"] for e in data["NESTED_REPOS"]} + # core had the branch pre-created, should report 'existing' + assert nested["components/core"] == "existing" + # api should be freshly created + assert nested["components/api"] == "created" + + def test_excluded_dirs_not_branched(self, git_repo_with_excluded_dirs: Path): + """Repos inside excluded directories like node_modules are not branched.""" + result = run_script( + git_repo_with_excluded_dirs, + "--json", + "--short-name", "excl-feat", + "Exclusion test", + ) + assert result.returncode == 0, result.stderr + data = json.loads(result.stdout.strip()) + + nested = data.get("NESTED_REPOS", []) + paths = [e["path"] for e in nested] + assert "lib" in paths + assert not any("node_modules" in p for p in paths) From 54cc59e8b63b88c430cdbe2ff5f422e6f1d56ea3 Mon Sep 17 00:00:00 2001 From: Sakit Atakishiyev Date: Wed, 8 Apr 2026 00:49:01 -0700 Subject: [PATCH 2/8] 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> --- scripts/bash/common.sh | 59 ++++---- scripts/bash/create-new-feature.sh | 59 +++++++- scripts/powershell/common.ps1 | 56 +++---- scripts/powershell/create-new-feature.ps1 | 20 ++- tests/test_nested_repos.py | 173 ++++++++++++++++++++++ 5 files changed, 298 insertions(+), 69 deletions(-) diff --git a/scripts/bash/common.sh b/scripts/bash/common.sh index a52edb692d..133d687734 100644 --- a/scripts/bash/common.sh +++ b/scripts/bash/common.sh @@ -279,12 +279,16 @@ check_file() { [[ -f "$1" ]] && echo " ✓ $2" || echo " ✗ $2"; } check_dir() { [[ -d "$1" && -n $(ls -A "$1" 2>/dev/null) ]] && echo " ✓ $2" || echo " ✗ $2"; } # Discover nested independent git repositories under REPO_ROOT. -# Searches up to 2 directory levels deep for subdirectories containing .git -# (directory or file, covering worktrees/submodules). Excludes the root repo -# itself and common non-project directories. +# Searches up to $max_depth directory levels deep for subdirectories containing +# .git (directory or file, covering worktrees/submodules). Excludes the root +# repo itself and common non-project directories. +# Usage: find_nested_git_repos [repo_root] [max_depth] +# repo_root — defaults to $(get_repo_root) +# max_depth — defaults to 2 # Outputs one absolute path per line. find_nested_git_repos() { local repo_root="${1:-$(get_repo_root)}" + local max_depth="${2:-2}" # Directories to skip during traversal local -a skip_dirs=(".specify" ".git" "node_modules" "vendor" ".venv" "venv" "__pycache__" ".gradle" "build" "dist" "target" ".idea" @@ -298,36 +302,27 @@ find_nested_git_repos() { return 1 } - # Level 1 - for child in "$repo_root"/*/; do - [ -d "$child" ] || continue - child="${child%/}" - local child_name - child_name="$(basename "$child")" - _should_skip "$child_name" && continue - - if [ -e "$child/.git" ]; then - # Verify it is a valid git work tree - if git -C "$child" rev-parse --is-inside-work-tree >/dev/null 2>&1; then - echo "$child" - fi - else - # Level 2 - for grandchild in "$child"/*/; do - [ -d "$grandchild" ] || continue - grandchild="${grandchild%/}" - local gc_name - gc_name="$(basename "$grandchild")" - _should_skip "$gc_name" && continue - - if [ -e "$grandchild/.git" ]; then - if git -C "$grandchild" rev-parse --is-inside-work-tree >/dev/null 2>&1; then - echo "$grandchild" - fi + _scan_dir() { + local dir="$1" + local current_depth="$2" + for child in "$dir"/*/; do + [ -d "$child" ] || continue + child="${child%/}" + local child_name + child_name="$(basename "$child")" + _should_skip "$child_name" && continue + + if [ -e "$child/.git" ]; then + if git -C "$child" rev-parse --is-inside-work-tree >/dev/null 2>&1; then + echo "$child" fi - done - fi - done + elif [ "$current_depth" -lt "$max_depth" ]; then + _scan_dir "$child" $((current_depth + 1)) + fi + done + } + + _scan_dir "$repo_root" 1 } # Resolve a template name to a file path using the priority stack: diff --git a/scripts/bash/create-new-feature.sh b/scripts/bash/create-new-feature.sh index 5e4cf9a19c..c8c5ca98a6 100644 --- a/scripts/bash/create-new-feature.sh +++ b/scripts/bash/create-new-feature.sh @@ -8,6 +8,8 @@ ALLOW_EXISTING=false SHORT_NAME="" BRANCH_NUMBER="" USE_TIMESTAMP=false +NESTED_REPOS_FILTER="" +SCAN_DEPTH="" ARGS=() i=1 while [ $i -le $# ]; do @@ -52,8 +54,34 @@ while [ $i -le $# ]; do --timestamp) USE_TIMESTAMP=true ;; + --repos) + if [ $((i + 1)) -gt $# ]; then + echo 'Error: --repos requires a value (comma-separated list of repo paths)' >&2 + exit 1 + fi + i=$((i + 1)) + next_arg="${!i}" + if [[ "$next_arg" == --* ]]; then + echo 'Error: --repos requires a value (comma-separated list of repo paths)' >&2 + exit 1 + fi + NESTED_REPOS_FILTER="$next_arg" + ;; + --scan-depth) + if [ $((i + 1)) -gt $# ]; then + echo 'Error: --scan-depth requires a value' >&2 + exit 1 + fi + i=$((i + 1)) + next_arg="${!i}" + if [[ "$next_arg" == --* ]]; then + echo 'Error: --scan-depth requires a value' >&2 + exit 1 + fi + SCAN_DEPTH="$next_arg" + ;; --help|-h) - echo "Usage: $0 [--json] [--dry-run] [--allow-existing-branch] [--short-name ] [--number N] [--timestamp] " + echo "Usage: $0 [--json] [--dry-run] [--allow-existing-branch] [--short-name ] [--number N] [--timestamp] [--repos ] [--scan-depth N] " echo "" echo "Options:" echo " --json Output in JSON format" @@ -62,12 +90,15 @@ while [ $i -le $# ]; do echo " --short-name Provide a custom short name (2-4 words) for the branch" echo " --number N Specify branch number manually (overrides auto-detection)" echo " --timestamp Use timestamp prefix (YYYYMMDD-HHMMSS) instead of sequential numbering" + echo " --repos Comma-separated list of nested repo relative paths to branch (default: all discovered)" + echo " --scan-depth N Max directory depth to scan for nested repos (default: 2)" echo " --help, -h Show this help message" echo "" echo "Examples:" echo " $0 'Add user authentication system' --short-name 'user-auth'" echo " $0 'Implement OAuth2 integration for API' --number 5" echo " $0 --timestamp --short-name 'user-auth' 'Add user authentication'" + echo " $0 --repos 'components/api,components/auth' 'Add API auth support'" exit 0 ;; *) @@ -384,7 +415,31 @@ fi # Create matching feature branches in nested independent git repositories NESTED_REPOS_JSON="" if [ "$HAS_GIT" = true ]; then - nested_repos=$(find_nested_git_repos "$REPO_ROOT") + scan_depth="${SCAN_DEPTH:-2}" + nested_repos=$(find_nested_git_repos "$REPO_ROOT" "$scan_depth") + + # Filter by --repos if specified: only branch repos in the requested list + if [ -n "$NESTED_REPOS_FILTER" ] && [ -n "$nested_repos" ]; then + IFS=',' read -ra requested_repos <<< "$NESTED_REPOS_FILTER" + filtered="" + while IFS= read -r nested_path; do + [ -z "$nested_path" ] && continue + nested_path="${nested_path%/}" + rel_path="${nested_path#"$REPO_ROOT/"}" + rel_path="${rel_path%/}" + for req in "${requested_repos[@]}"; do + req="$(echo "$req" | xargs)" + req="${req%/}" + if [ "$rel_path" = "$req" ]; then + [ -n "$filtered" ] && filtered+=$'\n' + filtered+="$nested_path" + break + fi + done + done <<< "$nested_repos" + nested_repos="$filtered" + fi + if [ -n "$nested_repos" ]; then NESTED_REPOS_JSON="[" first=true diff --git a/scripts/powershell/common.ps1 b/scripts/powershell/common.ps1 index c0d5e853c8..f5ce583f8c 100644 --- a/scripts/powershell/common.ps1 +++ b/scripts/powershell/common.ps1 @@ -234,48 +234,38 @@ function Test-DirHasFiles { # itself and common non-project directories. # Returns an array of absolute paths. function Find-NestedGitRepos { - param([string]$RepoRoot = (Get-RepoRoot)) + param( + [string]$RepoRoot = (Get-RepoRoot), + [int]$MaxDepth = 2 + ) $skipDirs = @('.specify', '.git', 'node_modules', 'vendor', '.venv', 'venv', '__pycache__', '.gradle', 'build', 'dist', 'target', '.idea', '.vscode', 'specs') - $results = @() - - # Level 1 - $children = Get-ChildItem -Path $RepoRoot -Directory -ErrorAction SilentlyContinue | - Where-Object { $skipDirs -notcontains $_.Name } - - foreach ($child in $children) { - $gitMarker = Join-Path $child.FullName '.git' - if (Test-Path -LiteralPath $gitMarker) { - # Verify it is a valid git work tree - try { - $null = git -C $child.FullName rev-parse --is-inside-work-tree 2>$null - if ($LASTEXITCODE -eq 0) { - $results += $child.FullName - } - } catch { } - } else { - # Level 2 - $grandchildren = Get-ChildItem -Path $child.FullName -Directory -ErrorAction SilentlyContinue | - Where-Object { $skipDirs -notcontains $_.Name } - - foreach ($gc in $grandchildren) { - $gcGitMarker = Join-Path $gc.FullName '.git' - if (Test-Path -LiteralPath $gcGitMarker) { - try { - $null = git -C $gc.FullName rev-parse --is-inside-work-tree 2>$null - if ($LASTEXITCODE -eq 0) { - $results += $gc.FullName - } - } catch { } - } + function ScanDir { + param([string]$Dir, [int]$CurrentDepth) + $found = @() + $children = Get-ChildItem -Path $Dir -Directory -ErrorAction SilentlyContinue | + Where-Object { $skipDirs -notcontains $_.Name } + + foreach ($child in $children) { + $gitMarker = Join-Path $child.FullName '.git' + if (Test-Path -LiteralPath $gitMarker) { + try { + $null = git -C $child.FullName rev-parse --is-inside-work-tree 2>$null + if ($LASTEXITCODE -eq 0) { + $found += $child.FullName + } + } catch { } + } elseif ($CurrentDepth -lt $MaxDepth) { + $found += ScanDir -Dir $child.FullName -CurrentDepth ($CurrentDepth + 1) } } + return $found } - return $results + return ScanDir -Dir $RepoRoot -CurrentDepth 1 } # Resolve a template name to a file path using the priority stack: diff --git a/scripts/powershell/create-new-feature.ps1 b/scripts/powershell/create-new-feature.ps1 index ac5e626a3d..1c45583c21 100644 --- a/scripts/powershell/create-new-feature.ps1 +++ b/scripts/powershell/create-new-feature.ps1 @@ -9,6 +9,8 @@ param( [Parameter()] [long]$Number = 0, [switch]$Timestamp, + [string]$Repos, + [int]$ScanDepth = 0, [switch]$Help, [Parameter(Position = 0, ValueFromRemainingArguments = $true)] [string[]]$FeatureDescription @@ -17,7 +19,7 @@ $ErrorActionPreference = 'Stop' # Show help if requested if ($Help) { - Write-Host "Usage: ./create-new-feature.ps1 [-Json] [-DryRun] [-AllowExistingBranch] [-ShortName ] [-Number N] [-Timestamp] " + Write-Host "Usage: ./create-new-feature.ps1 [-Json] [-DryRun] [-AllowExistingBranch] [-ShortName ] [-Number N] [-Timestamp] [-Repos ] [-ScanDepth N] " Write-Host "" Write-Host "Options:" Write-Host " -Json Output in JSON format" @@ -26,12 +28,15 @@ if ($Help) { Write-Host " -ShortName Provide a custom short name (2-4 words) for the branch" Write-Host " -Number N Specify branch number manually (overrides auto-detection)" Write-Host " -Timestamp Use timestamp prefix (YYYYMMDD-HHMMSS) instead of sequential numbering" + Write-Host " -Repos Comma-separated list of nested repo relative paths to branch (default: all discovered)" + Write-Host " -ScanDepth N Max directory depth to scan for nested repos (default: 2)" Write-Host " -Help Show this help message" Write-Host "" Write-Host "Examples:" Write-Host " ./create-new-feature.ps1 'Add user authentication system' -ShortName 'user-auth'" Write-Host " ./create-new-feature.ps1 'Implement OAuth2 integration for API'" Write-Host " ./create-new-feature.ps1 -Timestamp -ShortName 'user-auth' 'Add user authentication'" + Write-Host " ./create-new-feature.ps1 -Repos 'components/api,components/auth' 'Add API auth support'" exit 0 } @@ -363,7 +368,18 @@ if (-not $DryRun) { # Create matching feature branches in nested independent git repositories $nestedReposResult = @() if ($hasGit) { - $nestedRepos = Find-NestedGitRepos -RepoRoot $repoRoot + $effectiveDepth = if ($ScanDepth -gt 0) { $ScanDepth } else { 2 } + $nestedRepos = Find-NestedGitRepos -RepoRoot $repoRoot -MaxDepth $effectiveDepth + + # Filter by -Repos if specified: only branch repos in the requested list + if ($Repos) { + $requestedRepos = $Repos -split ',' | ForEach-Object { $_.Trim().TrimEnd('\', '/') } | Where-Object { $_ -ne '' } + $nestedRepos = $nestedRepos | Where-Object { + $relPath = $_.Substring($repoRoot.Length).TrimStart('\', '/') + $requestedRepos -contains $relPath + } + } + foreach ($nestedPath in $nestedRepos) { $relPath = $nestedPath.Substring($repoRoot.Length).TrimStart('\', '/') $nestedStatus = 'skipped' diff --git a/tests/test_nested_repos.py b/tests/test_nested_repos.py index 4779f5df79..e6b18e67dd 100644 --- a/tests/test_nested_repos.py +++ b/tests/test_nested_repos.py @@ -3,6 +3,8 @@ Tests cover: - Discovery of nested git repos via find_nested_git_repos (bash) / Find-NestedGitRepos (PS) +- Configurable scan depth for nested repo discovery +- Selective branching via --repos flag - Branch creation in nested repos during feature creation - JSON output includes NESTED_REPOS field - --dry-run reports nested repos without creating branches @@ -320,3 +322,174 @@ def test_excluded_dirs_not_branched(self, git_repo_with_excluded_dirs: Path): paths = [e["path"] for e in nested] assert "lib" in paths assert not any("node_modules" in p for p in paths) + + +# ── Configurable Depth Tests ───────────────────────────────────────────────── + + +class TestConfigurableDepth: + def test_depth_1_misses_level2_repos(self, git_repo_with_nested: Path): + """Depth 1 only scans immediate children; level-2 repos under components/ are missed.""" + result = source_and_call( + f'find_nested_git_repos "{git_repo_with_nested}" 1', + cwd=git_repo_with_nested, + ) + assert result.returncode == 0, result.stderr + paths = [p.strip().rstrip("/") for p in result.stdout.strip().splitlines() if p.strip()] + # components/core and components/api are at depth 2, so depth=1 should find nothing + assert len(paths) == 0 + + def test_depth_2_finds_level2_repos(self, git_repo_with_nested: Path): + """Depth 2 (default) discovers repos at level 2.""" + result = source_and_call( + f'find_nested_git_repos "{git_repo_with_nested}" 2', + cwd=git_repo_with_nested, + ) + assert result.returncode == 0, result.stderr + paths = [p.strip().rstrip("/") for p in result.stdout.strip().splitlines() if p.strip()] + assert len(paths) == 2 + basenames = sorted(os.path.basename(p) for p in paths) + assert basenames == ["api", "core"] + + def test_depth_3_finds_deep_repos(self, tmp_path: Path): + """Depth 3 discovers repos at level 3.""" + _init_git_repo(tmp_path) + (tmp_path / ".specify").mkdir() + scripts_dir = tmp_path / "scripts" / "bash" + scripts_dir.mkdir(parents=True) + shutil.copy(COMMON_SH, scripts_dir / "common.sh") + + # Level 3 nested repo: services/backend/auth + deep_dir = tmp_path / "services" / "backend" / "auth" + deep_dir.mkdir(parents=True) + _init_git_repo(deep_dir) + + # With depth=2, should NOT find it + result2 = source_and_call( + f'find_nested_git_repos "{tmp_path}" 2', + cwd=tmp_path, + ) + assert result2.returncode == 0 + paths2 = [p.strip().rstrip("/") for p in result2.stdout.strip().splitlines() if p.strip()] + assert len(paths2) == 0 + + # With depth=3, should find it + result3 = source_and_call( + f'find_nested_git_repos "{tmp_path}" 3', + cwd=tmp_path, + ) + assert result3.returncode == 0 + paths3 = [p.strip().rstrip("/") for p in result3.stdout.strip().splitlines() if p.strip()] + assert len(paths3) == 1 + assert os.path.basename(paths3[0]) == "auth" + + def test_scan_depth_flag(self, tmp_path: Path): + """--scan-depth flag controls discovery depth in create-new-feature.""" + _init_git_repo(tmp_path) + scripts_dir = tmp_path / "scripts" / "bash" + scripts_dir.mkdir(parents=True) + shutil.copy(CREATE_FEATURE, scripts_dir / "create-new-feature.sh") + shutil.copy(COMMON_SH, scripts_dir / "common.sh") + (tmp_path / ".specify" / "templates").mkdir(parents=True) + + # Level 3 repo + deep_dir = tmp_path / "services" / "backend" / "auth" + deep_dir.mkdir(parents=True) + _init_git_repo(deep_dir) + + # Default depth (2): should not find level-3 repo + result = run_script(tmp_path, "--json", "--short-name", "depth-test", "Depth test") + assert result.returncode == 0, result.stderr + data = json.loads(result.stdout.strip()) + assert data["NESTED_REPOS"] == [] + + # Depth 3: should find it (need a new branch name since first run took 001) + result3 = run_script( + tmp_path, "--json", "--scan-depth", "3", + "--short-name", "depth-test3", "Depth test 3", + ) + assert result3.returncode == 0, result3.stderr + data3 = json.loads(result3.stdout.strip()) + assert len(data3["NESTED_REPOS"]) == 1 + assert data3["NESTED_REPOS"][0]["path"] == "services/backend/auth" + assert data3["NESTED_REPOS"][0]["status"] == "created" + + +# ── Selective Branching Tests ──────────────────────────────────────────────── + + +class TestSelectiveBranching: + def test_repos_flag_filters_to_selected(self, git_repo_with_nested: Path): + """--repos flag causes only selected repos to be branched.""" + result = run_script( + git_repo_with_nested, + "--json", + "--repos", "components/core", + "--short-name", "sel-feat", + "Selective feature", + ) + assert result.returncode == 0, result.stderr + data = json.loads(result.stdout.strip()) + + nested = data["NESTED_REPOS"] + assert len(nested) == 1 + assert nested[0]["path"] == "components/core" + assert nested[0]["status"] == "created" + + # api should NOT have the branch + api_branch = subprocess.run( + ["git", "rev-parse", "--abbrev-ref", "HEAD"], + cwd=git_repo_with_nested / "components" / "api", + capture_output=True, text=True, + ) + assert api_branch.stdout.strip() != "001-sel-feat" + + def test_repos_flag_multiple_repos(self, git_repo_with_nested: Path): + """--repos with comma-separated list branches only those repos.""" + result = run_script( + git_repo_with_nested, + "--json", + "--repos", "components/core,components/api", + "--short-name", "multi-sel", + "Multi select", + ) + assert result.returncode == 0, result.stderr + data = json.loads(result.stdout.strip()) + + nested = sorted(data["NESTED_REPOS"], key=lambda x: x["path"]) + assert len(nested) == 2 + assert nested[0]["path"] == "components/api" + assert nested[0]["status"] == "created" + assert nested[1]["path"] == "components/core" + assert nested[1]["status"] == "created" + + def test_repos_flag_nonexistent_repo_excluded(self, git_repo_with_nested: Path): + """--repos with a path that doesn't match any discovered repo produces empty result.""" + result = run_script( + git_repo_with_nested, + "--json", + "--repos", "nonexistent/repo", + "--short-name", "no-match", + "No match feature", + ) + assert result.returncode == 0, result.stderr + data = json.loads(result.stdout.strip()) + assert data["NESTED_REPOS"] == [] + + def test_repos_flag_with_dry_run(self, git_repo_with_nested: Path): + """--repos combined with --dry-run filters and reports dry_run status.""" + result = run_script( + git_repo_with_nested, + "--json", + "--dry-run", + "--repos", "components/api", + "--short-name", "dry-sel", + "Dry selective", + ) + assert result.returncode == 0, result.stderr + data = json.loads(result.stdout.strip()) + + nested = data["NESTED_REPOS"] + assert len(nested) == 1 + assert nested[0]["path"] == "components/api" + assert nested[0]["status"] == "dry_run" From e3f1d309b0a42c9b7f977fee8268371b9f7eef6e Mon Sep 17 00:00:00 2001 From: Sakit Atakishiyev Date: Wed, 8 Apr 2026 01:14:15 -0700 Subject: [PATCH 3/8] feat: move nested repo branching to plan-aware, task-driven workflow Instead of automatically creating branches in all nested repos during /speckit.specify, this redesign defers branching to the plantasksimplement workflow: - Specify phase: creates root branch + spec only (no nested repo work) - Plan phase: setup-plan.sh discovers nested repos (NESTED_REPOS in JSON) and the AI agent identifies affected modules from the spec - Tasks phase: generates a Phase 1 setup task for creating feature branches in only the affected nested repos identified by the plan - Implement phase: executes the branch-creation task via git commands Changes: - Revert create-new-feature.sh/.ps1: remove --repos, --scan-depth, and NESTED_REPOS output (specify phase is clean again) - Add nested repo discovery to setup-plan.sh/.ps1 with --scan-depth flag - Update plan.md template: add step for AI to identify affected repos - Update plan-template.md: add Affected Nested Repositories section - Update tasks.md command: guidance for generating branch-creation task - Update tasks-template.md: Phase 1 example for nested repo branching - Update specify.md: remove nested repo guidance, point to plan+tasks - Rewrite tests: 13 tests covering discovery, depth config, setup-plan output, and verification that specify phase doesn't branch nested repos Resolves the concern that at specify time the spec doesn't exist yet, so we cannot know which repos are affected. The AI agent now makes this determination during planning based on spec analysis. Refs: #2120 Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- scripts/bash/create-new-feature.sh | 134 +------ scripts/bash/setup-plan.sh | 54 ++- scripts/powershell/create-new-feature.ps1 | 82 +---- scripts/powershell/setup-plan.ps1 | 26 +- templates/commands/plan.md | 14 +- templates/commands/specify.md | 1 + templates/commands/tasks.md | 1 + templates/plan-template.md | 15 + templates/tasks-template.md | 8 + tests/test_nested_repos.py | 412 +++++++--------------- 10 files changed, 249 insertions(+), 498 deletions(-) diff --git a/scripts/bash/create-new-feature.sh b/scripts/bash/create-new-feature.sh index c8c5ca98a6..1879647026 100644 --- a/scripts/bash/create-new-feature.sh +++ b/scripts/bash/create-new-feature.sh @@ -8,8 +8,6 @@ ALLOW_EXISTING=false SHORT_NAME="" BRANCH_NUMBER="" USE_TIMESTAMP=false -NESTED_REPOS_FILTER="" -SCAN_DEPTH="" ARGS=() i=1 while [ $i -le $# ]; do @@ -54,34 +52,8 @@ while [ $i -le $# ]; do --timestamp) USE_TIMESTAMP=true ;; - --repos) - if [ $((i + 1)) -gt $# ]; then - echo 'Error: --repos requires a value (comma-separated list of repo paths)' >&2 - exit 1 - fi - i=$((i + 1)) - next_arg="${!i}" - if [[ "$next_arg" == --* ]]; then - echo 'Error: --repos requires a value (comma-separated list of repo paths)' >&2 - exit 1 - fi - NESTED_REPOS_FILTER="$next_arg" - ;; - --scan-depth) - if [ $((i + 1)) -gt $# ]; then - echo 'Error: --scan-depth requires a value' >&2 - exit 1 - fi - i=$((i + 1)) - next_arg="${!i}" - if [[ "$next_arg" == --* ]]; then - echo 'Error: --scan-depth requires a value' >&2 - exit 1 - fi - SCAN_DEPTH="$next_arg" - ;; --help|-h) - echo "Usage: $0 [--json] [--dry-run] [--allow-existing-branch] [--short-name ] [--number N] [--timestamp] [--repos ] [--scan-depth N] " + echo "Usage: $0 [--json] [--dry-run] [--allow-existing-branch] [--short-name ] [--number N] [--timestamp] " echo "" echo "Options:" echo " --json Output in JSON format" @@ -90,15 +62,12 @@ while [ $i -le $# ]; do echo " --short-name Provide a custom short name (2-4 words) for the branch" echo " --number N Specify branch number manually (overrides auto-detection)" echo " --timestamp Use timestamp prefix (YYYYMMDD-HHMMSS) instead of sequential numbering" - echo " --repos Comma-separated list of nested repo relative paths to branch (default: all discovered)" - echo " --scan-depth N Max directory depth to scan for nested repos (default: 2)" echo " --help, -h Show this help message" echo "" echo "Examples:" echo " $0 'Add user authentication system' --short-name 'user-auth'" echo " $0 'Implement OAuth2 integration for API' --number 5" echo " $0 --timestamp --short-name 'user-auth' 'Add user authentication'" - echo " $0 --repos 'components/api,components/auth' 'Add API auth support'" exit 0 ;; *) @@ -412,125 +381,32 @@ if [ "$DRY_RUN" != true ]; then printf '# To persist: export SPECIFY_FEATURE=%q\n' "$BRANCH_NAME" >&2 fi -# Create matching feature branches in nested independent git repositories -NESTED_REPOS_JSON="" -if [ "$HAS_GIT" = true ]; then - scan_depth="${SCAN_DEPTH:-2}" - nested_repos=$(find_nested_git_repos "$REPO_ROOT" "$scan_depth") - - # Filter by --repos if specified: only branch repos in the requested list - if [ -n "$NESTED_REPOS_FILTER" ] && [ -n "$nested_repos" ]; then - IFS=',' read -ra requested_repos <<< "$NESTED_REPOS_FILTER" - filtered="" - while IFS= read -r nested_path; do - [ -z "$nested_path" ] && continue - nested_path="${nested_path%/}" - rel_path="${nested_path#"$REPO_ROOT/"}" - rel_path="${rel_path%/}" - for req in "${requested_repos[@]}"; do - req="$(echo "$req" | xargs)" - req="${req%/}" - if [ "$rel_path" = "$req" ]; then - [ -n "$filtered" ] && filtered+=$'\n' - filtered+="$nested_path" - break - fi - done - done <<< "$nested_repos" - nested_repos="$filtered" - fi - - if [ -n "$nested_repos" ]; then - NESTED_REPOS_JSON="[" - first=true - while IFS= read -r nested_path; do - [ -z "$nested_path" ] && continue - # Normalize: remove trailing slash - nested_path="${nested_path%/}" - # Compute relative path for output - rel_path="${nested_path#"$REPO_ROOT/"}" - rel_path="${rel_path%/}" - status="skipped" - - if [ "$DRY_RUN" = true ]; then - status="dry_run" - else - # Attempt to create the branch in the nested repo - if git -C "$nested_path" checkout -q -b "$BRANCH_NAME" 2>/dev/null; then - status="created" - else - # Check if the branch already exists - if git -C "$nested_path" branch --list "$BRANCH_NAME" 2>/dev/null | grep -q .; then - if [ "$ALLOW_EXISTING" = true ]; then - current_nested="$(git -C "$nested_path" rev-parse --abbrev-ref HEAD 2>/dev/null || true)" - if [ "$current_nested" = "$BRANCH_NAME" ]; then - status="existing" - elif git -C "$nested_path" checkout -q "$BRANCH_NAME" 2>/dev/null; then - status="existing" - else - status="failed" - >&2 echo "[specify] Warning: Failed to switch nested repo '$rel_path' to branch '$BRANCH_NAME'" - fi - else - status="existing" - fi - else - status="failed" - >&2 echo "[specify] Warning: Failed to create branch '$BRANCH_NAME' in nested repo '$rel_path'" - fi - fi - fi - - if [ "$first" = true ]; then - first=false - else - NESTED_REPOS_JSON+="," - fi - NESTED_REPOS_JSON+="{\"path\":\"$(json_escape "$rel_path")\",\"status\":\"$status\"}" - done <<< "$nested_repos" - NESTED_REPOS_JSON+="]" - fi -fi - if $JSON_MODE; then - # Build the nested repos portion for JSON output - nested_json_field="" - if [ -n "$NESTED_REPOS_JSON" ]; then - nested_json_field="$NESTED_REPOS_JSON" - else - nested_json_field="[]" - fi - if command -v jq >/dev/null 2>&1; then if [ "$DRY_RUN" = true ]; then jq -cn \ --arg branch_name "$BRANCH_NAME" \ --arg spec_file "$SPEC_FILE" \ --arg feature_num "$FEATURE_NUM" \ - --argjson nested_repos "$nested_json_field" \ - '{BRANCH_NAME:$branch_name,SPEC_FILE:$spec_file,FEATURE_NUM:$feature_num,DRY_RUN:true,NESTED_REPOS:$nested_repos}' + '{BRANCH_NAME:$branch_name,SPEC_FILE:$spec_file,FEATURE_NUM:$feature_num,DRY_RUN:true}' else jq -cn \ --arg branch_name "$BRANCH_NAME" \ --arg spec_file "$SPEC_FILE" \ --arg feature_num "$FEATURE_NUM" \ - --argjson nested_repos "$nested_json_field" \ - '{BRANCH_NAME:$branch_name,SPEC_FILE:$spec_file,FEATURE_NUM:$feature_num,NESTED_REPOS:$nested_repos}' + '{BRANCH_NAME:$branch_name,SPEC_FILE:$spec_file,FEATURE_NUM:$feature_num}' fi else if [ "$DRY_RUN" = true ]; then - 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" + 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")" else - 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" + printf '{"BRANCH_NAME":"%s","SPEC_FILE":"%s","FEATURE_NUM":"%s"}\n' "$(json_escape "$BRANCH_NAME")" "$(json_escape "$SPEC_FILE")" "$(json_escape "$FEATURE_NUM")" fi fi else echo "BRANCH_NAME: $BRANCH_NAME" echo "SPEC_FILE: $SPEC_FILE" echo "FEATURE_NUM: $FEATURE_NUM" - if [ -n "$NESTED_REPOS_JSON" ] && [ "$NESTED_REPOS_JSON" != "[]" ]; then - echo "NESTED_REPOS: $NESTED_REPOS_JSON" - fi if [ "$DRY_RUN" != true ]; then printf '# To persist in your shell: export SPECIFY_FEATURE=%q\n' "$BRANCH_NAME" fi diff --git a/scripts/bash/setup-plan.sh b/scripts/bash/setup-plan.sh index 9f5523149e..110e8023e6 100644 --- a/scripts/bash/setup-plan.sh +++ b/scripts/bash/setup-plan.sh @@ -4,6 +4,7 @@ set -e # Parse command line arguments JSON_MODE=false +SCAN_DEPTH="" ARGS=() for arg in "$@"; do @@ -11,17 +12,28 @@ for arg in "$@"; do --json) JSON_MODE=true ;; + --scan-depth) + # Next argument is the depth value — handled below + SCAN_DEPTH="__NEXT__" + ;; --help|-h) - echo "Usage: $0 [--json]" - echo " --json Output results in JSON format" - echo " --help Show this help message" + echo "Usage: $0 [--json] [--scan-depth N]" + echo " --json Output results in JSON format" + echo " --scan-depth N Max directory depth for nested repo discovery (default: 2)" + echo " --help Show this help message" exit 0 ;; *) - ARGS+=("$arg") + if [ "$SCAN_DEPTH" = "__NEXT__" ]; then + SCAN_DEPTH="$arg" + else + ARGS+=("$arg") + fi ;; esac done +# Reset sentinel if --scan-depth was passed without a value +[ "$SCAN_DEPTH" = "__NEXT__" ] && SCAN_DEPTH="" # Get script directory and load common functions SCRIPT_DIR="$(CDPATH="" cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" @@ -49,6 +61,30 @@ else touch "$IMPL_PLAN" fi +# Discover nested independent git repositories (for AI agent to analyze) +NESTED_REPOS_JSON="[]" +if [ "$HAS_GIT" = true ]; then + scan_depth="${SCAN_DEPTH:-2}" + nested_repos=$(find_nested_git_repos "$REPO_ROOT" "$scan_depth") + if [ -n "$nested_repos" ]; then + NESTED_REPOS_JSON="[" + first=true + while IFS= read -r nested_path; do + [ -z "$nested_path" ] && continue + nested_path="${nested_path%/}" + rel_path="${nested_path#"$REPO_ROOT/"}" + rel_path="${rel_path%/}" + if [ "$first" = true ]; then + first=false + else + NESTED_REPOS_JSON+="," + fi + NESTED_REPOS_JSON+="{\"path\":\"$(json_escape "$rel_path")\"}" + done <<< "$nested_repos" + NESTED_REPOS_JSON+="]" + fi +fi + # Output results if $JSON_MODE; then if has_jq; then @@ -58,10 +94,11 @@ if $JSON_MODE; then --arg specs_dir "$FEATURE_DIR" \ --arg branch "$CURRENT_BRANCH" \ --arg has_git "$HAS_GIT" \ - '{FEATURE_SPEC:$feature_spec,IMPL_PLAN:$impl_plan,SPECS_DIR:$specs_dir,BRANCH:$branch,HAS_GIT:$has_git}' + --argjson nested_repos "$NESTED_REPOS_JSON" \ + '{FEATURE_SPEC:$feature_spec,IMPL_PLAN:$impl_plan,SPECS_DIR:$specs_dir,BRANCH:$branch,HAS_GIT:$has_git,NESTED_REPOS:$nested_repos}' else - printf '{"FEATURE_SPEC":"%s","IMPL_PLAN":"%s","SPECS_DIR":"%s","BRANCH":"%s","HAS_GIT":"%s"}\n' \ - "$(json_escape "$FEATURE_SPEC")" "$(json_escape "$IMPL_PLAN")" "$(json_escape "$FEATURE_DIR")" "$(json_escape "$CURRENT_BRANCH")" "$(json_escape "$HAS_GIT")" + printf '{"FEATURE_SPEC":"%s","IMPL_PLAN":"%s","SPECS_DIR":"%s","BRANCH":"%s","HAS_GIT":"%s","NESTED_REPOS":%s}\n' \ + "$(json_escape "$FEATURE_SPEC")" "$(json_escape "$IMPL_PLAN")" "$(json_escape "$FEATURE_DIR")" "$(json_escape "$CURRENT_BRANCH")" "$(json_escape "$HAS_GIT")" "$NESTED_REPOS_JSON" fi else echo "FEATURE_SPEC: $FEATURE_SPEC" @@ -69,5 +106,8 @@ else echo "SPECS_DIR: $FEATURE_DIR" echo "BRANCH: $CURRENT_BRANCH" echo "HAS_GIT: $HAS_GIT" + if [ "$NESTED_REPOS_JSON" != "[]" ]; then + echo "NESTED_REPOS: $NESTED_REPOS_JSON" + fi fi diff --git a/scripts/powershell/create-new-feature.ps1 b/scripts/powershell/create-new-feature.ps1 index 1c45583c21..2f23283fc4 100644 --- a/scripts/powershell/create-new-feature.ps1 +++ b/scripts/powershell/create-new-feature.ps1 @@ -9,8 +9,6 @@ param( [Parameter()] [long]$Number = 0, [switch]$Timestamp, - [string]$Repos, - [int]$ScanDepth = 0, [switch]$Help, [Parameter(Position = 0, ValueFromRemainingArguments = $true)] [string[]]$FeatureDescription @@ -19,7 +17,7 @@ $ErrorActionPreference = 'Stop' # Show help if requested if ($Help) { - Write-Host "Usage: ./create-new-feature.ps1 [-Json] [-DryRun] [-AllowExistingBranch] [-ShortName ] [-Number N] [-Timestamp] [-Repos ] [-ScanDepth N] " + Write-Host "Usage: ./create-new-feature.ps1 [-Json] [-DryRun] [-AllowExistingBranch] [-ShortName ] [-Number N] [-Timestamp] " Write-Host "" Write-Host "Options:" Write-Host " -Json Output in JSON format" @@ -28,15 +26,12 @@ if ($Help) { Write-Host " -ShortName Provide a custom short name (2-4 words) for the branch" Write-Host " -Number N Specify branch number manually (overrides auto-detection)" Write-Host " -Timestamp Use timestamp prefix (YYYYMMDD-HHMMSS) instead of sequential numbering" - Write-Host " -Repos Comma-separated list of nested repo relative paths to branch (default: all discovered)" - Write-Host " -ScanDepth N Max directory depth to scan for nested repos (default: 2)" Write-Host " -Help Show this help message" Write-Host "" Write-Host "Examples:" Write-Host " ./create-new-feature.ps1 'Add user authentication system' -ShortName 'user-auth'" Write-Host " ./create-new-feature.ps1 'Implement OAuth2 integration for API'" Write-Host " ./create-new-feature.ps1 -Timestamp -ShortName 'user-auth' 'Add user authentication'" - Write-Host " ./create-new-feature.ps1 -Repos 'components/api,components/auth' 'Add API auth support'" exit 0 } @@ -365,81 +360,12 @@ if (-not $DryRun) { $env:SPECIFY_FEATURE = $branchName } -# Create matching feature branches in nested independent git repositories -$nestedReposResult = @() -if ($hasGit) { - $effectiveDepth = if ($ScanDepth -gt 0) { $ScanDepth } else { 2 } - $nestedRepos = Find-NestedGitRepos -RepoRoot $repoRoot -MaxDepth $effectiveDepth - - # Filter by -Repos if specified: only branch repos in the requested list - if ($Repos) { - $requestedRepos = $Repos -split ',' | ForEach-Object { $_.Trim().TrimEnd('\', '/') } | Where-Object { $_ -ne '' } - $nestedRepos = $nestedRepos | Where-Object { - $relPath = $_.Substring($repoRoot.Length).TrimStart('\', '/') - $requestedRepos -contains $relPath - } - } - - foreach ($nestedPath in $nestedRepos) { - $relPath = $nestedPath.Substring($repoRoot.Length).TrimStart('\', '/') - $nestedStatus = 'skipped' - - if ($DryRun) { - $nestedStatus = 'dry_run' - } else { - try { - git -C $nestedPath checkout -q -b $branchName 2>$null | Out-Null - if ($LASTEXITCODE -eq 0) { - $nestedStatus = 'created' - } else { - throw "branch creation failed" - } - } catch { - # Check if branch already exists - $existingNested = git -C $nestedPath branch --list $branchName 2>$null - if ($existingNested) { - if ($AllowExistingBranch) { - $currentNested = git -C $nestedPath rev-parse --abbrev-ref HEAD 2>$null - if ($currentNested -eq $branchName) { - $nestedStatus = 'existing' - } else { - try { - git -C $nestedPath checkout -q $branchName 2>$null | Out-Null - if ($LASTEXITCODE -eq 0) { - $nestedStatus = 'existing' - } else { - $nestedStatus = 'failed' - Write-Warning "[specify] Failed to switch nested repo '$relPath' to branch '$branchName'" - } - } catch { - $nestedStatus = 'failed' - Write-Warning "[specify] Failed to switch nested repo '$relPath' to branch '$branchName'" - } - } - } else { - $nestedStatus = 'existing' - } - } else { - $nestedStatus = 'failed' - Write-Warning "[specify] Failed to create branch '$branchName' in nested repo '$relPath'" - } - } - } - - $nestedReposResult += [PSCustomObject]@{ - path = $relPath - status = $nestedStatus - } - } -} - if ($Json) { $obj = [PSCustomObject]@{ BRANCH_NAME = $branchName SPEC_FILE = $specFile FEATURE_NUM = $featureNum HAS_GIT = $hasGit - NESTED_REPOS = $nestedReposResult } if ($DryRun) { $obj | Add-Member -NotePropertyName 'DRY_RUN' -NotePropertyValue $true @@ -450,12 +376,6 @@ if ($Json) { Write-Output "SPEC_FILE: $specFile" Write-Output "FEATURE_NUM: $featureNum" Write-Output "HAS_GIT: $hasGit" - if ($nestedReposResult.Count -gt 0) { - Write-Output "NESTED_REPOS:" - foreach ($nr in $nestedReposResult) { - Write-Output " $($nr.path): $($nr.status)" - } - } if (-not $DryRun) { Write-Output "SPECIFY_FEATURE environment variable set to: $branchName" } diff --git a/scripts/powershell/setup-plan.ps1 b/scripts/powershell/setup-plan.ps1 index ee09094bf7..5bb1b01006 100644 --- a/scripts/powershell/setup-plan.ps1 +++ b/scripts/powershell/setup-plan.ps1 @@ -4,6 +4,7 @@ [CmdletBinding()] param( [switch]$Json, + [int]$ScanDepth = 0, [switch]$Help ) @@ -11,9 +12,10 @@ $ErrorActionPreference = 'Stop' # Show help if requested if ($Help) { - Write-Output "Usage: ./setup-plan.ps1 [-Json] [-Help]" - Write-Output " -Json Output results in JSON format" - Write-Output " -Help Show this help message" + Write-Output "Usage: ./setup-plan.ps1 [-Json] [-ScanDepth N] [-Help]" + Write-Output " -Json Output results in JSON format" + Write-Output " -ScanDepth N Max directory depth for nested repo discovery (default: 2)" + Write-Output " -Help Show this help message" exit 0 } @@ -42,6 +44,17 @@ if ($template -and (Test-Path $template)) { New-Item -ItemType File -Path $paths.IMPL_PLAN -Force | Out-Null } +# Discover nested independent git repositories (for AI agent to analyze) +$nestedReposResult = @() +if ($paths.HAS_GIT -eq 'true' -or $paths.HAS_GIT -eq $true) { + $effectiveDepth = if ($ScanDepth -gt 0) { $ScanDepth } else { 2 } + $nestedRepos = Find-NestedGitRepos -RepoRoot $paths.REPO_ROOT -MaxDepth $effectiveDepth + foreach ($nestedPath in $nestedRepos) { + $relPath = $nestedPath.Substring($paths.REPO_ROOT.Length).TrimStart('\', '/') + $nestedReposResult += [PSCustomObject]@{ path = $relPath } + } +} + # Output results if ($Json) { $result = [PSCustomObject]@{ @@ -50,6 +63,7 @@ if ($Json) { SPECS_DIR = $paths.FEATURE_DIR BRANCH = $paths.CURRENT_BRANCH HAS_GIT = $paths.HAS_GIT + NESTED_REPOS = $nestedReposResult } $result | ConvertTo-Json -Compress } else { @@ -58,4 +72,10 @@ if ($Json) { Write-Output "SPECS_DIR: $($paths.FEATURE_DIR)" Write-Output "BRANCH: $($paths.CURRENT_BRANCH)" Write-Output "HAS_GIT: $($paths.HAS_GIT)" + if ($nestedReposResult.Count -gt 0) { + Write-Output "NESTED_REPOS:" + foreach ($nr in $nestedReposResult) { + Write-Output " $($nr.path)" + } + } } diff --git a/templates/commands/plan.md b/templates/commands/plan.md index 4f1e9ed295..0b32783e03 100644 --- a/templates/commands/plan.md +++ b/templates/commands/plan.md @@ -60,11 +60,19 @@ You **MUST** consider the user input before proceeding (if not empty). ## Outline -1. **Setup**: Run `{SCRIPT}` from repo root and parse JSON for FEATURE_SPEC, IMPL_PLAN, SPECS_DIR, BRANCH. 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"). +1. **Setup**: Run `{SCRIPT}` from repo root and parse JSON for FEATURE_SPEC, IMPL_PLAN, SPECS_DIR, BRANCH, and NESTED_REPOS. 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"). + + **Nested repo scan depth**: Check `.specify/init-options.json` for `nested_repo_scan_depth`. If present, pass `--scan-depth N` (Bash) or `-ScanDepth N` (PowerShell) to the script. 2. **Load context**: Read FEATURE_SPEC and `/memory/constitution.md`. Load IMPL_PLAN template (already copied). -3. **Execute plan workflow**: Follow the structure in IMPL_PLAN template to: +3. **Identify affected nested repositories**: If NESTED_REPOS is non-empty: + - Read the feature spec (FEATURE_SPEC) + - For each nested repo in NESTED_REPOS, determine whether this feature requires changes in that repo based on the spec's requirements, user stories, and technical scope + - Document the affected repos in the plan's **Project Structure** section under a subsection called "Affected Nested Repositories", listing each repo path and a brief reason why it's affected + - This information will be used by `/speckit.tasks` to generate a setup task for creating feature branches in the affected repos + +4. **Execute plan workflow**: Follow the structure in IMPL_PLAN template to: - Fill Technical Context (mark unknowns as "NEEDS CLARIFICATION") - Fill Constitution Check section from constitution - Evaluate gates (ERROR if violations unjustified) @@ -73,7 +81,7 @@ You **MUST** consider the user input before proceeding (if not empty). - Phase 1: Update agent context by running the agent script - Re-evaluate Constitution Check post-design -4. **Stop and report**: Command ends after Phase 2 planning. Report branch, IMPL_PLAN path, and generated artifacts. +5. **Stop and report**: Command ends after Phase 2 planning. Report branch, IMPL_PLAN path, generated artifacts, and affected nested repos (if any). 5. **Check for extension hooks**: After reporting, check if `.specify/extensions.yml` exists in the project root. - If it exists, read it and look for entries under the `hooks.after_plan` key diff --git a/templates/commands/specify.md b/templates/commands/specify.md index 15c75ec396..241efec8d7 100644 --- a/templates/commands/specify.md +++ b/templates/commands/specify.md @@ -106,6 +106,7 @@ Given that feature description, do this: - You must only create one feature per `/speckit.specify` invocation - The spec directory name and the git branch name are independent — they may be the same but that is the user's choice - The spec directory and file are always created by this command, never by the hook + - **Note**: Nested git repository branching is handled during the `/speckit.plan` and `/speckit.tasks` phases, not here. The plan identifies affected repos and tasks generates a setup task for branch creation. 4. Load `templates/spec-template.md` to understand required sections. diff --git a/templates/commands/tasks.md b/templates/commands/tasks.md index 4e204abc1b..5ee0a5f783 100644 --- a/templates/commands/tasks.md +++ b/templates/commands/tasks.md @@ -68,6 +68,7 @@ You **MUST** consider the user input before proceeding (if not empty). 3. **Execute task generation workflow**: - Load plan.md and extract tech stack, libraries, project structure - Load spec.md and extract user stories with their priorities (P1, P2, P3, etc.) + - If plan.md contains an "Affected Nested Repositories" section: extract the repo paths and reasons. Generate a setup task (in Phase 1) to create the feature branch in each affected nested repo using `git -C checkout -b `. The branch name comes from the current feature branch. - If data-model.md exists: Extract entities and map to user stories - If contracts/ exists: Map interface contracts to user stories - If research.md exists: Extract decisions for setup tasks diff --git a/templates/plan-template.md b/templates/plan-template.md index 5a2fafebe3..2feafc9beb 100644 --- a/templates/plan-template.md +++ b/templates/plan-template.md @@ -94,6 +94,21 @@ ios/ or android/ **Structure Decision**: [Document the selected structure and reference the real directories captured above] +### Affected Nested Repositories + + +| Repo Path | Reason | +|-----------|--------| +| [e.g., components/auth] | [e.g., New OAuth2 provider needs auth module changes] | +| [e.g., components/api] | [e.g., New REST endpoints for OAuth2 flow] | + ## Complexity Tracking > **Fill ONLY if Constitution Check has violations that must be justified** diff --git a/templates/tasks-template.md b/templates/tasks-template.md index 60f9be455d..a2823b8707 100644 --- a/templates/tasks-template.md +++ b/templates/tasks-template.md @@ -52,6 +52,14 @@ description: "Task list template for feature implementation" - [ ] T002 Initialize [language] project with [framework] dependencies - [ ] T003 [P] Configure linting and formatting tools + + --- ## Phase 2: Foundational (Blocking Prerequisites) diff --git a/tests/test_nested_repos.py b/tests/test_nested_repos.py index e6b18e67dd..f7dad8208f 100644 --- a/tests/test_nested_repos.py +++ b/tests/test_nested_repos.py @@ -1,16 +1,12 @@ """ -Pytest tests for nested independent git repository support in create-new-feature scripts. +Pytest tests for nested independent git repository support. Tests cover: -- Discovery of nested git repos via find_nested_git_repos (bash) / Find-NestedGitRepos (PS) -- Configurable scan depth for nested repo discovery -- Selective branching via --repos flag -- Branch creation in nested repos during feature creation -- JSON output includes NESTED_REPOS field -- --dry-run reports nested repos without creating branches -- --allow-existing-branch propagates to nested repos +- Discovery of nested git repos via find_nested_git_repos (bash) +- Configurable scan depth for discovery - Excluded directories are skipped -- Graceful handling when nested repos cannot be branched +- setup-plan.sh reports discovered nested repos in JSON output +- create-new-feature.sh does NOT create branches in nested repos """ import json @@ -24,6 +20,7 @@ PROJECT_ROOT = Path(__file__).resolve().parent.parent CREATE_FEATURE = PROJECT_ROOT / "scripts" / "bash" / "create-new-feature.sh" +SETUP_PLAN = PROJECT_ROOT / "scripts" / "bash" / "setup-plan.sh" COMMON_SH = PROJECT_ROOT / "scripts" / "bash" / "common.sh" # On Windows, prefer Git Bash over WSL bash @@ -50,23 +47,28 @@ def _init_git_repo(path: Path) -> None: ) +def _setup_scripts(root: Path) -> None: + """Copy scripts and create .specify structure in a test repo.""" + scripts_dir = root / "scripts" / "bash" + scripts_dir.mkdir(parents=True) + shutil.copy(CREATE_FEATURE, scripts_dir / "create-new-feature.sh") + shutil.copy(SETUP_PLAN, scripts_dir / "setup-plan.sh") + shutil.copy(COMMON_SH, scripts_dir / "common.sh") + (root / ".specify" / "templates").mkdir(parents=True) + + @pytest.fixture def git_repo_with_nested(tmp_path: Path) -> Path: """Create a root git repo with nested independent git repos.""" - # Root repo _init_git_repo(tmp_path) - scripts_dir = tmp_path / "scripts" / "bash" - scripts_dir.mkdir(parents=True) - shutil.copy(CREATE_FEATURE, scripts_dir / "create-new-feature.sh") - shutil.copy(COMMON_SH, scripts_dir / "common.sh") - (tmp_path / ".specify" / "templates").mkdir(parents=True) + _setup_scripts(tmp_path) - # Nested repo at level 1: components/core + # Nested repo at level 2: components/core core_dir = tmp_path / "components" / "core" core_dir.mkdir(parents=True) _init_git_repo(core_dir) - # Nested repo at level 1: components/api + # Nested repo at level 2: components/api api_dir = tmp_path / "components" / "api" api_dir.mkdir(parents=True) _init_git_repo(api_dir) @@ -78,11 +80,7 @@ def git_repo_with_nested(tmp_path: Path) -> Path: def git_repo_no_nested(tmp_path: Path) -> Path: """Create a root git repo with no nested git repos.""" _init_git_repo(tmp_path) - scripts_dir = tmp_path / "scripts" / "bash" - scripts_dir.mkdir(parents=True) - shutil.copy(CREATE_FEATURE, scripts_dir / "create-new-feature.sh") - shutil.copy(COMMON_SH, scripts_dir / "common.sh") - (tmp_path / ".specify" / "templates").mkdir(parents=True) + _setup_scripts(tmp_path) # Regular subdirectory without .git (tmp_path / "components" / "core").mkdir(parents=True) @@ -93,11 +91,7 @@ def git_repo_no_nested(tmp_path: Path) -> Path: def git_repo_with_excluded_dirs(tmp_path: Path) -> Path: """Create a root git repo where git repos exist inside excluded directories.""" _init_git_repo(tmp_path) - scripts_dir = tmp_path / "scripts" / "bash" - scripts_dir.mkdir(parents=True) - shutil.copy(CREATE_FEATURE, scripts_dir / "create-new-feature.sh") - shutil.copy(COMMON_SH, scripts_dir / "common.sh") - (tmp_path / ".specify" / "templates").mkdir(parents=True) + _setup_scripts(tmp_path) # Git repo inside node_modules (should be excluded) nm_dir = tmp_path / "node_modules" / "some-pkg" @@ -112,26 +106,32 @@ def git_repo_with_excluded_dirs(tmp_path: Path) -> Path: return tmp_path -def run_script(cwd: Path, *args: str) -> subprocess.CompletedProcess: +def run_create_feature(cwd: Path, *args: str) -> subprocess.CompletedProcess: """Run create-new-feature.sh with given args.""" cmd = [BASH, "scripts/bash/create-new-feature.sh", *args] - return subprocess.run( - cmd, - cwd=cwd, - capture_output=True, - text=True, - ) + return subprocess.run(cmd, cwd=cwd, capture_output=True, text=True) + + +def run_setup_plan(cwd: Path, *args: str) -> subprocess.CompletedProcess: + """Run setup-plan.sh with given args.""" + cmd = [BASH, "scripts/bash/setup-plan.sh", *args] + return subprocess.run(cmd, cwd=cwd, capture_output=True, text=True) + +def parse_json_from_output(stdout: str) -> dict: + """Extract JSON object from script output that may contain non-JSON lines (warnings).""" + for line in stdout.strip().splitlines(): + line = line.strip() + if line.startswith("{"): + return json.loads(line) + raise ValueError(f"No JSON found in output: {stdout!r}") -def source_and_call(func_call: str, cwd: Path | None = None, env: dict | None = None) -> subprocess.CompletedProcess: + +def source_and_call(func_call: str, cwd: Path | None = None) -> subprocess.CompletedProcess: """Source common.sh and call a function.""" cmd = f'source "{COMMON_SH}" && {func_call}' return subprocess.run( - [BASH, "-c", cmd], - cwd=cwd, - capture_output=True, - text=True, - env={**os.environ, **(env or {})}, + [BASH, "-c", cmd], cwd=cwd, capture_output=True, text=True, env=os.environ.copy() ) @@ -175,16 +175,14 @@ def test_discovers_level1_repos(self, tmp_path: Path): """find_nested_git_repos discovers repos directly under root (level 1).""" _init_git_repo(tmp_path) (tmp_path / ".specify").mkdir() + scripts_dir = tmp_path / "scripts" / "bash" + scripts_dir.mkdir(parents=True) + shutil.copy(COMMON_SH, scripts_dir / "common.sh") - # Level 1 nested repo nested = tmp_path / "mylib" nested.mkdir() _init_git_repo(nested) - scripts_dir = tmp_path / "scripts" / "bash" - scripts_dir.mkdir(parents=True) - shutil.copy(COMMON_SH, scripts_dir / "common.sh") - result = source_and_call( f'find_nested_git_repos "{tmp_path}"', cwd=tmp_path, @@ -195,148 +193,18 @@ def test_discovers_level1_repos(self, tmp_path: Path): assert os.path.basename(paths[0]) == "mylib" -# ── Branch Creation Tests ──────────────────────────────────────────────────── - - -class TestNestedRepoBranchCreation: - def test_creates_branch_in_nested_repos(self, git_repo_with_nested: Path): - """Feature branch is created in all nested repos.""" - result = run_script( - git_repo_with_nested, - "--json", - "--short-name", "my-feat", - "Add a feature", - ) - assert result.returncode == 0, result.stderr - data = json.loads(result.stdout.strip()) - - # Verify root branch - assert data["BRANCH_NAME"] == "001-my-feat" - - # Verify nested repos - assert "NESTED_REPOS" in data - nested = sorted(data["NESTED_REPOS"], key=lambda x: x["path"]) - assert len(nested) == 2 - assert nested[0]["path"] == "components/api" - assert nested[0]["status"] == "created" - assert nested[1]["path"] == "components/core" - assert nested[1]["status"] == "created" - - # Verify branches actually exist in nested repos - for subdir in ["components/core", "components/api"]: - branch_result = subprocess.run( - ["git", "rev-parse", "--abbrev-ref", "HEAD"], - cwd=git_repo_with_nested / subdir, - capture_output=True, - text=True, - ) - assert branch_result.stdout.strip() == "001-my-feat" - - def test_no_nested_repos_returns_empty_array(self, git_repo_no_nested: Path): - """JSON output has empty NESTED_REPOS when no nested repos exist.""" - result = run_script( - git_repo_no_nested, - "--json", - "--short-name", "solo-feat", - "Solo feature", - ) - assert result.returncode == 0, result.stderr - data = json.loads(result.stdout.strip()) - assert data["NESTED_REPOS"] == [] - - def test_dry_run_does_not_create_branches(self, git_repo_with_nested: Path): - """--dry-run reports nested repos but does not create branches.""" - result = run_script( - git_repo_with_nested, - "--json", - "--dry-run", - "--short-name", "dry-feat", - "Dry run feature", - ) - assert result.returncode == 0, result.stderr - data = json.loads(result.stdout.strip()) - assert data.get("DRY_RUN") is True - - nested = data.get("NESTED_REPOS", []) - assert len(nested) == 2 - for entry in nested: - assert entry["status"] == "dry_run" - - # Verify branches were NOT created in nested repos - for subdir in ["components/core", "components/api"]: - branch_result = subprocess.run( - ["git", "rev-parse", "--abbrev-ref", "HEAD"], - cwd=git_repo_with_nested / subdir, - capture_output=True, - text=True, - ) - # Should still be on the default branch (main or master) - assert branch_result.stdout.strip() != "001-dry-feat" - - def test_allow_existing_branch_in_nested(self, git_repo_with_nested: Path): - """--allow-existing-branch works for nested repos where branch already exists.""" - # First, create the branch in one nested repo manually - subprocess.run( - ["git", "checkout", "-b", "001-existing-feat"], - cwd=git_repo_with_nested / "components" / "core", - check=True, - capture_output=True, - ) - # Switch back so the create script can still create in root - subprocess.run( - ["git", "checkout", "-"], - cwd=git_repo_with_nested / "components" / "core", - check=True, - capture_output=True, - ) - - result = run_script( - git_repo_with_nested, - "--json", - "--allow-existing-branch", - "--number", "1", - "--short-name", "existing-feat", - "Existing feature", - ) - assert result.returncode == 0, result.stderr - data = json.loads(result.stdout.strip()) - - nested = {e["path"]: e["status"] for e in data["NESTED_REPOS"]} - # core had the branch pre-created, should report 'existing' - assert nested["components/core"] == "existing" - # api should be freshly created - assert nested["components/api"] == "created" - - def test_excluded_dirs_not_branched(self, git_repo_with_excluded_dirs: Path): - """Repos inside excluded directories like node_modules are not branched.""" - result = run_script( - git_repo_with_excluded_dirs, - "--json", - "--short-name", "excl-feat", - "Exclusion test", - ) - assert result.returncode == 0, result.stderr - data = json.loads(result.stdout.strip()) - - nested = data.get("NESTED_REPOS", []) - paths = [e["path"] for e in nested] - assert "lib" in paths - assert not any("node_modules" in p for p in paths) - - # ── Configurable Depth Tests ───────────────────────────────────────────────── class TestConfigurableDepth: def test_depth_1_misses_level2_repos(self, git_repo_with_nested: Path): - """Depth 1 only scans immediate children; level-2 repos under components/ are missed.""" + """Depth 1 only scans immediate children; level-2 repos are missed.""" result = source_and_call( f'find_nested_git_repos "{git_repo_with_nested}" 1', cwd=git_repo_with_nested, ) assert result.returncode == 0, result.stderr paths = [p.strip().rstrip("/") for p in result.stdout.strip().splitlines() if p.strip()] - # components/core and components/api are at depth 2, so depth=1 should find nothing assert len(paths) == 0 def test_depth_2_finds_level2_repos(self, git_repo_with_nested: Path): @@ -348,8 +216,6 @@ def test_depth_2_finds_level2_repos(self, git_repo_with_nested: Path): assert result.returncode == 0, result.stderr paths = [p.strip().rstrip("/") for p in result.stdout.strip().splitlines() if p.strip()] assert len(paths) == 2 - basenames = sorted(os.path.basename(p) for p in paths) - assert basenames == ["api", "core"] def test_depth_3_finds_deep_repos(self, tmp_path: Path): """Depth 3 discovers repos at level 3.""" @@ -359,137 +225,133 @@ def test_depth_3_finds_deep_repos(self, tmp_path: Path): scripts_dir.mkdir(parents=True) shutil.copy(COMMON_SH, scripts_dir / "common.sh") - # Level 3 nested repo: services/backend/auth deep_dir = tmp_path / "services" / "backend" / "auth" deep_dir.mkdir(parents=True) _init_git_repo(deep_dir) - # With depth=2, should NOT find it - result2 = source_and_call( - f'find_nested_git_repos "{tmp_path}" 2', - cwd=tmp_path, - ) + # Depth 2: should NOT find it + result2 = source_and_call(f'find_nested_git_repos "{tmp_path}" 2', cwd=tmp_path) assert result2.returncode == 0 - paths2 = [p.strip().rstrip("/") for p in result2.stdout.strip().splitlines() if p.strip()] - assert len(paths2) == 0 + assert result2.stdout.strip() == "" - # With depth=3, should find it - result3 = source_and_call( - f'find_nested_git_repos "{tmp_path}" 3', - cwd=tmp_path, - ) + # Depth 3: should find it + result3 = source_and_call(f'find_nested_git_repos "{tmp_path}" 3', cwd=tmp_path) assert result3.returncode == 0 paths3 = [p.strip().rstrip("/") for p in result3.stdout.strip().splitlines() if p.strip()] assert len(paths3) == 1 assert os.path.basename(paths3[0]) == "auth" - def test_scan_depth_flag(self, tmp_path: Path): - """--scan-depth flag controls discovery depth in create-new-feature.""" - _init_git_repo(tmp_path) - scripts_dir = tmp_path / "scripts" / "bash" - scripts_dir.mkdir(parents=True) - shutil.copy(CREATE_FEATURE, scripts_dir / "create-new-feature.sh") - shutil.copy(COMMON_SH, scripts_dir / "common.sh") - (tmp_path / ".specify" / "templates").mkdir(parents=True) - # Level 3 repo - deep_dir = tmp_path / "services" / "backend" / "auth" - deep_dir.mkdir(parents=True) - _init_git_repo(deep_dir) +# ── Create Feature Does NOT Branch Nested Repos ───────────────────────────── - # Default depth (2): should not find level-3 repo - result = run_script(tmp_path, "--json", "--short-name", "depth-test", "Depth test") - assert result.returncode == 0, result.stderr - data = json.loads(result.stdout.strip()) - assert data["NESTED_REPOS"] == [] - # Depth 3: should find it (need a new branch name since first run took 001) - result3 = run_script( - tmp_path, "--json", "--scan-depth", "3", - "--short-name", "depth-test3", "Depth test 3", +class TestCreateFeatureNoNestedBranching: + def test_no_nested_repos_in_json(self, git_repo_with_nested: Path): + """create-new-feature JSON output should NOT contain NESTED_REPOS.""" + result = run_create_feature( + git_repo_with_nested, + "--json", "--short-name", "my-feat", "Add a feature", ) - assert result3.returncode == 0, result3.stderr - data3 = json.loads(result3.stdout.strip()) - assert len(data3["NESTED_REPOS"]) == 1 - assert data3["NESTED_REPOS"][0]["path"] == "services/backend/auth" - assert data3["NESTED_REPOS"][0]["status"] == "created" - - -# ── Selective Branching Tests ──────────────────────────────────────────────── - + assert result.returncode == 0, result.stderr + data = json.loads(result.stdout.strip()) + assert "NESTED_REPOS" not in data + assert "BRANCH_NAME" in data -class TestSelectiveBranching: - def test_repos_flag_filters_to_selected(self, git_repo_with_nested: Path): - """--repos flag causes only selected repos to be branched.""" - result = run_script( + def test_nested_repos_not_branched(self, git_repo_with_nested: Path): + """create-new-feature should not create branches in nested repos.""" + result = run_create_feature( git_repo_with_nested, - "--json", - "--repos", "components/core", - "--short-name", "sel-feat", - "Selective feature", + "--json", "--short-name", "no-nest", "No nesting", ) assert result.returncode == 0, result.stderr data = json.loads(result.stdout.strip()) + branch_name = data["BRANCH_NAME"] + + # Nested repos should still be on their original branch + for subdir in ["components/core", "components/api"]: + br = subprocess.run( + ["git", "rev-parse", "--abbrev-ref", "HEAD"], + cwd=git_repo_with_nested / subdir, + capture_output=True, text=True, + ) + assert br.stdout.strip() != branch_name - nested = data["NESTED_REPOS"] - assert len(nested) == 1 - assert nested[0]["path"] == "components/core" - assert nested[0]["status"] == "created" - # api should NOT have the branch - api_branch = subprocess.run( - ["git", "rev-parse", "--abbrev-ref", "HEAD"], - cwd=git_repo_with_nested / "components" / "api", - capture_output=True, text=True, - ) - assert api_branch.stdout.strip() != "001-sel-feat" +# ── Setup Plan Discovery Tests ─────────────────────────────────────────────── - def test_repos_flag_multiple_repos(self, git_repo_with_nested: Path): - """--repos with comma-separated list branches only those repos.""" - result = run_script( - git_repo_with_nested, - "--json", - "--repos", "components/core,components/api", - "--short-name", "multi-sel", - "Multi select", + +class TestSetupPlanDiscovery: + def _create_feature_first(self, repo: Path) -> str: + """Helper: create a feature branch so setup-plan has a valid branch.""" + result = run_create_feature( + repo, "--json", "--short-name", "plan-test", "Plan test feature", ) assert result.returncode == 0, result.stderr - data = json.loads(result.stdout.strip()) + data = parse_json_from_output(result.stdout) + return data["BRANCH_NAME"] + def test_discovers_nested_repos_in_json(self, git_repo_with_nested: Path): + """setup-plan JSON output includes NESTED_REPOS with discovered repos.""" + self._create_feature_first(git_repo_with_nested) + result = run_setup_plan(git_repo_with_nested, "--json") + assert result.returncode == 0, result.stderr + data = parse_json_from_output(result.stdout) + + assert "NESTED_REPOS" in data nested = sorted(data["NESTED_REPOS"], key=lambda x: x["path"]) assert len(nested) == 2 assert nested[0]["path"] == "components/api" - assert nested[0]["status"] == "created" assert nested[1]["path"] == "components/core" - assert nested[1]["status"] == "created" - def test_repos_flag_nonexistent_repo_excluded(self, git_repo_with_nested: Path): - """--repos with a path that doesn't match any discovered repo produces empty result.""" - result = run_script( - git_repo_with_nested, - "--json", - "--repos", "nonexistent/repo", - "--short-name", "no-match", - "No match feature", - ) + def test_no_nested_repos_returns_empty_array(self, git_repo_no_nested: Path): + """setup-plan JSON has empty NESTED_REPOS when no nested repos exist.""" + self._create_feature_first(git_repo_no_nested) + result = run_setup_plan(git_repo_no_nested, "--json") assert result.returncode == 0, result.stderr - data = json.loads(result.stdout.strip()) + data = parse_json_from_output(result.stdout) assert data["NESTED_REPOS"] == [] - def test_repos_flag_with_dry_run(self, git_repo_with_nested: Path): - """--repos combined with --dry-run filters and reports dry_run status.""" - result = run_script( - git_repo_with_nested, - "--json", - "--dry-run", - "--repos", "components/api", - "--short-name", "dry-sel", - "Dry selective", + def test_scan_depth_flag(self, tmp_path: Path): + """--scan-depth controls discovery depth in setup-plan.""" + _init_git_repo(tmp_path) + _setup_scripts(tmp_path) + + # Level 3 repo + deep_dir = tmp_path / "services" / "backend" / "auth" + deep_dir.mkdir(parents=True) + _init_git_repo(deep_dir) + + # Create feature branch first + run_create_feature( + tmp_path, "--json", "--short-name", "depth-plan", "Depth plan test", ) + + # Default depth (2): should not find level-3 repo + result = run_setup_plan(tmp_path, "--json") assert result.returncode == 0, result.stderr - data = json.loads(result.stdout.strip()) + data = parse_json_from_output(result.stdout) + assert data["NESTED_REPOS"] == [] - nested = data["NESTED_REPOS"] - assert len(nested) == 1 - assert nested[0]["path"] == "components/api" - assert nested[0]["status"] == "dry_run" + # Depth 3: should find it + result3 = run_setup_plan(tmp_path, "--json", "--scan-depth", "3") + assert result3.returncode == 0, result3.stderr + data3 = parse_json_from_output(result3.stdout) + assert len(data3["NESTED_REPOS"]) == 1 + assert data3["NESTED_REPOS"][0]["path"] == "services/backend/auth" + + def test_discovery_does_not_create_branches(self, git_repo_with_nested: Path): + """setup-plan discovers repos but does NOT create branches in them.""" + self._create_feature_first(git_repo_with_nested) + result = run_setup_plan(git_repo_with_nested, "--json") + assert result.returncode == 0, result.stderr + data = parse_json_from_output(result.stdout) + branch_name = data["BRANCH"] + + # Nested repos should still be on their original branch + for subdir in ["components/core", "components/api"]: + br = subprocess.run( + ["git", "rev-parse", "--abbrev-ref", "HEAD"], + cwd=git_repo_with_nested / subdir, + capture_output=True, text=True, + ) + assert br.stdout.strip() != branch_name From 749e95b934fec40851758ba90ade3af1ad1bde23 Mon Sep 17 00:00:00 2001 From: Sakit Atakishiyev Date: Wed, 8 Apr 2026 01:33:44 -0700 Subject: [PATCH 4/8] fix: address PR review comments - Wrap find_nested_git_repos helpers in subshell to prevent bash global namespace pollution (_should_skip, _scan_dir) - Validate --scan-depth is a positive integer in setup-plan.sh - Remove non-existent nested_repo_scan_depth config reference from plan.md template - Fix duplicate step numbering (two items numbered 5) in plan.md - Quote repo paths in tasks.md git command template - Update Find-NestedGitRepos comment to reflect configurable depth Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- scripts/bash/common.sh | 57 +++++++++++++++++++---------------- scripts/bash/setup-plan.sh | 15 +++++++-- scripts/powershell/common.ps1 | 2 +- templates/commands/plan.md | 4 +-- templates/commands/specify.md | 1 - templates/commands/tasks.md | 2 +- 6 files changed, 47 insertions(+), 34 deletions(-) diff --git a/scripts/bash/common.sh b/scripts/bash/common.sh index 133d687734..3f05f557c0 100644 --- a/scripts/bash/common.sh +++ b/scripts/bash/common.sh @@ -294,35 +294,40 @@ find_nested_git_repos() { "__pycache__" ".gradle" "build" "dist" "target" ".idea" ".vscode" "specs") - _should_skip() { - local name="$1" - for skip in "${skip_dirs[@]}"; do - [ "$name" = "$skip" ] && return 0 - done - return 1 - } - - _scan_dir() { - local dir="$1" - local current_depth="$2" - for child in "$dir"/*/; do - [ -d "$child" ] || continue - child="${child%/}" - local child_name - child_name="$(basename "$child")" - _should_skip "$child_name" && continue + # Run in a subshell to avoid leaking helper functions into global scope + ( + _should_skip() { + local name="$1" + local skip + for skip in "${skip_dirs[@]}"; do + [ "$name" = "$skip" ] && return 0 + done + return 1 + } - if [ -e "$child/.git" ]; then - if git -C "$child" rev-parse --is-inside-work-tree >/dev/null 2>&1; then - echo "$child" + _scan_dir() { + local dir="$1" + local current_depth="$2" + local child + local child_name + for child in "$dir"/*/; do + [ -d "$child" ] || continue + child="${child%/}" + child_name="$(basename "$child")" + _should_skip "$child_name" && continue + + if [ -e "$child/.git" ]; then + if git -C "$child" rev-parse --is-inside-work-tree >/dev/null 2>&1; then + echo "$child" + fi + elif [ "$current_depth" -lt "$max_depth" ]; then + _scan_dir "$child" $((current_depth + 1)) fi - elif [ "$current_depth" -lt "$max_depth" ]; then - _scan_dir "$child" $((current_depth + 1)) - fi - done - } + done + } - _scan_dir "$repo_root" 1 + _scan_dir "$repo_root" 1 + ) } # Resolve a template name to a file path using the priority stack: diff --git a/scripts/bash/setup-plan.sh b/scripts/bash/setup-plan.sh index 110e8023e6..af4df61ae4 100644 --- a/scripts/bash/setup-plan.sh +++ b/scripts/bash/setup-plan.sh @@ -32,8 +32,19 @@ for arg in "$@"; do ;; esac done -# Reset sentinel if --scan-depth was passed without a value -[ "$SCAN_DEPTH" = "__NEXT__" ] && SCAN_DEPTH="" +# Validate --scan-depth argument +if [ "$SCAN_DEPTH" = "__NEXT__" ]; then + echo "ERROR: --scan-depth requires a positive integer value" >&2 + exit 1 +fi +if [ -n "$SCAN_DEPTH" ]; then + case "$SCAN_DEPTH" in + ''|*[!0-9]*|0) + echo "ERROR: --scan-depth must be a positive integer, got '$SCAN_DEPTH'" >&2 + exit 1 + ;; + esac +fi # Get script directory and load common functions SCRIPT_DIR="$(CDPATH="" cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" diff --git a/scripts/powershell/common.ps1 b/scripts/powershell/common.ps1 index f5ce583f8c..bbce77b0e9 100644 --- a/scripts/powershell/common.ps1 +++ b/scripts/powershell/common.ps1 @@ -232,7 +232,7 @@ function Test-DirHasFiles { # Searches up to 2 directory levels deep for subdirectories containing .git # (directory or file, covering worktrees/submodules). Excludes the root repo # itself and common non-project directories. -# Returns an array of absolute paths. +# Returns an array of absolute paths. Scan depth is configurable (default 2). function Find-NestedGitRepos { param( [string]$RepoRoot = (Get-RepoRoot), diff --git a/templates/commands/plan.md b/templates/commands/plan.md index 0b32783e03..41fa84fa51 100644 --- a/templates/commands/plan.md +++ b/templates/commands/plan.md @@ -62,8 +62,6 @@ You **MUST** consider the user input before proceeding (if not empty). 1. **Setup**: Run `{SCRIPT}` from repo root and parse JSON for FEATURE_SPEC, IMPL_PLAN, SPECS_DIR, BRANCH, and NESTED_REPOS. 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"). - **Nested repo scan depth**: Check `.specify/init-options.json` for `nested_repo_scan_depth`. If present, pass `--scan-depth N` (Bash) or `-ScanDepth N` (PowerShell) to the script. - 2. **Load context**: Read FEATURE_SPEC and `/memory/constitution.md`. Load IMPL_PLAN template (already copied). 3. **Identify affected nested repositories**: If NESTED_REPOS is non-empty: @@ -83,7 +81,7 @@ You **MUST** consider the user input before proceeding (if not empty). 5. **Stop and report**: Command ends after Phase 2 planning. Report branch, IMPL_PLAN path, generated artifacts, and affected nested repos (if any). -5. **Check for extension hooks**: After reporting, check if `.specify/extensions.yml` exists in the project root. +6. **Check for extension hooks**: After reporting, check if `.specify/extensions.yml` exists in the project root. - If it exists, read it and look for entries under the `hooks.after_plan` key - If the YAML cannot be parsed or is invalid, skip hook checking silently and continue normally - Filter out hooks where `enabled` is explicitly `false`. Treat hooks without an `enabled` field as enabled by default. diff --git a/templates/commands/specify.md b/templates/commands/specify.md index 241efec8d7..15c75ec396 100644 --- a/templates/commands/specify.md +++ b/templates/commands/specify.md @@ -106,7 +106,6 @@ Given that feature description, do this: - You must only create one feature per `/speckit.specify` invocation - The spec directory name and the git branch name are independent — they may be the same but that is the user's choice - The spec directory and file are always created by this command, never by the hook - - **Note**: Nested git repository branching is handled during the `/speckit.plan` and `/speckit.tasks` phases, not here. The plan identifies affected repos and tasks generates a setup task for branch creation. 4. Load `templates/spec-template.md` to understand required sections. diff --git a/templates/commands/tasks.md b/templates/commands/tasks.md index 5ee0a5f783..f5599b5605 100644 --- a/templates/commands/tasks.md +++ b/templates/commands/tasks.md @@ -68,7 +68,7 @@ You **MUST** consider the user input before proceeding (if not empty). 3. **Execute task generation workflow**: - Load plan.md and extract tech stack, libraries, project structure - Load spec.md and extract user stories with their priorities (P1, P2, P3, etc.) - - If plan.md contains an "Affected Nested Repositories" section: extract the repo paths and reasons. Generate a setup task (in Phase 1) to create the feature branch in each affected nested repo using `git -C checkout -b `. The branch name comes from the current feature branch. + - If plan.md contains an "Affected Nested Repositories" section: extract the repo paths and reasons. Generate a setup task (in Phase 1) to create the feature branch in each affected nested repo using `git -C "" checkout -b ""`. The branch name comes from the current feature branch. - If data-model.md exists: Extract entities and map to user stories - If contracts/ exists: Map interface contracts to user stories - If research.md exists: Extract decisions for setup tasks From 5b2d0b21090771bd438c6b56b1fd8686884b4b6d Mon Sep 17 00:00:00 2001 From: Sakit Atakishiyev Date: Wed, 8 Apr 2026 19:45:24 -0700 Subject: [PATCH 5/8] feat: hybrid nested repo discovery explicit config + .gitignore fallback Replace hardcoded skip list with two-tier discovery: 1. Explicit paths: If nested_repos is defined in init-options.json, validate and return those paths directly (no scanning). 2. .gitignore-based scan: If no explicit config, scan directories but use git check-ignore to skip gitignored dirs instead of a hardcoded skip list. Only .git is hardcoded. Dirs with their own .git are always treated as nested repos (even if gitignored in parent). - Bash: find_nested_git_repos() accepts optional explicit paths (3rd+ args) - PowerShell: Find-NestedGitRepos accepts -ExplicitPaths param - setup-plan.sh/.ps1: reads nested_repos from init-options.json - Python fallback for JSON parsing when jq is unavailable - Windows \r line-ending handling in Python subprocess output - 18 tests: discovery, gitignore filtering, explicit paths, depth, init-options Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- scripts/bash/common.sh | 47 +++++++++----- scripts/bash/setup-plan.sh | 29 ++++++++- scripts/powershell/common.ps1 | 31 +++++++-- scripts/powershell/setup-plan.ps1 | 19 +++++- tests/test_nested_repos.py | 102 +++++++++++++++++++++++++++--- 5 files changed, 194 insertions(+), 34 deletions(-) diff --git a/scripts/bash/common.sh b/scripts/bash/common.sh index 3f05f557c0..732900caa8 100644 --- a/scripts/bash/common.sh +++ b/scripts/bash/common.sh @@ -282,29 +282,36 @@ check_dir() { [[ -d "$1" && -n $(ls -A "$1" 2>/dev/null) ]] && echo " ✓ $2" | # Searches up to $max_depth directory levels deep for subdirectories containing # .git (directory or file, covering worktrees/submodules). Excludes the root # repo itself and common non-project directories. -# Usage: find_nested_git_repos [repo_root] [max_depth] -# repo_root — defaults to $(get_repo_root) -# max_depth — defaults to 2 +# Usage: find_nested_git_repos [repo_root] [max_depth] [explicit_paths...] +# repo_root — defaults to $(get_repo_root) +# max_depth — defaults to 2 +# explicit_paths — if provided (3rd arg onward), validate and return these directly (no scanning) # Outputs one absolute path per line. find_nested_git_repos() { local repo_root="${1:-$(get_repo_root)}" local max_depth="${2:-2}" - # Directories to skip during traversal - local -a skip_dirs=(".specify" ".git" "node_modules" "vendor" ".venv" "venv" - "__pycache__" ".gradle" "build" "dist" "target" ".idea" - ".vscode" "specs") + # Collect explicit paths from 3rd argument onward + local -a explicit_paths=() + if [ $# -ge 3 ]; then + shift 2 + explicit_paths=("$@") + fi + + # If explicit paths are provided, validate and return them directly + if [ ${#explicit_paths[@]} -gt 0 ]; then + for rel_path in "${explicit_paths[@]}"; do + local abs_path="$repo_root/$rel_path" + if [ -e "$abs_path/.git" ] && git -C "$abs_path" rev-parse --is-inside-work-tree >/dev/null 2>&1; then + echo "$abs_path" + fi + done + return + fi + + # Fallback: scan using .gitignore-based filtering # Run in a subshell to avoid leaking helper functions into global scope ( - _should_skip() { - local name="$1" - local skip - for skip in "${skip_dirs[@]}"; do - [ "$name" = "$skip" ] && return 0 - done - return 1 - } - _scan_dir() { local dir="$1" local current_depth="$2" @@ -314,13 +321,19 @@ find_nested_git_repos() { [ -d "$child" ] || continue child="${child%/}" child_name="$(basename "$child")" - _should_skip "$child_name" && continue + # Always skip .git directory + [ "$child_name" = ".git" ] && continue if [ -e "$child/.git" ]; then + # Directory has its own .git — it's a nested repo (even if gitignored in parent) if git -C "$child" rev-parse --is-inside-work-tree >/dev/null 2>&1; then echo "$child" fi elif [ "$current_depth" -lt "$max_depth" ]; then + # Skip gitignored directories (they won't contain nested repos) + if git -C "$repo_root" check-ignore -q "$child" 2>/dev/null; then + continue + fi _scan_dir "$child" $((current_depth + 1)) fi done diff --git a/scripts/bash/setup-plan.sh b/scripts/bash/setup-plan.sh index af4df61ae4..eb89e5aaf2 100644 --- a/scripts/bash/setup-plan.sh +++ b/scripts/bash/setup-plan.sh @@ -76,7 +76,34 @@ fi NESTED_REPOS_JSON="[]" if [ "$HAS_GIT" = true ]; then scan_depth="${SCAN_DEPTH:-2}" - nested_repos=$(find_nested_git_repos "$REPO_ROOT" "$scan_depth") + INIT_OPTIONS="$REPO_ROOT/.specify/init-options.json" + explicit_repos=() + + # Read explicit nested_repos from init-options.json if available + if [ -f "$INIT_OPTIONS" ]; then + if has_jq; then + while IFS= read -r rp; do + [ -n "$rp" ] && explicit_repos+=("$rp") + done < <(jq -r '.nested_repos // [] | .[]' "$INIT_OPTIONS" 2>/dev/null) + else + _py=$(command -v python3 2>/dev/null || command -v python 2>/dev/null || echo "") + if [ -n "$_py" ]; then + while IFS= read -r rp; do + rp="${rp%$'\r'}" + [ -n "$rp" ] && explicit_repos+=("$rp") + done < <("$_py" -c "import json,sys +try: + [print(p) for p in json.load(open(sys.argv[1])).get('nested_repos',[])] +except: pass" "$INIT_OPTIONS" 2>/dev/null) + fi + fi + fi + + if [ ${#explicit_repos[@]} -gt 0 ]; then + nested_repos=$(find_nested_git_repos "$REPO_ROOT" "$scan_depth" "${explicit_repos[@]}") + else + nested_repos=$(find_nested_git_repos "$REPO_ROOT" "$scan_depth") + fi if [ -n "$nested_repos" ]; then NESTED_REPOS_JSON="[" first=true diff --git a/scripts/powershell/common.ps1 b/scripts/powershell/common.ps1 index bbce77b0e9..7032dbb93d 100644 --- a/scripts/powershell/common.ps1 +++ b/scripts/powershell/common.ps1 @@ -233,25 +233,43 @@ function Test-DirHasFiles { # (directory or file, covering worktrees/submodules). Excludes the root repo # itself and common non-project directories. # Returns an array of absolute paths. Scan depth is configurable (default 2). +# If ExplicitPaths are provided, validates and returns those directly (no scanning). function Find-NestedGitRepos { param( [string]$RepoRoot = (Get-RepoRoot), - [int]$MaxDepth = 2 + [int]$MaxDepth = 2, + [string[]]$ExplicitPaths = @() ) - $skipDirs = @('.specify', '.git', 'node_modules', 'vendor', '.venv', 'venv', - '__pycache__', '.gradle', 'build', 'dist', 'target', '.idea', - '.vscode', 'specs') + # If explicit paths are provided, validate and return them directly + if ($ExplicitPaths.Count -gt 0) { + $found = @() + foreach ($relPath in $ExplicitPaths) { + $absPath = Join-Path $RepoRoot $relPath + $gitMarker = Join-Path $absPath '.git' + if (Test-Path -LiteralPath $gitMarker) { + try { + $null = git -C $absPath rev-parse --is-inside-work-tree 2>$null + if ($LASTEXITCODE -eq 0) { + $found += $absPath + } + } catch { } + } + } + return $found + } + # Fallback: scan using .gitignore-based filtering function ScanDir { param([string]$Dir, [int]$CurrentDepth) $found = @() $children = Get-ChildItem -Path $Dir -Directory -ErrorAction SilentlyContinue | - Where-Object { $skipDirs -notcontains $_.Name } + Where-Object { $_.Name -ne '.git' } foreach ($child in $children) { $gitMarker = Join-Path $child.FullName '.git' if (Test-Path -LiteralPath $gitMarker) { + # Directory has its own .git — it's a nested repo (even if gitignored in parent) try { $null = git -C $child.FullName rev-parse --is-inside-work-tree 2>$null if ($LASTEXITCODE -eq 0) { @@ -259,6 +277,9 @@ function Find-NestedGitRepos { } } catch { } } elseif ($CurrentDepth -lt $MaxDepth) { + # Skip gitignored directories (they won't contain nested repos) + $null = git -C $RepoRoot check-ignore -q $child.FullName 2>$null + if ($LASTEXITCODE -eq 0) { continue } $found += ScanDir -Dir $child.FullName -CurrentDepth ($CurrentDepth + 1) } } diff --git a/scripts/powershell/setup-plan.ps1 b/scripts/powershell/setup-plan.ps1 index 5bb1b01006..d213944998 100644 --- a/scripts/powershell/setup-plan.ps1 +++ b/scripts/powershell/setup-plan.ps1 @@ -48,7 +48,24 @@ if ($template -and (Test-Path $template)) { $nestedReposResult = @() if ($paths.HAS_GIT -eq 'true' -or $paths.HAS_GIT -eq $true) { $effectiveDepth = if ($ScanDepth -gt 0) { $ScanDepth } else { 2 } - $nestedRepos = Find-NestedGitRepos -RepoRoot $paths.REPO_ROOT -MaxDepth $effectiveDepth + $initOptions = Join-Path $paths.REPO_ROOT '.specify' 'init-options.json' + $explicitPaths = @() + + # Read explicit nested_repos from init-options.json if available + if (Test-Path -LiteralPath $initOptions) { + try { + $opts = Get-Content $initOptions -Raw | ConvertFrom-Json + if ($opts.nested_repos -and $opts.nested_repos.Count -gt 0) { + $explicitPaths = @($opts.nested_repos) + } + } catch { } + } + + if ($explicitPaths.Count -gt 0) { + $nestedRepos = Find-NestedGitRepos -RepoRoot $paths.REPO_ROOT -MaxDepth $effectiveDepth -ExplicitPaths $explicitPaths + } else { + $nestedRepos = Find-NestedGitRepos -RepoRoot $paths.REPO_ROOT -MaxDepth $effectiveDepth + } foreach ($nestedPath in $nestedRepos) { $relPath = $nestedPath.Substring($paths.REPO_ROOT.Length).TrimStart('\', '/') $nestedReposResult += [PSCustomObject]@{ path = $relPath } diff --git a/tests/test_nested_repos.py b/tests/test_nested_repos.py index f7dad8208f..cbfa8dbdd4 100644 --- a/tests/test_nested_repos.py +++ b/tests/test_nested_repos.py @@ -4,7 +4,8 @@ Tests cover: - Discovery of nested git repos via find_nested_git_repos (bash) - Configurable scan depth for discovery -- Excluded directories are skipped +- .gitignore-based directory filtering (replaces hardcoded skip list) +- Explicit paths from init-options.json - setup-plan.sh reports discovered nested repos in JSON output - create-new-feature.sh does NOT create branches in nested repos """ @@ -88,17 +89,21 @@ def git_repo_no_nested(tmp_path: Path) -> Path: @pytest.fixture -def git_repo_with_excluded_dirs(tmp_path: Path) -> Path: - """Create a root git repo where git repos exist inside excluded directories.""" +def git_repo_with_gitignored_dirs(tmp_path: Path) -> Path: + """Create a root git repo with gitignored dirs containing git repos.""" _init_git_repo(tmp_path) _setup_scripts(tmp_path) - # Git repo inside node_modules (should be excluded) + # Add .gitignore that ignores node_modules and build + (tmp_path / ".gitignore").write_text("node_modules/\nbuild/\n") + subprocess.run(["git", "add", ".gitignore"], cwd=tmp_path, check=True) + subprocess.run(["git", "commit", "-m", "add gitignore", "-q"], cwd=tmp_path, check=True) + + # Non-repo dir inside gitignored path (should be skipped during traversal) nm_dir = tmp_path / "node_modules" / "some-pkg" nm_dir.mkdir(parents=True) - _init_git_repo(nm_dir) - # Valid nested repo + # Valid nested repo (not gitignored) lib_dir = tmp_path / "lib" lib_dir.mkdir(parents=True) _init_git_repo(lib_dir) @@ -160,11 +165,11 @@ def test_no_nested_repos_returns_empty(self, git_repo_no_nested: Path): assert result.returncode == 0 assert result.stdout.strip() == "" - def test_excludes_node_modules(self, git_repo_with_excluded_dirs: Path): - """find_nested_git_repos skips repos inside excluded directories.""" + def test_skips_gitignored_directories(self, git_repo_with_gitignored_dirs: Path): + """find_nested_git_repos skips traversal into gitignored directories.""" result = source_and_call( - f'find_nested_git_repos "{git_repo_with_excluded_dirs}"', - cwd=git_repo_with_excluded_dirs, + f'find_nested_git_repos "{git_repo_with_gitignored_dirs}"', + cwd=git_repo_with_gitignored_dirs, ) assert result.returncode == 0 paths = [p.strip().rstrip("/") for p in result.stdout.strip().splitlines() if p.strip()] @@ -192,6 +197,67 @@ def test_discovers_level1_repos(self, tmp_path: Path): assert len(paths) == 1 assert os.path.basename(paths[0]) == "mylib" + def test_nested_repo_found_even_if_gitignored(self, tmp_path: Path): + """A directory with its own .git is reported even if it's gitignored in the parent.""" + _init_git_repo(tmp_path) + (tmp_path / ".specify").mkdir() + + # Gitignore the nested repo path + (tmp_path / ".gitignore").write_text("nested-lib/\n") + subprocess.run(["git", "add", ".gitignore"], cwd=tmp_path, check=True) + subprocess.run(["git", "commit", "-m", "ignore", "-q"], cwd=tmp_path, check=True) + + # Create nested repo at the gitignored path + nested = tmp_path / "nested-lib" + nested.mkdir() + _init_git_repo(nested) + + result = source_and_call( + f'find_nested_git_repos "{tmp_path}"', + cwd=tmp_path, + ) + assert result.returncode == 0 + paths = [p.strip().rstrip("/") for p in result.stdout.strip().splitlines() if p.strip()] + assert len(paths) == 1 + assert os.path.basename(paths[0]) == "nested-lib" + + +# ── Explicit Paths Tests ───────────────────────────────────────────────────── + + +class TestExplicitPaths: + def test_explicit_paths_returns_only_valid(self, git_repo_with_nested: Path): + """When explicit paths are given, only valid nested repos are returned.""" + result = source_and_call( + f'find_nested_git_repos "{git_repo_with_nested}" 2 "components/core" "nonexistent/repo"', + cwd=git_repo_with_nested, + ) + assert result.returncode == 0, result.stderr + paths = [p.strip().rstrip("/") for p in result.stdout.strip().splitlines() if p.strip()] + assert len(paths) == 1 + assert os.path.basename(paths[0]) == "core" + + def test_explicit_paths_skips_scanning(self, git_repo_with_nested: Path): + """When explicit paths are given, only those are checked — no scanning.""" + result = source_and_call( + f'find_nested_git_repos "{git_repo_with_nested}" 2 "components/core"', + cwd=git_repo_with_nested, + ) + assert result.returncode == 0, result.stderr + paths = [p.strip().rstrip("/") for p in result.stdout.strip().splitlines() if p.strip()] + # Only core should be returned, not api (even though it exists) + assert len(paths) == 1 + assert os.path.basename(paths[0]) == "core" + + def test_explicit_empty_returns_nothing(self, git_repo_with_nested: Path): + """When explicit paths are all invalid, nothing is returned.""" + result = source_and_call( + f'find_nested_git_repos "{git_repo_with_nested}" 2 "does/not/exist"', + cwd=git_repo_with_nested, + ) + assert result.returncode == 0 + assert result.stdout.strip() == "" + # ── Configurable Depth Tests ───────────────────────────────────────────────── @@ -355,3 +421,19 @@ def test_discovery_does_not_create_branches(self, git_repo_with_nested: Path): capture_output=True, text=True, ) assert br.stdout.strip() != branch_name + + def test_explicit_nested_repos_from_init_options(self, git_repo_with_nested: Path): + """setup-plan reads nested_repos from init-options.json and uses explicit paths.""" + self._create_feature_first(git_repo_with_nested) + + # Write init-options.json with explicit nested_repos (only core, not api) + init_options = git_repo_with_nested / ".specify" / "init-options.json" + init_options.write_text(json.dumps({"nested_repos": ["components/core"]})) + + result = run_setup_plan(git_repo_with_nested, "--json") + assert result.returncode == 0, result.stderr + data = parse_json_from_output(result.stdout) + + assert "NESTED_REPOS" in data + assert len(data["NESTED_REPOS"]) == 1 + assert data["NESTED_REPOS"][0]["path"] == "components/core" From 53460999a5e377006c27c5df5d45786d40154c9d Mon Sep 17 00:00:00 2001 From: Sakit Atakishiyev Date: Wed, 8 Apr 2026 20:07:16 -0700 Subject: [PATCH 6/8] fix: address active PR review comments - plan.md: clarify JSON parsing instructions (extract first { line) - common.sh: validate max_depth is a positive integer - common.ps1: add ValidateRange(1, MaxValue) on -MaxDepth - setup-plan.ps1: add ValidateRange(0, MaxValue) on -ScanDepth Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- scripts/bash/common.sh | 4 ++++ scripts/powershell/common.ps1 | 1 + scripts/powershell/setup-plan.ps1 | 1 + templates/commands/plan.md | 2 +- 4 files changed, 7 insertions(+), 1 deletion(-) diff --git a/scripts/bash/common.sh b/scripts/bash/common.sh index 732900caa8..d3e4bf4775 100644 --- a/scripts/bash/common.sh +++ b/scripts/bash/common.sh @@ -290,6 +290,10 @@ check_dir() { [[ -d "$1" && -n $(ls -A "$1" 2>/dev/null) ]] && echo " ✓ $2" | find_nested_git_repos() { local repo_root="${1:-$(get_repo_root)}" local max_depth="${2:-2}" + if ! [[ "$max_depth" =~ ^[0-9]+$ ]] || [ "$max_depth" -eq 0 ]; then + echo "find_nested_git_repos: max_depth must be a positive integer" >&2 + return 1 + fi # Collect explicit paths from 3rd argument onward local -a explicit_paths=() diff --git a/scripts/powershell/common.ps1 b/scripts/powershell/common.ps1 index 7032dbb93d..a9ce2ef58f 100644 --- a/scripts/powershell/common.ps1 +++ b/scripts/powershell/common.ps1 @@ -237,6 +237,7 @@ function Test-DirHasFiles { function Find-NestedGitRepos { param( [string]$RepoRoot = (Get-RepoRoot), + [ValidateRange(1, [int]::MaxValue)] [int]$MaxDepth = 2, [string[]]$ExplicitPaths = @() ) diff --git a/scripts/powershell/setup-plan.ps1 b/scripts/powershell/setup-plan.ps1 index d213944998..3592102aa6 100644 --- a/scripts/powershell/setup-plan.ps1 +++ b/scripts/powershell/setup-plan.ps1 @@ -4,6 +4,7 @@ [CmdletBinding()] param( [switch]$Json, + [ValidateRange(0, [int]::MaxValue)] [int]$ScanDepth = 0, [switch]$Help ) diff --git a/templates/commands/plan.md b/templates/commands/plan.md index 41fa84fa51..c3d1d20524 100644 --- a/templates/commands/plan.md +++ b/templates/commands/plan.md @@ -60,7 +60,7 @@ You **MUST** consider the user input before proceeding (if not empty). ## Outline -1. **Setup**: Run `{SCRIPT}` from repo root and parse JSON for FEATURE_SPEC, IMPL_PLAN, SPECS_DIR, BRANCH, and NESTED_REPOS. 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"). +1. **Setup**: Run `{SCRIPT}` from repo root and capture its stdout. The script may print informational lines (e.g., "Copied plan template…") before the JSON payload, so extract the first line starting with `{` and parse that JSON object to obtain FEATURE_SPEC, IMPL_PLAN, SPECS_DIR, BRANCH, and NESTED_REPOS. 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"). 2. **Load context**: Read FEATURE_SPEC and `/memory/constitution.md`. Load IMPL_PLAN template (already copied). From 80bcbb4eaa398dc4f729c97abfa2d60408543cbd Mon Sep 17 00:00:00 2001 From: Sakit Atakishiyev Date: Wed, 8 Apr 2026 20:18:56 -0700 Subject: [PATCH 7/8] 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> --- scripts/bash/common.sh | 14 +++++- scripts/bash/setup-plan.sh | 16 ++++++- scripts/powershell/common.ps1 | 17 +++++-- scripts/powershell/setup-plan.ps1 | 14 ++++-- tests/test_nested_repos.py | 75 +++++++++++++++++++++++++++++++ 5 files changed, 125 insertions(+), 11 deletions(-) diff --git a/scripts/bash/common.sh b/scripts/bash/common.sh index d3e4bf4775..9c0ad0481c 100644 --- a/scripts/bash/common.sh +++ b/scripts/bash/common.sh @@ -287,6 +287,17 @@ check_dir() { [[ -d "$1" && -n $(ls -A "$1" 2>/dev/null) ]] && echo " ✓ $2" | # max_depth — defaults to 2 # explicit_paths — if provided (3rd arg onward), validate and return these directly (no scanning) # Outputs one absolute path per line. +# +# Discovery modes: +# Explicit — validates paths from init-options.json `nested_repos`; no scanning. +# Scan — recursively searches child directories up to max_depth. +# Skips .git directories. Uses `git check-ignore` to prune +# gitignored directories during traversal. A directory with +# its own .git marker is always reported (even if gitignored). +# +# Note: Scanning will NOT descend into gitignored parent directories, so a +# nested repo beneath one (e.g., vendor/foo/.git when vendor/ is ignored) +# will not be discovered. Use init-options.json `nested_repos` for those. find_nested_git_repos() { local repo_root="${1:-$(get_repo_root)}" local max_depth="${2:-2}" @@ -334,7 +345,8 @@ find_nested_git_repos() { echo "$child" fi elif [ "$current_depth" -lt "$max_depth" ]; then - # Skip gitignored directories (they won't contain nested repos) + # Skip gitignored directories — won't descend into them. + # Repos under gitignored parents require explicit init-options config. if git -C "$repo_root" check-ignore -q "$child" 2>/dev/null; then continue fi diff --git a/scripts/bash/setup-plan.sh b/scripts/bash/setup-plan.sh index eb89e5aaf2..6e4d39f011 100644 --- a/scripts/bash/setup-plan.sh +++ b/scripts/bash/setup-plan.sh @@ -75,16 +75,18 @@ fi # Discover nested independent git repositories (for AI agent to analyze) NESTED_REPOS_JSON="[]" if [ "$HAS_GIT" = true ]; then - scan_depth="${SCAN_DEPTH:-2}" INIT_OPTIONS="$REPO_ROOT/.specify/init-options.json" explicit_repos=() + config_depth="" - # Read explicit nested_repos from init-options.json if available + # Read explicit nested_repos and nested_repo_scan_depth from init-options.json if available if [ -f "$INIT_OPTIONS" ]; then if has_jq; then while IFS= read -r rp; do [ -n "$rp" ] && explicit_repos+=("$rp") done < <(jq -r '.nested_repos // [] | .[]' "$INIT_OPTIONS" 2>/dev/null) + _cd=$(jq -r '.nested_repo_scan_depth // empty' "$INIT_OPTIONS" 2>/dev/null) + [ -n "$_cd" ] && config_depth="$_cd" else _py=$(command -v python3 2>/dev/null || command -v python 2>/dev/null || echo "") if [ -n "$_py" ]; then @@ -95,10 +97,20 @@ if [ "$HAS_GIT" = true ]; then try: [print(p) for p in json.load(open(sys.argv[1])).get('nested_repos',[])] except: pass" "$INIT_OPTIONS" 2>/dev/null) + _cd=$("$_py" -c "import json,sys +try: + v=json.load(open(sys.argv[1])).get('nested_repo_scan_depth') + if v is not None: print(v) +except: pass" "$INIT_OPTIONS" 2>/dev/null) + _cd="${_cd%$'\r'}" + [ -n "$_cd" ] && config_depth="$_cd" fi fi fi + # Priority: CLI --scan-depth > init-options nested_repo_scan_depth > default 2 + scan_depth="${SCAN_DEPTH:-${config_depth:-2}}" + if [ ${#explicit_repos[@]} -gt 0 ]; then nested_repos=$(find_nested_git_repos "$REPO_ROOT" "$scan_depth" "${explicit_repos[@]}") else diff --git a/scripts/powershell/common.ps1 b/scripts/powershell/common.ps1 index a9ce2ef58f..5b316b0d39 100644 --- a/scripts/powershell/common.ps1 +++ b/scripts/powershell/common.ps1 @@ -229,11 +229,19 @@ function Test-DirHasFiles { } # Discover nested independent git repositories under RepoRoot. -# Searches up to 2 directory levels deep for subdirectories containing .git -# (directory or file, covering worktrees/submodules). Excludes the root repo -# itself and common non-project directories. # Returns an array of absolute paths. Scan depth is configurable (default 2). # If ExplicitPaths are provided, validates and returns those directly (no scanning). +# +# Discovery modes: +# Explicit — validates paths from init-options.json `nested_repos`; no scanning. +# Scan — recursively searches child directories up to MaxDepth. +# Skips .git directories. Uses `git check-ignore` to prune +# gitignored directories during traversal. A directory with +# its own .git marker is always reported (even if gitignored). +# +# Note: Scanning will NOT descend into gitignored parent directories, so a +# nested repo beneath one (e.g., vendor/foo/.git when vendor/ is ignored) +# will not be discovered. Use init-options.json `nested_repos` for those. function Find-NestedGitRepos { param( [string]$RepoRoot = (Get-RepoRoot), @@ -278,7 +286,8 @@ function Find-NestedGitRepos { } } catch { } } elseif ($CurrentDepth -lt $MaxDepth) { - # Skip gitignored directories (they won't contain nested repos) + # Skip gitignored directories — won't descend into them. + # Repos under gitignored parents require explicit init-options config. $null = git -C $RepoRoot check-ignore -q $child.FullName 2>$null if ($LASTEXITCODE -eq 0) { continue } $found += ScanDir -Dir $child.FullName -CurrentDepth ($CurrentDepth + 1) diff --git a/scripts/powershell/setup-plan.ps1 b/scripts/powershell/setup-plan.ps1 index 3592102aa6..261e5b5aef 100644 --- a/scripts/powershell/setup-plan.ps1 +++ b/scripts/powershell/setup-plan.ps1 @@ -4,8 +4,8 @@ [CmdletBinding()] param( [switch]$Json, - [ValidateRange(0, [int]::MaxValue)] - [int]$ScanDepth = 0, + [ValidateRange(1, [int]::MaxValue)] + [int]$ScanDepth, [switch]$Help ) @@ -48,20 +48,26 @@ if ($template -and (Test-Path $template)) { # Discover nested independent git repositories (for AI agent to analyze) $nestedReposResult = @() if ($paths.HAS_GIT -eq 'true' -or $paths.HAS_GIT -eq $true) { - $effectiveDepth = if ($ScanDepth -gt 0) { $ScanDepth } else { 2 } $initOptions = Join-Path $paths.REPO_ROOT '.specify' 'init-options.json' $explicitPaths = @() + $configDepth = $null - # Read explicit nested_repos from init-options.json if available + # Read explicit nested_repos and nested_repo_scan_depth from init-options.json if available if (Test-Path -LiteralPath $initOptions) { try { $opts = Get-Content $initOptions -Raw | ConvertFrom-Json if ($opts.nested_repos -and $opts.nested_repos.Count -gt 0) { $explicitPaths = @($opts.nested_repos) } + if ($null -ne $opts.nested_repo_scan_depth) { + $configDepth = [int]$opts.nested_repo_scan_depth + } } catch { } } + # Priority: CLI -ScanDepth > init-options nested_repo_scan_depth > default 2 + $effectiveDepth = if ($PSBoundParameters.ContainsKey('ScanDepth')) { $ScanDepth } elseif ($configDepth) { $configDepth } else { 2 } + if ($explicitPaths.Count -gt 0) { $nestedRepos = Find-NestedGitRepos -RepoRoot $paths.REPO_ROOT -MaxDepth $effectiveDepth -ExplicitPaths $explicitPaths } else { diff --git a/tests/test_nested_repos.py b/tests/test_nested_repos.py index cbfa8dbdd4..9ef3783e3b 100644 --- a/tests/test_nested_repos.py +++ b/tests/test_nested_repos.py @@ -221,6 +221,48 @@ def test_nested_repo_found_even_if_gitignored(self, tmp_path: Path): assert len(paths) == 1 assert os.path.basename(paths[0]) == "nested-lib" + def test_repo_under_gitignored_parent_not_discovered_by_scan(self, tmp_path: Path): + """A nested repo beneath a gitignored parent dir is NOT found by scanning. + + When vendor/ is gitignored and vendor/foo/.git exists, the scan will not + descend into vendor/ so vendor/foo won't be discovered. Users should use + init-options.json `nested_repos` for this case. + """ + _init_git_repo(tmp_path) + (tmp_path / ".specify").mkdir() + scripts_dir = tmp_path / "scripts" / "bash" + scripts_dir.mkdir(parents=True) + shutil.copy(COMMON_SH, scripts_dir / "common.sh") + + # Gitignore vendor/ + (tmp_path / ".gitignore").write_text("vendor/\n") + subprocess.run(["git", "add", ".gitignore"], cwd=tmp_path, check=True) + subprocess.run(["git", "commit", "-m", "ignore vendor", "-q"], cwd=tmp_path, check=True) + + # Create nested repo under gitignored parent: vendor/foo + vendor_foo = tmp_path / "vendor" / "foo" + vendor_foo.mkdir(parents=True) + _init_git_repo(vendor_foo) + + # Scan with depth 3 — should still not find it because vendor/ is pruned + result = source_and_call( + f'find_nested_git_repos "{tmp_path}" 3', + cwd=tmp_path, + ) + assert result.returncode == 0 + paths = [p.strip().rstrip("/") for p in result.stdout.strip().splitlines() if p.strip()] + assert len(paths) == 0 + + # But explicit paths mode WILL find it + result2 = source_and_call( + f'find_nested_git_repos "{tmp_path}" 3 "vendor/foo"', + cwd=tmp_path, + ) + assert result2.returncode == 0 + paths2 = [p.strip().rstrip("/") for p in result2.stdout.strip().splitlines() if p.strip()] + assert len(paths2) == 1 + assert os.path.basename(paths2[0]) == "foo" + # ── Explicit Paths Tests ───────────────────────────────────────────────────── @@ -340,6 +382,7 @@ def test_nested_repos_not_branched(self, git_repo_with_nested: Path): cwd=git_repo_with_nested / subdir, capture_output=True, text=True, ) + assert br.returncode == 0, br.stderr assert br.stdout.strip() != branch_name @@ -437,3 +480,35 @@ def test_explicit_nested_repos_from_init_options(self, git_repo_with_nested: Pat assert "NESTED_REPOS" in data assert len(data["NESTED_REPOS"]) == 1 assert data["NESTED_REPOS"][0]["path"] == "components/core" + + def test_scan_depth_from_init_options(self, tmp_path: Path): + """setup-plan reads nested_repo_scan_depth from init-options.json as default.""" + _init_git_repo(tmp_path) + _setup_scripts(tmp_path) + + # Level 3 repo + deep_dir = tmp_path / "services" / "backend" / "auth" + deep_dir.mkdir(parents=True) + _init_git_repo(deep_dir) + + # Create feature branch first + run_create_feature( + tmp_path, "--json", "--short-name", "cfg-depth", "Config depth test", + ) + + # Without config: default depth 2, should not find level-3 repo + result = run_setup_plan(tmp_path, "--json") + assert result.returncode == 0, result.stderr + data = parse_json_from_output(result.stdout) + assert data["NESTED_REPOS"] == [] + + # Add nested_repo_scan_depth=3 in init-options.json + init_options = tmp_path / ".specify" / "init-options.json" + init_options.write_text(json.dumps({"nested_repo_scan_depth": 3})) + + # Now should discover the level-3 repo + result2 = run_setup_plan(tmp_path, "--json") + assert result2.returncode == 0, result2.stderr + data2 = parse_json_from_output(result2.stdout) + assert len(data2["NESTED_REPOS"]) == 1 + assert data2["NESTED_REPOS"][0]["path"] == "services/backend/auth" From 3e25758a281b70a4617ba5b6ac96d435cb8758a9 Mon Sep 17 00:00:00 2001 From: Sakit Atakishiyev Date: Wed, 8 Apr 2026 20:34:18 -0700 Subject: [PATCH 8/8] Validate config_depth and make discovery non-blocking - Fix stale comment in common.sh (remove 'common non-project directories') - Validate nested_repo_scan_depth from init-options.json (positive integer) - Wrap find_nested_git_repos calls with || fallback in bash (set -e safe) - Wrap Find-NestedGitRepos calls in try/catch in PowerShell - Discovery failures now emit warnings and fall back to empty list Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- scripts/bash/common.sh | 3 ++- scripts/bash/setup-plan.sh | 13 +++++++++++-- scripts/powershell/setup-plan.ps1 | 21 ++++++++++++++++++--- 3 files changed, 31 insertions(+), 6 deletions(-) diff --git a/scripts/bash/common.sh b/scripts/bash/common.sh index 9c0ad0481c..610e0c2379 100644 --- a/scripts/bash/common.sh +++ b/scripts/bash/common.sh @@ -281,7 +281,8 @@ check_dir() { [[ -d "$1" && -n $(ls -A "$1" 2>/dev/null) ]] && echo " ✓ $2" | # Discover nested independent git repositories under REPO_ROOT. # Searches up to $max_depth directory levels deep for subdirectories containing # .git (directory or file, covering worktrees/submodules). Excludes the root -# repo itself and common non-project directories. +# repo itself; scanning skips .git directories and prunes gitignored +# directories via `git check-ignore`. # Usage: find_nested_git_repos [repo_root] [max_depth] [explicit_paths...] # repo_root — defaults to $(get_repo_root) # max_depth — defaults to 2 diff --git a/scripts/bash/setup-plan.sh b/scripts/bash/setup-plan.sh index 6e4d39f011..e63cc20d88 100644 --- a/scripts/bash/setup-plan.sh +++ b/scripts/bash/setup-plan.sh @@ -109,12 +109,21 @@ except: pass" "$INIT_OPTIONS" 2>/dev/null) fi # Priority: CLI --scan-depth > init-options nested_repo_scan_depth > default 2 + # Validate config_depth the same way as --scan-depth (must be positive integer) + if [ -n "$config_depth" ]; then + case "$config_depth" in + ''|*[!0-9]*|0) + echo "WARNING: nested_repo_scan_depth in init-options.json must be a positive integer, got '$config_depth' — using default" >&2 + config_depth="" + ;; + esac + fi scan_depth="${SCAN_DEPTH:-${config_depth:-2}}" if [ ${#explicit_repos[@]} -gt 0 ]; then - nested_repos=$(find_nested_git_repos "$REPO_ROOT" "$scan_depth" "${explicit_repos[@]}") + nested_repos=$(find_nested_git_repos "$REPO_ROOT" "$scan_depth" "${explicit_repos[@]}") || nested_repos="" else - nested_repos=$(find_nested_git_repos "$REPO_ROOT" "$scan_depth") + nested_repos=$(find_nested_git_repos "$REPO_ROOT" "$scan_depth") || nested_repos="" fi if [ -n "$nested_repos" ]; then NESTED_REPOS_JSON="[" diff --git a/scripts/powershell/setup-plan.ps1 b/scripts/powershell/setup-plan.ps1 index 261e5b5aef..cfce7d9f72 100644 --- a/scripts/powershell/setup-plan.ps1 +++ b/scripts/powershell/setup-plan.ps1 @@ -60,7 +60,12 @@ if ($paths.HAS_GIT -eq 'true' -or $paths.HAS_GIT -eq $true) { $explicitPaths = @($opts.nested_repos) } if ($null -ne $opts.nested_repo_scan_depth) { - $configDepth = [int]$opts.nested_repo_scan_depth + $parsedConfigDepth = [int]$opts.nested_repo_scan_depth + if ($parsedConfigDepth -ge 1) { + $configDepth = $parsedConfigDepth + } else { + Write-Warning "nested_repo_scan_depth in init-options.json must be >= 1, got $parsedConfigDepth — using default" + } } } catch { } } @@ -69,9 +74,19 @@ if ($paths.HAS_GIT -eq 'true' -or $paths.HAS_GIT -eq $true) { $effectiveDepth = if ($PSBoundParameters.ContainsKey('ScanDepth')) { $ScanDepth } elseif ($configDepth) { $configDepth } else { 2 } if ($explicitPaths.Count -gt 0) { - $nestedRepos = Find-NestedGitRepos -RepoRoot $paths.REPO_ROOT -MaxDepth $effectiveDepth -ExplicitPaths $explicitPaths + try { + $nestedRepos = Find-NestedGitRepos -RepoRoot $paths.REPO_ROOT -MaxDepth $effectiveDepth -ExplicitPaths $explicitPaths + } catch { + Write-Warning "Nested repo discovery failed: $_" + $nestedRepos = @() + } } else { - $nestedRepos = Find-NestedGitRepos -RepoRoot $paths.REPO_ROOT -MaxDepth $effectiveDepth + try { + $nestedRepos = Find-NestedGitRepos -RepoRoot $paths.REPO_ROOT -MaxDepth $effectiveDepth + } catch { + Write-Warning "Nested repo discovery failed: $_" + $nestedRepos = @() + } } foreach ($nestedPath in $nestedRepos) { $relPath = $nestedPath.Substring($paths.REPO_ROOT.Length).TrimStart('\', '/')