Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
82 changes: 82 additions & 0 deletions scripts/bash/common.sh
Original file line number Diff line number Diff line change
Expand Up @@ -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/<preset-id>/templates/ (sorted by priority from .registry)
Expand Down
113 changes: 106 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,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
Expand All @@ -58,16 +153,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
Loading