fix(scripts): prioritize .specify over git for repo root detection (#1933)

* fix(scripts): prioritize .specify over git for repo root detection

When spec-kit is initialized in a subdirectory that doesn't have its
own .git, but a parent directory does, spec-kit was incorrectly using
the parent's git repository root. This caused specs to be created in
the wrong location.

The fix changes repo root detection to prioritize .specify directory
over git rev-parse, ensuring spec-kit respects its own initialization
boundary rather than inheriting a parent git repo.

Fixes #1932

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

* fix: address code review feedback

- Normalize paths in find_specify_root to prevent infinite loop with relative paths
- Use -PathType Container in PowerShell to only match .specify directories
- Improve has_git/Test-HasGit to check git command availability and validate work tree
- Handle git worktrees/submodules where .git can be a file
- Remove dead fallback code in create-new-feature scripts

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

* fix: check .specify before termination in find_specify_root

Fixes edge case where project root is at filesystem root (common in
containers). The loop now checks for .specify before checking the
termination condition.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

* fix: scope git operations to spec-kit root & remove unused helpers

- get_current_branch now uses has_git check and runs git with -C to
  prevent using parent git repo branch names in .specify-only projects
- Same fix applied to PowerShell Get-CurrentBranch
- Removed unused find_repo_root() from create-new-feature.sh
- Removed unused Find-RepositoryRoot from create-new-feature.ps1

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

* fix: use cd -- to handle paths starting with dash

Prevents cd from interpreting directory names like -P or -L as options.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

* fix: check git command exists before calling get_repo_root in has_git

Avoids unnecessary work when git isn't installed since get_repo_root
may internally call git rev-parse.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

* fix(powershell): use LiteralPath and check git before Get-RepoRoot

- Use -LiteralPath in Find-SpecifyRoot to handle paths with wildcard
  characters ([, ], *, ?)
- Check Get-Command git before calling Get-RepoRoot in Test-HasGit to
  avoid unnecessary work when git isn't installed

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

* fix(powershell): use LiteralPath for .git check in Test-HasGit

Prevents Test-Path from treating wildcard characters in paths as globs.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

* fix(powershell): use LiteralPath in Get-RepoRoot fallback

Prevents Resolve-Path from treating wildcard characters as patterns.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

---------

Co-authored-by: iamaeroplane <michal.bachorik@gmail.com>
Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
Michal Bachorik
2026-03-24 14:55:21 +01:00
committed by GitHub
parent 24247c24c9
commit b1ba972978
4 changed files with 127 additions and 92 deletions

View File

@@ -1,15 +1,48 @@
#!/usr/bin/env bash #!/usr/bin/env bash
# Common functions and variables for all scripts # Common functions and variables for all scripts
# Get repository root, with fallback for non-git repositories # Find repository root by searching upward for .specify directory
# This is the primary marker for spec-kit projects
find_specify_root() {
local dir="${1:-$(pwd)}"
# Normalize to absolute path to prevent infinite loop with relative paths
# Use -- to handle paths starting with - (e.g., -P, -L)
dir="$(cd -- "$dir" 2>/dev/null && pwd)" || return 1
local prev_dir=""
while true; do
if [ -d "$dir/.specify" ]; then
echo "$dir"
return 0
fi
# Stop if we've reached filesystem root or dirname stops changing
if [ "$dir" = "/" ] || [ "$dir" = "$prev_dir" ]; then
break
fi
prev_dir="$dir"
dir="$(dirname "$dir")"
done
return 1
}
# Get repository root, prioritizing .specify directory over git
# This prevents using a parent git repo when spec-kit is initialized in a subdirectory
get_repo_root() { get_repo_root() {
# First, look for .specify directory (spec-kit's own marker)
local specify_root
if specify_root=$(find_specify_root); then
echo "$specify_root"
return
fi
# Fallback to git if no .specify found
if git rev-parse --show-toplevel >/dev/null 2>&1; then if git rev-parse --show-toplevel >/dev/null 2>&1; then
git rev-parse --show-toplevel git rev-parse --show-toplevel
else return
# Fall back to script location for non-git repos
local script_dir="$(CDPATH="" cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
(cd "$script_dir/../../.." && pwd)
fi fi
# Final fallback to script location for non-git repos
local script_dir="$(CDPATH="" cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
(cd "$script_dir/../../.." && pwd)
} }
# Get current branch, with fallback for non-git repositories # Get current branch, with fallback for non-git repositories
@@ -20,14 +53,14 @@ get_current_branch() {
return return
fi fi
# Then check git if available # Then check git if available at the spec-kit root (not parent)
if git rev-parse --abbrev-ref HEAD >/dev/null 2>&1; then local repo_root=$(get_repo_root)
git rev-parse --abbrev-ref HEAD if has_git; then
git -C "$repo_root" rev-parse --abbrev-ref HEAD
return return
fi fi
# For non-git repos, try to find the latest feature directory # For non-git repos, try to find the latest feature directory
local repo_root=$(get_repo_root)
local specs_dir="$repo_root/specs" local specs_dir="$repo_root/specs"
if [[ -d "$specs_dir" ]]; then if [[ -d "$specs_dir" ]]; then
@@ -68,9 +101,17 @@ get_current_branch() {
echo "main" # Final fallback echo "main" # Final fallback
} }
# Check if we have git available # Check if we have git available at the spec-kit root level
# Returns true only if git is installed and the repo root is inside a git work tree
# Handles both regular repos (.git directory) and worktrees/submodules (.git file)
has_git() { has_git() {
git rev-parse --show-toplevel >/dev/null 2>&1 # First check if git command is available (before calling get_repo_root which may use git)
command -v git >/dev/null 2>&1 || return 1
local repo_root=$(get_repo_root)
# Check if .git exists (directory or file for worktrees/submodules)
[ -e "$repo_root/.git" ] || return 1
# Verify it's actually a valid git work tree
git -C "$repo_root" rev-parse --is-inside-work-tree >/dev/null 2>&1
} }
check_feature_branch() { check_feature_branch() {

View File

@@ -80,19 +80,6 @@ if [ -z "$FEATURE_DESCRIPTION" ]; then
exit 1 exit 1
fi fi
# Function to find the repository root by searching for existing project markers
find_repo_root() {
local dir="$1"
while [ "$dir" != "/" ]; do
if [ -d "$dir/.git" ] || [ -d "$dir/.specify" ]; then
echo "$dir"
return 0
fi
dir="$(dirname "$dir")"
done
return 1
}
# Function to get highest number from specs directory # Function to get highest number from specs directory
get_highest_from_specs() { get_highest_from_specs() {
local specs_dir="$1" local specs_dir="$1"
@@ -171,21 +158,16 @@ clean_branch_name() {
echo "$name" | tr '[:upper:]' '[:lower:]' | sed 's/[^a-z0-9]/-/g' | sed 's/-\+/-/g' | sed 's/^-//' | sed 's/-$//' echo "$name" | tr '[:upper:]' '[:lower:]' | sed 's/[^a-z0-9]/-/g' | sed 's/-\+/-/g' | sed 's/^-//' | sed 's/-$//'
} }
# Resolve repository root. Prefer git information when available, but fall back # Resolve repository root using common.sh functions which prioritize .specify over git
# to searching for repository markers so the workflow still functions in repositories that
# were initialised with --no-git.
SCRIPT_DIR="$(CDPATH="" cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" SCRIPT_DIR="$(CDPATH="" cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
source "$SCRIPT_DIR/common.sh" source "$SCRIPT_DIR/common.sh"
if git rev-parse --show-toplevel >/dev/null 2>&1; then REPO_ROOT=$(get_repo_root)
REPO_ROOT=$(git rev-parse --show-toplevel)
# Check if git is available at this repo root (not a parent)
if has_git; then
HAS_GIT=true HAS_GIT=true
else else
REPO_ROOT="$(find_repo_root "$SCRIPT_DIR")"
if [ -z "$REPO_ROOT" ]; then
echo "Error: Could not determine repository root. Please run this script from within the repository." >&2
exit 1
fi
HAS_GIT=false HAS_GIT=false
fi fi

View File

@@ -1,7 +1,38 @@
#!/usr/bin/env pwsh #!/usr/bin/env pwsh
# Common PowerShell functions analogous to common.sh # Common PowerShell functions analogous to common.sh
# Find repository root by searching upward for .specify directory
# This is the primary marker for spec-kit projects
function Find-SpecifyRoot {
param([string]$StartDir = (Get-Location).Path)
# Normalize to absolute path to prevent issues with relative paths
# Use -LiteralPath to handle paths with wildcard characters ([, ], *, ?)
$current = (Resolve-Path -LiteralPath $StartDir -ErrorAction SilentlyContinue)?.Path
if (-not $current) { return $null }
while ($true) {
if (Test-Path -LiteralPath (Join-Path $current ".specify") -PathType Container) {
return $current
}
$parent = Split-Path $current -Parent
if ([string]::IsNullOrEmpty($parent) -or $parent -eq $current) {
return $null
}
$current = $parent
}
}
# Get repository root, prioritizing .specify directory over git
# This prevents using a parent git repo when spec-kit is initialized in a subdirectory
function Get-RepoRoot { function Get-RepoRoot {
# First, look for .specify directory (spec-kit's own marker)
$specifyRoot = Find-SpecifyRoot
if ($specifyRoot) {
return $specifyRoot
}
# Fallback to git if no .specify found
try { try {
$result = git rev-parse --show-toplevel 2>$null $result = git rev-parse --show-toplevel 2>$null
if ($LASTEXITCODE -eq 0) { if ($LASTEXITCODE -eq 0) {
@@ -10,9 +41,10 @@ function Get-RepoRoot {
} catch { } catch {
# Git command failed # Git command failed
} }
# Fall back to script location for non-git repos # Final fallback to script location for non-git repos
return (Resolve-Path (Join-Path $PSScriptRoot "../../..")).Path # Use -LiteralPath to handle paths with wildcard characters
return (Resolve-Path -LiteralPath (Join-Path $PSScriptRoot "../../..")).Path
} }
function Get-CurrentBranch { function Get-CurrentBranch {
@@ -20,19 +52,21 @@ function Get-CurrentBranch {
if ($env:SPECIFY_FEATURE) { if ($env:SPECIFY_FEATURE) {
return $env:SPECIFY_FEATURE return $env:SPECIFY_FEATURE
} }
# Then check git if available # Then check git if available at the spec-kit root (not parent)
try {
$result = git rev-parse --abbrev-ref HEAD 2>$null
if ($LASTEXITCODE -eq 0) {
return $result
}
} catch {
# Git command failed
}
# For non-git repos, try to find the latest feature directory
$repoRoot = Get-RepoRoot $repoRoot = Get-RepoRoot
if (Test-HasGit) {
try {
$result = git -C $repoRoot rev-parse --abbrev-ref HEAD 2>$null
if ($LASTEXITCODE -eq 0) {
return $result
}
} catch {
# Git command failed
}
}
# For non-git repos, try to find the latest feature directory
$specsDir = Join-Path $repoRoot "specs" $specsDir = Join-Path $repoRoot "specs"
if (Test-Path $specsDir) { if (Test-Path $specsDir) {
@@ -69,9 +103,23 @@ function Get-CurrentBranch {
return "main" return "main"
} }
# Check if we have git available at the spec-kit root level
# Returns true only if git is installed and the repo root is inside a git work tree
# Handles both regular repos (.git directory) and worktrees/submodules (.git file)
function Test-HasGit { function Test-HasGit {
# First check if git command is available (before calling Get-RepoRoot which may use git)
if (-not (Get-Command git -ErrorAction SilentlyContinue)) {
return $false
}
$repoRoot = Get-RepoRoot
# Check if .git exists (directory or file for worktrees/submodules)
# Use -LiteralPath to handle paths with wildcard characters
if (-not (Test-Path -LiteralPath (Join-Path $repoRoot ".git"))) {
return $false
}
# Verify it's actually a valid git work tree
try { try {
git rev-parse --show-toplevel 2>$null | Out-Null $null = git -C $repoRoot rev-parse --is-inside-work-tree 2>$null
return ($LASTEXITCODE -eq 0) return ($LASTEXITCODE -eq 0)
} catch { } catch {
return $false return $false

View File

@@ -45,30 +45,6 @@ if ([string]::IsNullOrWhiteSpace($featureDesc)) {
exit 1 exit 1
} }
# Resolve repository root. Prefer git information when available, but fall back
# to searching for repository markers so the workflow still functions in repositories that
# were initialized with --no-git.
function Find-RepositoryRoot {
param(
[string]$StartDir,
[string[]]$Markers = @('.git', '.specify')
)
$current = Resolve-Path $StartDir
while ($true) {
foreach ($marker in $Markers) {
if (Test-Path (Join-Path $current $marker)) {
return $current
}
}
$parent = Split-Path $current -Parent
if ($parent -eq $current) {
# Reached filesystem root without finding markers
return $null
}
$current = $parent
}
}
function Get-HighestNumberFromSpecs { function Get-HighestNumberFromSpecs {
param([string]$SpecsDir) param([string]$SpecsDir)
@@ -139,26 +115,14 @@ function ConvertTo-CleanBranchName {
return $Name.ToLower() -replace '[^a-z0-9]', '-' -replace '-{2,}', '-' -replace '^-', '' -replace '-$', '' return $Name.ToLower() -replace '[^a-z0-9]', '-' -replace '-{2,}', '-' -replace '^-', '' -replace '-$', ''
} }
$fallbackRoot = (Find-RepositoryRoot -StartDir $PSScriptRoot) # Load common functions (includes Get-RepoRoot, Test-HasGit, Resolve-Template)
if (-not $fallbackRoot) {
Write-Error "Error: Could not determine repository root. Please run this script from within the repository."
exit 1
}
# Load common functions (includes Resolve-Template)
. "$PSScriptRoot/common.ps1" . "$PSScriptRoot/common.ps1"
try { # Use common.ps1 functions which prioritize .specify over git
$repoRoot = git rev-parse --show-toplevel 2>$null $repoRoot = Get-RepoRoot
if ($LASTEXITCODE -eq 0) {
$hasGit = $true # Check if git is available at this repo root (not a parent)
} else { $hasGit = Test-HasGit
throw "Git not available"
}
} catch {
$repoRoot = $fallbackRoot
$hasGit = $false
}
Set-Location $repoRoot Set-Location $repoRoot