diff --git a/scripts/bash/common.sh b/scripts/bash/common.sh index 04af7d794..610e0c237 100644 --- a/scripts/bash/common.sh +++ b/scripts/bash/common.sh @@ -278,6 +278,88 @@ 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 $max_depth directory levels deep for subdirectories containing +# .git (directory or file, covering worktrees/submodules). Excludes the root +# 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 +# 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}" + 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=() + 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 + ( + _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")" + # 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 — 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 + _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: # 1. .specify/templates/overrides/ # 2. .specify/presets//templates/ (sorted by priority from .registry) diff --git a/scripts/bash/setup-plan.sh b/scripts/bash/setup-plan.sh index 9f5523149..e63cc20d8 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,39 @@ 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 +# 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)" @@ -49,6 +72,78 @@ else touch "$IMPL_PLAN" fi +# Discover nested independent git repositories (for AI agent to analyze) +NESTED_REPOS_JSON="[]" +if [ "$HAS_GIT" = true ]; then + INIT_OPTIONS="$REPO_ROOT/.specify/init-options.json" + explicit_repos=() + config_depth="" + + # 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 + 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) + _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 + # 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="" + else + nested_repos=$(find_nested_git_repos "$REPO_ROOT" "$scan_depth") || nested_repos="" + fi + 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 +153,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 +165,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/common.ps1 b/scripts/powershell/common.ps1 index 35ed884f0..5b316b0d3 100644 --- a/scripts/powershell/common.ps1 +++ b/scripts/powershell/common.ps1 @@ -228,6 +228,77 @@ function Test-DirHasFiles { } } +# Discover nested independent git repositories under RepoRoot. +# 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), + [ValidateRange(1, [int]::MaxValue)] + [int]$MaxDepth = 2, + [string[]]$ExplicitPaths = @() + ) + + # 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 { $_.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) { + $found += $child.FullName + } + } catch { } + } elseif ($CurrentDepth -lt $MaxDepth) { + # 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) + } + } + return $found + } + + return ScanDir -Dir $RepoRoot -CurrentDepth 1 +} + # 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/setup-plan.ps1 b/scripts/powershell/setup-plan.ps1 index ee09094bf..cfce7d9f7 100644 --- a/scripts/powershell/setup-plan.ps1 +++ b/scripts/powershell/setup-plan.ps1 @@ -4,6 +4,8 @@ [CmdletBinding()] param( [switch]$Json, + [ValidateRange(1, [int]::MaxValue)] + [int]$ScanDepth, [switch]$Help ) @@ -11,9 +13,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 +45,55 @@ 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) { + $initOptions = Join-Path $paths.REPO_ROOT '.specify' 'init-options.json' + $explicitPaths = @() + $configDepth = $null + + # 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) { + $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 { } + } + + # 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) { + try { + $nestedRepos = Find-NestedGitRepos -RepoRoot $paths.REPO_ROOT -MaxDepth $effectiveDepth -ExplicitPaths $explicitPaths + } catch { + Write-Warning "Nested repo discovery failed: $_" + $nestedRepos = @() + } + } else { + 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('\', '/') + $nestedReposResult += [PSCustomObject]@{ path = $relPath } + } +} + # Output results if ($Json) { $result = [PSCustomObject]@{ @@ -50,6 +102,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 +111,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 4f1e9ed29..c3d1d2052 100644 --- a/templates/commands/plan.md +++ b/templates/commands/plan.md @@ -60,11 +60,17 @@ 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 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). -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,9 +79,9 @@ 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. +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/tasks.md b/templates/commands/tasks.md index 4e204abc1..f5599b560 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 5a2fafebe..2feafc9be 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 60f9be455..a2823b870 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 new file mode 100644 index 000000000..9ef3783e3 --- /dev/null +++ b/tests/test_nested_repos.py @@ -0,0 +1,514 @@ +""" +Pytest tests for nested independent git repository support. + +Tests cover: +- Discovery of nested git repos via find_nested_git_repos (bash) +- Configurable scan depth for discovery +- .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 +""" + +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" +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 +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, + ) + + +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.""" + _init_git_repo(tmp_path) + _setup_scripts(tmp_path) + + # 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 2: 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) + _setup_scripts(tmp_path) + + # Regular subdirectory without .git + (tmp_path / "components" / "core").mkdir(parents=True) + return tmp_path + + +@pytest.fixture +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) + + # 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) + + # Valid nested repo (not gitignored) + lib_dir = tmp_path / "lib" + lib_dir.mkdir(parents=True) + _init_git_repo(lib_dir) + + return tmp_path + + +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) + + +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) -> 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.copy() + ) + + +# ── 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_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_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()] + 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() + scripts_dir = tmp_path / "scripts" / "bash" + scripts_dir.mkdir(parents=True) + shutil.copy(COMMON_SH, scripts_dir / "common.sh") + + nested = tmp_path / "mylib" + 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]) == "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" + + 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 ───────────────────────────────────────────────────── + + +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 ───────────────────────────────────────────────── + + +class TestConfigurableDepth: + def test_depth_1_misses_level2_repos(self, git_repo_with_nested: Path): + """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()] + 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 + + 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") + + deep_dir = tmp_path / "services" / "backend" / "auth" + deep_dir.mkdir(parents=True) + _init_git_repo(deep_dir) + + # 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 + assert result2.stdout.strip() == "" + + # 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" + + +# ── Create Feature Does NOT Branch Nested Repos ───────────────────────────── + + +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 result.returncode == 0, result.stderr + data = json.loads(result.stdout.strip()) + assert "NESTED_REPOS" not in data + assert "BRANCH_NAME" in data + + 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", "--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.returncode == 0, br.stderr + assert br.stdout.strip() != branch_name + + +# ── Setup Plan Discovery Tests ─────────────────────────────────────────────── + + +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 = 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[1]["path"] == "components/core" + + 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 = parse_json_from_output(result.stdout) + assert data["NESTED_REPOS"] == [] + + 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 = parse_json_from_output(result.stdout) + assert data["NESTED_REPOS"] == [] + + # 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 + + 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" + + 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"