Skip to content

Commit 7aea4ab

Browse files
committed
Fully-qualify branch refspecs when pushing
gh-stack builds `git push` arguments from stack branch names in `internal/git`. The force path passed `<branch>:refs/heads/<branch>`, and the non-force path passed bare branch names. A git refspec treats a leading `+` as "force update", and Git allows branch names that begin with `+`, so a branch named `+feature` was parsed as refspec syntax for `feature`: the force path pushed local `feature` into remote `+feature`, and the non-force path force-updated remote `feature`. Build fully-qualified refspecs for both the source and destination of every push: `refs/heads/<branch>:refs/heads/<branch>`. A branch name can no longer be reinterpreted as a refspec modifier. Force updates are still requested via the existing `--force-with-lease` flags, whose ref names were already fully-qualified. `DeleteRemoteBranch` is fully-qualified the same way. The `Push` signature and every call site are unchanged. Add real-git integration tests covering the force and non-force paths with a `+`-prefixed branch.
1 parent 231523d commit 7aea4ab

2 files changed

Lines changed: 107 additions & 4 deletions

File tree

internal/git/gitops.go

Lines changed: 13 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -188,12 +188,19 @@ func (d *defaultOps) Push(remote string, branches []string, force, atomic bool)
188188
args = append(args, "--atomic")
189189
}
190190
if force {
191-
// Explicit refspecs: <local-branch>:refs/heads/<remote-branch>.
191+
// Fully-qualified refspecs: refs/heads/<local>:refs/heads/<remote>.
192+
// Qualifying the source (not a bare branch name) ensures a branch
193+
// name is never reinterpreted as refspec syntax — e.g. a leading
194+
// "+" is part of the ref, not a force modifier. Force is supplied
195+
// out-of-band by the --force-with-lease flags above.
192196
for _, b := range branches {
193-
args = append(args, fmt.Sprintf("%s:refs/heads/%s", b, b))
197+
args = append(args, fmt.Sprintf("refs/heads/%s:refs/heads/%s", b, b))
194198
}
195199
} else {
196-
args = append(args, branches...)
200+
// Fully-qualified refspecs here too, for the same reason.
201+
for _, b := range branches {
202+
args = append(args, fmt.Sprintf("refs/heads/%s:refs/heads/%s", b, b))
203+
}
197204
}
198205
return runSilent(args...)
199206
}
@@ -544,7 +551,9 @@ func (d *defaultOps) DeleteBranch(name string, force bool) error {
544551
}
545552

546553
func (d *defaultOps) DeleteRemoteBranch(remote, branch string) error {
547-
return runSilent("push", remote, "--delete", branch)
554+
// Fully-qualify the ref so a branch name is never reinterpreted as
555+
// refspec syntax.
556+
return runSilent("push", remote, "--delete", "refs/heads/"+branch)
548557
}
549558

