Skip to content
Closed
81 changes: 81 additions & 0 deletions scripts/bash/common.sh
Original file line number Diff line number Diff line change
Expand Up @@ -278,6 +278,87 @@ 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 and common non-project directories.
# 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/<preset-id>/templates/ (sorted by priority from .registry)
Expand Down
104 changes: 97 additions & 7 deletions scripts/bash/setup-plan.sh
Original file line number Diff line number Diff line change
Expand Up @@ -4,24 +4,47 @@ set -e

# Parse command line arguments
JSON_MODE=false
SCAN_DEPTH=""
ARGS=()

for arg in "$@"; do
case "$arg" in
--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)"
Expand Down Expand Up @@ -49,6 +72,69 @@ 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
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
nested_repos=$(find_nested_git_repos "$REPO_ROOT" "$scan_depth")
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
Expand All @@ -58,16 +144,20 @@ 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"
echo "IMPL_PLAN: $IMPL_PLAN"
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

71 changes: 71 additions & 0 deletions scripts/powershell/common.ps1
Original file line number Diff line number Diff line change
Expand Up @@ -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/<preset-id>/templates/ (sorted by priority from .registry)
Expand Down
50 changes: 47 additions & 3 deletions scripts/powershell/setup-plan.ps1
Original file line number Diff line number Diff line change
Expand Up @@ -4,16 +4,19 @@
[CmdletBinding()]
param(
[switch]$Json,
[ValidateRange(1, [int]::MaxValue)]
[int]$ScanDepth,
[switch]$Help
)

$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
}

Expand Down Expand Up @@ -42,6 +45,40 @@ 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) {
$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 {
$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]@{
Expand All @@ -50,6 +87,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 {
Expand All @@ -58,4 +96,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)"
}
}
}
Loading
Loading