Skip to content
Draft
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
2 changes: 2 additions & 0 deletions cmd/rebase_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -2273,6 +2273,7 @@ func setupAmendedParentRepo(t *testing.T, corruptBase bool) amendedParentRepo {
cloneDir := filepath.Join(t.TempDir(), "clone")

issue250Git(t, ".", "-c", "safe.bareRepository=all", "init", "--bare", "-b", "main", remoteDir)
issue250Git(t, ".", "-c", "safe.bareRepository=all", "-C", remoteDir, "config", "receive.advertisePushOptions", "true")
issue250Git(t, ".", "clone", remoteDir, cloneDir)
issue250Git(t, cloneDir, "config", "user.name", "Test")
issue250Git(t, cloneDir, "config", "user.email", "test@example.com")
Expand Down Expand Up @@ -2410,6 +2411,7 @@ func TestIntegration_AdoptedBranchRebasesFromCommonAncestor(t *testing.T) {
cloneDir := filepath.Join(t.TempDir(), "clone")

issue250Git(t, ".", "-c", "safe.bareRepository=all", "init", "--bare", "-b", "main", remoteDir)
issue250Git(t, ".", "-c", "safe.bareRepository=all", "-C", remoteDir, "config", "receive.advertisePushOptions", "true")
issue250Git(t, ".", "clone", remoteDir, cloneDir)
issue250Git(t, cloneDir, "config", "user.name", "Test")
issue250Git(t, cloneDir, "config", "user.email", "test@example.com")
Expand Down
10 changes: 9 additions & 1 deletion internal/git/gitops.go
Original file line number Diff line number Diff line change
Expand Up @@ -254,6 +254,7 @@ func (d *defaultOps) CreateBranch(name, base string) error {
return d.runSilent("branch", name, base)
}

// TODO send pull.stacked option on force push after rebase only?
func (d *defaultOps) Push(remote string, branches []string, force, atomic bool) error {
args := []string{"push", remote}
if force {
Expand Down Expand Up @@ -286,7 +287,14 @@ func (d *defaultOps) Push(remote string, branches []string, force, atomic bool)
for _, b := range branches {
args = append(args, fmt.Sprintf("refs/heads/%s:refs/heads/%s", b, b))
}
return d.runSilent(args...)
// Send the "pull.stacked" push option, falling back to a plain push for
// remotes that don't support push options.
withOpt := append([]string{"push", "-o", "pull.stacked"}, args[1:]...)
err := d.runSilent(withOpt...)
if err != nil && strings.Contains(err.Error(), "does not support push options") {
return d.runSilent(args...)
}
return err
}

// ResolveRemote determines the remote for pushing a branch. It checks git
Expand Down
63 changes: 63 additions & 0 deletions internal/git/gitops_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -63,6 +63,10 @@ func setupBareAndClone(t *testing.T) (string, string) {
out, err := cmd.CombinedOutput()
require.NoError(t, err, "git init --bare: %s", string(out))

cfgCmd := exec.Command("git", "-c", "safe.bareRepository=all", "-C", bareDir, "config", "receive.advertisePushOptions", "true")
out, err = cfgCmd.CombinedOutput()
require.NoError(t, err, "git config: %s", string(out))

cloneDir := filepath.Join(t.TempDir(), "clone")
gitExec(t, ".", "clone", bareDir, cloneDir)

Expand Down Expand Up @@ -1703,3 +1707,62 @@ func TestIntegration_WorktreeIgnoresCallerRepositoryEnvironment(t *testing.T) {
require.NoError(t, err)
assert.Equal(t, "feature", branch)
}

// Every push must send the "pull.stacked" push option, for both forced and atomic pushes.
// TODO send option on force push rebase only?
func TestIntegration_Push_SendsStackPushOption(t *testing.T) {
if runtime.GOOS == "windows" {
t.Skip("uses a shell pre-receive hook")
}

for _, tc := range []struct {
name string
force, atomic bool
}{
{"force", true, false},
{"atomic", false, true},
{"plain", false, false},
} {
t.Run(tc.name, func(t *testing.T) {
bareDir, cloneDir := setupBareAndClone(t)
restore := withGitDir(t, cloneDir)
defer restore()

optsFile := filepath.Join(t.TempDir(), "opts")
hook := fmt.Sprintf("#!/bin/sh\nenv | grep '^GIT_PUSH_OPTION' | sort > %q\n", optsFile)
require.NoError(t, os.WriteFile(filepath.Join(bareDir, "hooks", "pre-receive"), []byte(hook), 0o755))

gitExec(t, cloneDir, "checkout", "-b", "b1")
writeFile(t, cloneDir, "b1.txt", "v1")
gitExec(t, cloneDir, "add", ".")
gitExec(t, cloneDir, "commit", "-m", "b1")

d := &defaultOps{}
require.NoError(t, d.Push("origin", []string{"b1"}, tc.force, tc.atomic))

got, err := os.ReadFile(optsFile)
require.NoError(t, err)
assert.Equal(t, "GIT_PUSH_OPTION_0=pull.stacked\nGIT_PUSH_OPTION_COUNT=1\n", string(got))
})
}
}

// Remotes that don't advertise push options must still accept pushes.
func TestIntegration_Push_FallsBackWithoutPushOptionSupport(t *testing.T) {
bareDir, cloneDir := setupBareAndClone(t)
restore := withGitDir(t, cloneDir)
defer restore()

out, err := exec.Command("git", "-c", "safe.bareRepository=all", "-C", bareDir,
"config", "receive.advertisePushOptions", "false").CombinedOutput()
require.NoError(t, err, string(out))

gitExec(t, cloneDir, "checkout", "-b", "b1")
writeFile(t, cloneDir, "b1.txt", "v1")
gitExec(t, cloneDir, "add", ".")
gitExec(t, cloneDir, "commit", "-m", "b1")

d := &defaultOps{}
require.NoError(t, d.Push("origin", []string{"b1"}, true, true))
assert.Equal(t, gitExec(t, cloneDir, "rev-parse", "b1"), remoteBranchSHA(t, bareDir, "b1"))
}
Loading