Skip to content

Commit 71e0b8f

Browse files
committed
fix: use git ls-remote for remote-aware dry-run numbering
Dry-run now queries remote branches via `git ls-remote --heads` (read-only, no fetch) to account for remote-only branches when computing the next sequential number. This prevents dry-run from returning a number that already exists on a remote. Added test verifying dry-run sees remote-only higher-numbered branches and adjusts numbering accordingly. Assisted-By: 🤖 Claude Code
1 parent ca4f930 commit 71e0b8f

File tree

3 files changed

+113
-2
lines changed

3 files changed

+113
-2
lines changed

scripts/bash/create-new-feature.sh

Lines changed: 27 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -139,6 +139,28 @@ get_highest_from_branches() {
139139
echo "$highest"
140140
}
141141

142+
# Function to get highest number from remote branches without fetching (side-effect-free)
143+
get_highest_from_remote_refs() {
144+
local highest=0
145+
146+
for remote in $(git remote 2>/dev/null); do
147+
while IFS= read -r line; do
148+
[ -z "$line" ] && continue
149+
# Extract ref name from ls-remote output (hash\trefs/heads/branch-name)
150+
ref="${line##*/}"
151+
if echo "$ref" | grep -Eq '^[0-9]{3,}-' && ! echo "$ref" | grep -Eq '^[0-9]{8}-[0-9]{6}-'; then
152+
number=$(echo "$ref" | grep -Eo '^[0-9]+' || echo "0")
153+
number=$((10#$number))
154+
if [ "$number" -gt "$highest" ]; then
155+
highest=$number
156+
fi
157+
fi
158+
done <<< "$(git ls-remote --heads "$remote" 2>/dev/null || echo "")"
159+
done
160+
161+
echo "$highest"
162+
}
163+
142164
# Function to check existing branches (local and remote) and return next available number
143165
check_existing_branches() {
144166
local specs_dir="$1"
@@ -259,10 +281,14 @@ else
259281
# Determine branch number
260282
if [ -z "$BRANCH_NUMBER" ]; then
261283
if [ "$DRY_RUN" = true ]; then
262-
# Dry-run: use locally available data only (skip git fetch)
284+
# Dry-run: query remote refs without fetching (side-effect-free)
263285
_highest_branch=0
264286
if [ "$HAS_GIT" = true ]; then
265287
_highest_branch=$(get_highest_from_branches)
288+
_highest_remote=$(get_highest_from_remote_refs)
289+
if [ "$_highest_remote" -gt "$_highest_branch" ]; then
290+
_highest_branch=$_highest_remote
291+
fi
266292
fi
267293
_highest_spec=$(get_highest_from_specs "$SPECS_DIR")
268294
_max_num=$_highest_branch

scripts/powershell/create-new-feature.ps1

Lines changed: 32 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -94,6 +94,35 @@ function Get-HighestNumberFromBranches {
9494
return $highest
9595
}
9696

97+
function Get-HighestNumberFromRemoteRefs {
98+
[long]$highest = 0
99+
try {
100+
$remotes = git remote 2>$null
101+
if ($remotes) {
102+
foreach ($remote in $remotes) {
103+
$refs = git ls-remote --heads $remote 2>$null
104+
if ($LASTEXITCODE -eq 0 -and $refs) {
105+
foreach ($line in $refs) {
106+
# Extract branch name from refs/heads/branch-name
107+
if ($line -match 'refs/heads/(.+)$') {
108+
$ref = $matches[1]
109+
if ($ref -match '^(\d{3,})-' -and $ref -notmatch '^\d{8}-\d{6}-') {
110+
[long]$num = 0
111+
if ([long]::TryParse($matches[1], [ref]$num) -and $num -gt $highest) {
112+
$highest = $num
113+
}
114+
}
115+
}
116+
}
117+
}
118+
}
119+
}
120+
} catch {
121+
Write-Verbose "Could not query remote refs: $_"
122+
}
123+
return $highest
124+
}
125+
97126
function Get-NextBranchNumber {
98127
param(
99128
[string]$SpecsDir
@@ -208,10 +237,12 @@ if ($Timestamp) {
208237
# Determine branch number
209238
if ($Number -eq 0) {
210239
if ($DryRun) {
211-
# Dry-run: use locally available data only (skip git fetch)
240+
# Dry-run: query remote refs without fetching (side-effect-free)
212241
$highestBranch = 0
213242
if ($hasGit) {
214243
$highestBranch = Get-HighestNumberFromBranches
244+
$highestRemote = Get-HighestNumberFromRemoteRefs
245+
$highestBranch = [Math]::Max($highestBranch, $highestRemote)
215246
}
216247
$highestSpec = Get-HighestNumberFromSpecs -SpecsDir $specsDir
217248
$Number = [Math]::Max($highestBranch, $highestSpec) + 1

tests/test_timestamp_branches.py

Lines changed: 54 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -510,6 +510,60 @@ def test_dry_run_then_real_run_match(self, git_repo: Path):
510510
real_branch = line.split(":", 1)[1].strip()
511511
assert dry_branch == real_branch, f"dry={dry_branch} != real={real_branch}"
512512

513+
def test_dry_run_accounts_for_remote_branches(self, git_repo: Path):
514+
"""Dry-run queries remote refs via ls-remote (no fetch) for accurate numbering."""
515+
(git_repo / "specs" / "001-existing").mkdir(parents=True)
516+
517+
# Set up a bare remote and push
518+
remote_dir = git_repo.parent / "remote.git"
519+
subprocess.run(
520+
["git", "init", "--bare", str(remote_dir)],
521+
check=True, capture_output=True,
522+
)
523+
subprocess.run(
524+
["git", "remote", "add", "origin", str(remote_dir)],
525+
check=True, cwd=git_repo, capture_output=True,
526+
)
527+
subprocess.run(
528+
["git", "push", "-u", "origin", "HEAD"],
529+
check=True, cwd=git_repo, capture_output=True,
530+
)
531+
532+
# Clone into a second copy, create a higher-numbered branch, push it
533+
second_clone = git_repo.parent / "second_clone"
534+
subprocess.run(
535+
["git", "clone", str(remote_dir), str(second_clone)],
536+
check=True, capture_output=True,
537+
)
538+
subprocess.run(
539+
["git", "config", "user.email", "test@example.com"],
540+
cwd=second_clone, check=True, capture_output=True,
541+
)
542+
subprocess.run(
543+
["git", "config", "user.name", "Test User"],
544+
cwd=second_clone, check=True, capture_output=True,
545+
)
546+
# Create branch 005 on the remote (higher than local 001)
547+
subprocess.run(
548+
["git", "checkout", "-b", "005-remote-only"],
549+
cwd=second_clone, check=True, capture_output=True,
550+
)
551+
subprocess.run(
552+
["git", "push", "origin", "005-remote-only"],
553+
cwd=second_clone, check=True, capture_output=True,
554+
)
555+
556+
# Primary repo: dry-run should see 005 via ls-remote and return 006
557+
dry_result = run_script(
558+
git_repo, "--dry-run", "--short-name", "remote-test", "Remote test"
559+
)
560+
assert dry_result.returncode == 0, dry_result.stderr
561+
dry_branch = None
562+
for line in dry_result.stdout.splitlines():
563+
if line.startswith("BRANCH_NAME:"):
564+
dry_branch = line.split(":", 1)[1].strip()
565+
assert dry_branch == "006-remote-test", f"expected 006-remote-test, got: {dry_branch}"
566+
513567
def test_dry_run_json_includes_field(self, git_repo: Path):
514568
"""T015: JSON output includes DRY_RUN field when --dry-run is active."""
515569
import json

0 commit comments

Comments
 (0)