550559
func (d *defaultOps) DeleteTrackingRef(remote, branch string) error {

internal/git/gitops_test.go

Lines changed: 94 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -361,6 +361,100 @@ func TestIntegration_Push_MixedStack(t *testing.T) {
361361
assert.Equal(t, localB2, remoteBranchSHA(t, bareDir, "b2"))
362362
}
363363

364+
// A stack branch whose name begins with "+" must push its own ref, not a
365+
// similarly named sibling. A leading "+" in a git refspec means "force update",
366+
// so passing a bare "+feature" (or "+feature:...") lets git treat it as a
367+
// refspec modifier for "feature". Fully-qualified refspecs keep the "+" part of
368+
// the branch name. Force path (used by push/submit).
369+
func TestIntegration_Push_PlusPrefixedBranch_Force(t *testing.T) {
370+
bareDir, cloneDir := setupBareAndClone(t)
371+
restore := withGitDir(t, cloneDir)
372+
defer restore()
373+
374+
d := &defaultOps{}
375+
376+
// Create and push a normal "feature" branch (content A).
377+
gitExec(t, cloneDir, "checkout", "-b", "feature")
378+
writeFile(t, cloneDir, "feature.txt", "A")
379+
gitExec(t, cloneDir, "add", ".")
380+
gitExec(t, cloneDir, "commit", "-m", "feature A")
381+
gitExec(t, cloneDir, "push", "origin", "feature")
382+
remoteFeatureBefore := remoteBranchSHA(t, bareDir, "feature")
383+
384+
// Create a local "+feature" branch off main with different content (B).
385+
gitExec(t, cloneDir, "checkout", "main")
386+
gitExec(t, cloneDir, "checkout", "-b", "+feature")
387+
writeFile(t, cloneDir, "plus.txt", "B")
388+
gitExec(t, cloneDir, "add", ".")
389+
gitExec(t, cloneDir, "commit", "-m", "plus B")
390+
localPlus := gitExec(t, cloneDir, "rev-parse", "refs/heads/+feature")
391+
392+
// Advance local "feature" (content C) but do NOT push it. If the push
393+
// followed refspec syntax, "+feature" would push this ref instead.
394+
gitExec(t, cloneDir, "checkout", "feature")
395+
writeFile(t, cloneDir, "feature.txt", "C")
396+
gitExec(t, cloneDir, "add", ".")
397+
gitExec(t, cloneDir, "commit", "-m", "feature C")
398+
localFeature := gitExec(t, cloneDir, "rev-parse", "refs/heads/feature")
399+
require.NotEqual(t, localPlus, localFeature, "test setup: +feature and feature must differ")
400+
401+
// Push the "+feature" stack branch via the force path.
402+
gitExec(t, cloneDir, "checkout", "+feature")
403+
require.NoError(t, d.FetchBranches("origin", []string{"+feature"}))
404+
require.NoError(t, d.Push("origin", []string{"+feature"}, true, false))
405+
406+
// Remote "+feature" must point at local "+feature" (B), and remote
407+
// "feature" must be untouched (still A).
408+
assert.Equal(t, localPlus, remoteBranchSHA(t, bareDir, "+feature"),
409+
"remote +feature should hold the +feature commit, not feature's")
410+
assert.Equal(t, remoteFeatureBefore, remoteBranchSHA(t, bareDir, "feature"),
411+
"remote feature must not be force-updated")
412+
}
413+
414+
// Same as above for the non-force, atomic path (used by link and by sync when
415+
// no rebase happened). A bare "+feature" operand would force-update remote
416+
// "feature"; a fully-qualified refspec creates remote "+feature" instead.
417+
func TestIntegration_Push_PlusPrefixedBranch_NonForce(t *testing.T) {
418+
bareDir, cloneDir := setupBareAndClone(t)
419+
restore := withGitDir(t, cloneDir)
420+
defer restore()
421+
422+
d := &defaultOps{}
423+
424+
// Create and push a normal "feature" branch (content A).
425+
gitExec(t, cloneDir, "checkout", "-b", "feature")
426+
writeFile(t, cloneDir, "feature.txt", "A")
427+
gitExec(t, cloneDir, "add", ".")
428+
gitExec(t, cloneDir, "commit", "-m", "feature A")
429+
gitExec(t, cloneDir, "push", "origin", "feature")
430+
remoteFeatureBefore := remoteBranchSHA(t, bareDir, "feature")
431+
432+
// Create a local "+feature" branch off main with different content (B).
433+
gitExec(t, cloneDir, "checkout", "main")
434+
gitExec(t, cloneDir, "checkout", "-b", "+feature")
435+
writeFile(t, cloneDir, "plus.txt", "B")
436+
gitExec(t, cloneDir, "add", ".")
437+
gitExec(t, cloneDir, "commit", "-m", "plus B")
438+
localPlus := gitExec(t, cloneDir, "rev-parse", "refs/heads/+feature")
439+
440+
// Advance local "feature" (content C) but do NOT push it.
441+
gitExec(t, cloneDir, "checkout", "feature")
442+
writeFile(t, cloneDir, "feature.txt", "C")
443+
gitExec(t, cloneDir, "add", ".")
444+
gitExec(t, cloneDir, "commit", "-m", "feature C")
445+
446+
// Push "+feature" via the non-force, atomic path.
447+
gitExec(t, cloneDir, "checkout", "+feature")
448+
require.NoError(t, d.Push("origin", []string{"+feature"}, false, true))
449+
450+
// Remote "+feature" must be created from local "+feature" (B), and remote
451+
// "feature" must be untouched (still A).
452+
assert.Equal(t, localPlus, remoteBranchSHA(t, bareDir, "+feature"),
453+
"remote +feature should be created from the +feature commit")
454+
assert.Equal(t, remoteFeatureBefore, remoteBranchSHA(t, bareDir, "feature"),
455+
"remote feature must not be force-updated")
456+
}
457+
364458
func TestSplitCommitMessage(t *testing.T) {
365459
tests := []struct {
366460
name string

0 commit comments

Comments
 (0)