mirror of
https://github.com/github/gh-stack.git
synced 2026-09-14 20:26:28 +08:00
0c51c08255
* Add cherry-pick abort/quit/in-progress git primitives Introduce IsCherryPickInProgress() (detects .git/CHERRY_PICK_HEAD) and split the existing cherry-pick reset into two distinct operations: - CherryPickQuit() runs `git cherry-pick --quit`, clearing the sequencer state without touching the index (used to clear stale state before starting a fresh cherry-pick). - CherryPickAbort() now runs `git cherry-pick --abort`, which fully restores the working tree and index to the pre-cherry-pick state. The previous CherryPickAbort() ran --quit, which leaves an unmerged index and therefore cannot recover a conflicted fold-down. Integration tests cover the in-progress detection, the full abort restore, and the --quit-leaves-index behavior. * Fix modify --abort leaving a broken stack after a conflict When `gh stack modify` hit a rebase or cherry-pick conflict it saved state with phase "conflict" and told the user to run `gh stack modify --abort` to restore. But runModifyAbort had no case for PhaseConflict, so it fell into the default branch that merely printed "unexpected modify state phase" and deleted the state file without unwinding. The in-flight rebase/cherry-pick stayed active, branches were left partially rewritten, and the deleted state file also made --continue impossible: the stack was stuck in limbo. Fixes: - runModifyAbort now unwinds on PhaseConflict (same recovery as PhaseApplying), aborting the in-progress operation, resetting branch tips to their pre-modify SHAs, restoring stack metadata, and clearing state. - Unwind now also aborts an in-progress cherry-pick (fold-down conflicts), not just a rebase. Without this the restore checkouts would fail on the unmerged cherry-pick index. - ContinueApply now records a subsequent cascade-rebase conflict as ConflictType "rebase" instead of leaving a stale "cherry_pick", so the next --continue calls RebaseContinue rather than failing in CherryPickContinue. Adds coverage for the conflict-phase abort, pending-submit no-op abort, Unwind aborting an active cherry-pick, and the cherry-pick to rebase ConflictType transition. * Persist fold-branch removal when a post-fold cascade rebase conflicts ContinueApply removes the folded branch from the in-memory stack after a fold-down cherry-pick is resolved, but a subsequent cascade rebase conflict only saved the modify state file, not the stack metadata. On the next --continue the on-disk metadata (folded branch still present) was re-read, and because ConflictType is now "rebase" the fold-removal block was skipped, so the final save resurrected the folded branch as a phantom entry pointing at an orphaned tip. Persist the stack file alongside the state file on a cascade-rebase conflict, mirroring ApplyPlan's save-on-conflict, so the fold removal survives recovery. Adds an end-to-end regression test covering the fold-then-cascade-conflict path across two --continue calls.
670 lines
24 KiB
Go
670 lines
24 KiB
Go
package git
|
|
|
|
import (
|
|
"os"
|
|
"os/exec"
|
|
"path/filepath"
|
|
"strings"
|
|
"testing"
|
|
|
|
"github.com/stretchr/testify/assert"
|
|
"github.com/stretchr/testify/require"
|
|
)
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Helpers for integration tests that use real git repos
|
|
// ---------------------------------------------------------------------------
|
|
|
|
// gitExec runs a git command in the given directory and returns trimmed stdout.
|
|
func gitExec(t *testing.T, dir string, args ...string) string {
|
|
t.Helper()
|
|
cmd := exec.Command("git", args...)
|
|
cmd.Dir = dir
|
|
cmd.Env = append(os.Environ(),
|
|
"GIT_AUTHOR_NAME=Test",
|
|
"GIT_AUTHOR_EMAIL=test@test.com",
|
|
"GIT_COMMITTER_NAME=Test",
|
|
"GIT_COMMITTER_EMAIL=test@test.com",
|
|
)
|
|
out, err := cmd.CombinedOutput()
|
|
require.NoError(t, err, "git %s in %s:\n%s", strings.Join(args, " "), dir, string(out))
|
|
return strings.TrimSpace(string(out))
|
|
}
|
|
|
|
// gitExecMayFail runs a git command allowing failure. Returns stdout and error.
|
|
func gitExecMayFail(t *testing.T, dir string, args ...string) (string, error) {
|
|
t.Helper()
|
|
cmd := exec.Command("git", args...)
|
|
cmd.Dir = dir
|
|
cmd.Env = append(os.Environ(),
|
|
"GIT_AUTHOR_NAME=Test",
|
|
"GIT_AUTHOR_EMAIL=test@test.com",
|
|
"GIT_COMMITTER_NAME=Test",
|
|
"GIT_COMMITTER_EMAIL=test@test.com",
|
|
)
|
|
out, err := cmd.CombinedOutput()
|
|
return strings.TrimSpace(string(out)), err
|
|
}
|
|
|
|
// setupBareAndClone creates a bare repo, clones it, makes an initial commit on
|
|
// main, and pushes. Returns (bareDir, cloneDir).
|
|
func setupBareAndClone(t *testing.T) (string, string) {
|
|
t.Helper()
|
|
bareDir := filepath.Join(t.TempDir(), "bare.git")
|
|
|
|
// Use -c safe.bareRepository=all so git init --bare works in temp dirs.
|
|
// Use -b main to ensure a consistent default branch name across environments.
|
|
cmd := exec.Command("git", "-c", "safe.bareRepository=all", "init", "--bare", "-b", "main", bareDir)
|
|
out, err := cmd.CombinedOutput()
|
|
require.NoError(t, err, "git init --bare: %s", string(out))
|
|
|
|
cloneDir := filepath.Join(t.TempDir(), "clone")
|
|
gitExec(t, ".", "clone", bareDir, cloneDir)
|
|
|
|
// Initial commit so main exists.
|
|
writeFile(t, cloneDir, "init.txt", "hello")
|
|
gitExec(t, cloneDir, "add", ".")
|
|
gitExec(t, cloneDir, "commit", "-m", "initial commit")
|
|
gitExec(t, cloneDir, "push", "origin", "main")
|
|
|
|
return bareDir, cloneDir
|
|
}
|
|
|
|
// writeFile creates or overwrites a file in dir.
|
|
func writeFile(t *testing.T, dir, name, content string) {
|
|
t.Helper()
|
|
require.NoError(t, os.WriteFile(filepath.Join(dir, name), []byte(content), 0644))
|
|
}
|
|
|
|
// withGitDir temporarily sets the git client to operate in dir by changing
|
|
// the working directory. Returns a restore function.
|
|
func withGitDir(t *testing.T, dir string) func() {
|
|
t.Helper()
|
|
old, err := os.Getwd()
|
|
require.NoError(t, err)
|
|
require.NoError(t, os.Chdir(dir))
|
|
return func() { _ = os.Chdir(old) }
|
|
}
|
|
|
|
// remoteBranchSHA returns the SHA of a branch on the bare remote.
|
|
func remoteBranchSHA(t *testing.T, bareDir, branch string) string {
|
|
t.Helper()
|
|
cmd := exec.Command("git", "-C", bareDir, "-c", "safe.bareRepository=all",
|
|
"rev-parse", "refs/heads/"+branch)
|
|
out, err := cmd.CombinedOutput()
|
|
require.NoError(t, err, "rev-parse in bare repo %s:\n%s", bareDir, string(out))
|
|
return strings.TrimSpace(string(out))
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Integration tests for FetchBranches + Push (force-with-lease)
|
|
// ---------------------------------------------------------------------------
|
|
|
|
// Test 1: Branch exists remotely with a current tracking ref.
|
|
// Push should succeed and update the remote.
|
|
func TestIntegration_Push_ExistingBranchCurrentTrackingRef(t *testing.T) {
|
|
bareDir, cloneDir := setupBareAndClone(t)
|
|
restore := withGitDir(t, cloneDir)
|
|
defer restore()
|
|
|
|
d := &defaultOps{}
|
|
|
|
// Create a branch, push it, then make a new commit.
|
|
gitExec(t, cloneDir, "checkout", "-b", "b1")
|
|
writeFile(t, cloneDir, "b1.txt", "v1")
|
|
gitExec(t, cloneDir, "add", ".")
|
|
gitExec(t, cloneDir, "commit", "-m", "b1 initial")
|
|
gitExec(t, cloneDir, "push", "origin", "b1")
|
|
|
|
// Make a local commit (simulating rebase).
|
|
writeFile(t, cloneDir, "b1.txt", "v2")
|
|
gitExec(t, cloneDir, "add", ".")
|
|
gitExec(t, cloneDir, "commit", "-m", "b1 updated")
|
|
|
|
localSHA := gitExec(t, cloneDir, "rev-parse", "b1")
|
|
|
|
// FetchBranches should update tracking ref.
|
|
err := d.FetchBranches("origin", []string{"b1"})
|
|
require.NoError(t, err)
|
|
|
|
// Push with force-with-lease should succeed.
|
|
err = d.Push("origin", []string{"b1"}, true, false)
|
|
require.NoError(t, err)
|
|
|
|
// Verify remote was updated.
|
|
remoteSHA := remoteBranchSHA(t, bareDir, "b1")
|
|
assert.Equal(t, localSHA, remoteSHA)
|
|
}
|
|
|
|
// Test 2: Branch exists remotely but tracking ref was deleted locally.
|
|
// This is the regression test for https://github.com/github/gh-stack/issues/118.
|
|
func TestIntegration_Push_TrackingRefDeletedLocally(t *testing.T) {
|
|
bareDir, cloneDir := setupBareAndClone(t)
|
|
restore := withGitDir(t, cloneDir)
|
|
defer restore()
|
|
|
|
d := &defaultOps{}
|
|
|
|
// Create a branch and push it.
|
|
gitExec(t, cloneDir, "checkout", "-b", "b1")
|
|
writeFile(t, cloneDir, "b1.txt", "v1")
|
|
gitExec(t, cloneDir, "add", ".")
|
|
gitExec(t, cloneDir, "commit", "-m", "b1 initial")
|
|
gitExec(t, cloneDir, "push", "origin", "b1")
|
|
|
|
// Delete the local tracking ref to simulate the bug condition.
|
|
gitExec(t, cloneDir, "branch", "-dr", "origin/b1")
|
|
|
|
// Verify tracking ref is gone.
|
|
_, err := gitExecMayFail(t, cloneDir, "rev-parse", "--verify", "--quiet", "refs/remotes/origin/b1")
|
|
require.Error(t, err, "tracking ref should be deleted")
|
|
|
|
// Make a local commit (simulating rebase).
|
|
writeFile(t, cloneDir, "b1.txt", "v2")
|
|
gitExec(t, cloneDir, "add", ".")
|
|
gitExec(t, cloneDir, "commit", "-m", "b1 rebased")
|
|
|
|
localSHA := gitExec(t, cloneDir, "rev-parse", "b1")
|
|
|
|
// FetchBranches should recreate the tracking ref.
|
|
err = d.FetchBranches("origin", []string{"b1"})
|
|
require.NoError(t, err)
|
|
|
|
// Verify tracking ref was recreated.
|
|
_, err = gitExecMayFail(t, cloneDir, "rev-parse", "--verify", "--quiet", "refs/remotes/origin/b1")
|
|
require.NoError(t, err, "tracking ref should be recreated by FetchBranches")
|
|
|
|
// Push with force-with-lease should succeed.
|
|
err = d.Push("origin", []string{"b1"}, true, false)
|
|
require.NoError(t, err)
|
|
|
|
// Verify remote was updated.
|
|
remoteSHA := remoteBranchSHA(t, bareDir, "b1")
|
|
assert.Equal(t, localSHA, remoteSHA)
|
|
}
|
|
|
|
// Test 3: Branch advanced on remote by another client.
|
|
// Push should be rejected (lease protects the other commit).
|
|
func TestIntegration_Push_RemoteAdvancedByOther(t *testing.T) {
|
|
bareDir, cloneDir := setupBareAndClone(t)
|
|
restore := withGitDir(t, cloneDir)
|
|
defer restore()
|
|
|
|
d := &defaultOps{}
|
|
|
|
// Create a branch and push it.
|
|
gitExec(t, cloneDir, "checkout", "-b", "b1")
|
|
writeFile(t, cloneDir, "b1.txt", "v1")
|
|
gitExec(t, cloneDir, "add", ".")
|
|
gitExec(t, cloneDir, "commit", "-m", "b1 initial")
|
|
gitExec(t, cloneDir, "push", "origin", "b1")
|
|
|
|
// Simulate another client advancing the branch on the remote.
|
|
otherClone := filepath.Join(t.TempDir(), "other")
|
|
gitExec(t, ".", "clone", bareDir, otherClone)
|
|
gitExec(t, otherClone, "checkout", "b1")
|
|
writeFile(t, otherClone, "b1.txt", "v-other")
|
|
gitExec(t, otherClone, "add", ".")
|
|
gitExec(t, otherClone, "commit", "-m", "other update")
|
|
gitExec(t, otherClone, "push", "origin", "b1")
|
|
|
|
// Record the other client's SHA on the remote.
|
|
otherSHA := remoteBranchSHA(t, bareDir, "b1")
|
|
|
|
// Local client makes a different commit (simulating rebase).
|
|
writeFile(t, cloneDir, "b1.txt", "v-local")
|
|
gitExec(t, cloneDir, "add", ".")
|
|
gitExec(t, cloneDir, "commit", "-m", "b1 local update")
|
|
|
|
// FetchBranches — this updates tracking ref to the other client's SHA.
|
|
err := d.FetchBranches("origin", []string{"b1"})
|
|
require.NoError(t, err)
|
|
|
|
// But wait: after fetch, our tracking ref now matches the remote.
|
|
// The push should succeed because the lease matches. To truly test
|
|
// the "someone pushed after our fetch" scenario, we need to advance
|
|
// the remote AFTER the fetch.
|
|
//
|
|
// Advance remote again after our fetch.
|
|
writeFile(t, otherClone, "b1.txt", "v-other-2")
|
|
gitExec(t, otherClone, "add", ".")
|
|
gitExec(t, otherClone, "commit", "-m", "other update 2")
|
|
gitExec(t, otherClone, "push", "origin", "b1", "--force")
|
|
|
|
// Now remote is ahead of our tracking ref — push should fail.
|
|
err = d.Push("origin", []string{"b1"}, true, false)
|
|
require.Error(t, err, "push should be rejected when remote was advanced after fetch")
|
|
|
|
// Confirm no overwrite: remote still has the other client's latest SHA.
|
|
finalRemoteSHA := remoteBranchSHA(t, bareDir, "b1")
|
|
assert.NotEqual(t, otherSHA, finalRemoteSHA, "remote should have advanced past original other SHA")
|
|
}
|
|
|
|
// Test 4: Brand-new branch, absent on remote.
|
|
// Push should create the branch via empty-expect lease.
|
|
func TestIntegration_Push_NewBranchAbsentOnRemote(t *testing.T) {
|
|
bareDir, cloneDir := setupBareAndClone(t)
|
|
restore := withGitDir(t, cloneDir)
|
|
defer restore()
|
|
|
|
d := &defaultOps{}
|
|
|
|
// Create a new branch locally, do NOT push it.
|
|
gitExec(t, cloneDir, "checkout", "-b", "b1")
|
|
writeFile(t, cloneDir, "b1.txt", "v1")
|
|
gitExec(t, cloneDir, "add", ".")
|
|
gitExec(t, cloneDir, "commit", "-m", "b1 new")
|
|
|
|
localSHA := gitExec(t, cloneDir, "rev-parse", "b1")
|
|
|
|
// FetchBranches — branch doesn't exist on remote, should tolerate the error.
|
|
err := d.FetchBranches("origin", []string{"b1"})
|
|
require.NoError(t, err)
|
|
|
|
// No tracking ref should exist (branch is absent remotely).
|
|
_, err = gitExecMayFail(t, cloneDir, "rev-parse", "--verify", "--quiet", "refs/remotes/origin/b1")
|
|
require.Error(t, err, "tracking ref should not exist for branch absent on remote")
|
|
|
|
// Push should create the branch on remote.
|
|
err = d.Push("origin", []string{"b1"}, true, false)
|
|
require.NoError(t, err)
|
|
|
|
// Verify remote has the branch.
|
|
remoteSHA := remoteBranchSHA(t, bareDir, "b1")
|
|
assert.Equal(t, localSHA, remoteSHA)
|
|
}
|
|
|
|
// Test 5: Brand-new branch that another client created first (race).
|
|
// Push should be rejected because empty-expect means "must not exist."
|
|
func TestIntegration_Push_NewBranchRaceCondition(t *testing.T) {
|
|
bareDir, cloneDir := setupBareAndClone(t)
|
|
restore := withGitDir(t, cloneDir)
|
|
defer restore()
|
|
|
|
d := &defaultOps{}
|
|
|
|
// Create branch locally but don't push.
|
|
gitExec(t, cloneDir, "checkout", "-b", "b1")
|
|
writeFile(t, cloneDir, "b1.txt", "v1")
|
|
gitExec(t, cloneDir, "add", ".")
|
|
gitExec(t, cloneDir, "commit", "-m", "b1 new")
|
|
|
|
// FetchBranches — branch doesn't exist on remote yet.
|
|
err := d.FetchBranches("origin", []string{"b1"})
|
|
require.NoError(t, err)
|
|
|
|
// Another client creates the same branch on the remote.
|
|
otherClone := filepath.Join(t.TempDir(), "other")
|
|
gitExec(t, ".", "clone", bareDir, otherClone)
|
|
gitExec(t, otherClone, "checkout", "-b", "b1")
|
|
writeFile(t, otherClone, "b1.txt", "v-other")
|
|
gitExec(t, otherClone, "add", ".")
|
|
gitExec(t, otherClone, "commit", "-m", "other b1")
|
|
gitExec(t, otherClone, "push", "origin", "b1")
|
|
|
|
// Now our push should fail because the branch exists on remote
|
|
// but we have an empty-expect lease.
|
|
err = d.Push("origin", []string{"b1"}, true, false)
|
|
require.Error(t, err, "push should be rejected when branch was created by another client")
|
|
}
|
|
|
|
// Test 6: Mixed stack — one branch with current tracking ref + one with
|
|
// deleted tracking ref. Both should push successfully after FetchBranches fix.
|
|
func TestIntegration_Push_MixedStack(t *testing.T) {
|
|
bareDir, cloneDir := setupBareAndClone(t)
|
|
restore := withGitDir(t, cloneDir)
|
|
defer restore()
|
|
|
|
d := &defaultOps{}
|
|
|
|
// Create and push b1.
|
|
gitExec(t, cloneDir, "checkout", "-b", "b1")
|
|
writeFile(t, cloneDir, "b1.txt", "v1")
|
|
gitExec(t, cloneDir, "add", ".")
|
|
gitExec(t, cloneDir, "commit", "-m", "b1 initial")
|
|
gitExec(t, cloneDir, "push", "origin", "b1")
|
|
|
|
// Create and push b2.
|
|
gitExec(t, cloneDir, "checkout", "-b", "b2")
|
|
writeFile(t, cloneDir, "b2.txt", "v1")
|
|
gitExec(t, cloneDir, "add", ".")
|
|
gitExec(t, cloneDir, "commit", "-m", "b2 initial")
|
|
gitExec(t, cloneDir, "push", "origin", "b2")
|
|
|
|
// Delete tracking ref for b2 only (simulating the bug for one branch).
|
|
gitExec(t, cloneDir, "branch", "-dr", "origin/b2")
|
|
|
|
// Simulate rebase: update both branches.
|
|
gitExec(t, cloneDir, "checkout", "b1")
|
|
writeFile(t, cloneDir, "b1.txt", "v2")
|
|
gitExec(t, cloneDir, "add", ".")
|
|
gitExec(t, cloneDir, "commit", "-m", "b1 rebased")
|
|
|
|
gitExec(t, cloneDir, "checkout", "b2")
|
|
writeFile(t, cloneDir, "b2.txt", "v2")
|
|
gitExec(t, cloneDir, "add", ".")
|
|
gitExec(t, cloneDir, "commit", "-m", "b2 rebased")
|
|
|
|
localB1 := gitExec(t, cloneDir, "rev-parse", "b1")
|
|
localB2 := gitExec(t, cloneDir, "rev-parse", "b2")
|
|
|
|
// FetchBranches should handle both: b1 has tracking ref, b2 does not.
|
|
err := d.FetchBranches("origin", []string{"b1", "b2"})
|
|
require.NoError(t, err)
|
|
|
|
// Push both branches with force-with-lease.
|
|
err = d.Push("origin", []string{"b1", "b2"}, true, false)
|
|
require.NoError(t, err)
|
|
|
|
// Verify both were updated on remote.
|
|
assert.Equal(t, localB1, remoteBranchSHA(t, bareDir, "b1"))
|
|
assert.Equal(t, localB2, remoteBranchSHA(t, bareDir, "b2"))
|
|
}
|
|
|
|
// A stack branch whose name begins with "+" must push its own ref, not a
|
|
// similarly named sibling. A leading "+" in a git refspec means "force update",
|
|
// so passing a bare "+feature" (or "+feature:...") lets git treat it as a
|
|
// refspec modifier for "feature". Fully-qualified refspecs keep the "+" part of
|
|
// the branch name. Force path (used by push/submit).
|
|
func TestIntegration_Push_PlusPrefixedBranch_Force(t *testing.T) {
|
|
bareDir, cloneDir := setupBareAndClone(t)
|
|
restore := withGitDir(t, cloneDir)
|
|
defer restore()
|
|
|
|
d := &defaultOps{}
|
|
|
|
// Create and push a normal "feature" branch (content A).
|
|
gitExec(t, cloneDir, "checkout", "-b", "feature")
|
|
writeFile(t, cloneDir, "feature.txt", "A")
|
|
gitExec(t, cloneDir, "add", ".")
|
|
gitExec(t, cloneDir, "commit", "-m", "feature A")
|
|
gitExec(t, cloneDir, "push", "origin", "feature")
|
|
remoteFeatureBefore := remoteBranchSHA(t, bareDir, "feature")
|
|
|
|
// Create a local "+feature" branch off main with different content (B).
|
|
gitExec(t, cloneDir, "checkout", "main")
|
|
gitExec(t, cloneDir, "checkout", "-b", "+feature")
|
|
writeFile(t, cloneDir, "plus.txt", "B")
|
|
gitExec(t, cloneDir, "add", ".")
|
|
gitExec(t, cloneDir, "commit", "-m", "plus B")
|
|
localPlus := gitExec(t, cloneDir, "rev-parse", "refs/heads/+feature")
|
|
|
|
// Advance local "feature" (content C) but do NOT push it. If the push
|
|
// followed refspec syntax, "+feature" would push this ref instead.
|
|
gitExec(t, cloneDir, "checkout", "feature")
|
|
writeFile(t, cloneDir, "feature.txt", "C")
|
|
gitExec(t, cloneDir, "add", ".")
|
|
gitExec(t, cloneDir, "commit", "-m", "feature C")
|
|
localFeature := gitExec(t, cloneDir, "rev-parse", "refs/heads/feature")
|
|
require.NotEqual(t, localPlus, localFeature, "test setup: +feature and feature must differ")
|
|
|
|
// Push the "+feature" stack branch via the force path.
|
|
gitExec(t, cloneDir, "checkout", "+feature")
|
|
require.NoError(t, d.FetchBranches("origin", []string{"+feature"}))
|
|
require.NoError(t, d.Push("origin", []string{"+feature"}, true, false))
|
|
|
|
// Remote "+feature" must point at local "+feature" (B), and remote
|
|
// "feature" must be untouched (still A).
|
|
assert.Equal(t, localPlus, remoteBranchSHA(t, bareDir, "+feature"),
|
|
"remote +feature should hold the +feature commit, not feature's")
|
|
assert.Equal(t, remoteFeatureBefore, remoteBranchSHA(t, bareDir, "feature"),
|
|
"remote feature must not be force-updated")
|
|
}
|
|
|
|
// Same as above for the non-force, atomic path (used by link and by sync when
|
|
// no rebase happened). A bare "+feature" operand would force-update remote
|
|
// "feature"; a fully-qualified refspec creates remote "+feature" instead.
|
|
func TestIntegration_Push_PlusPrefixedBranch_NonForce(t *testing.T) {
|
|
bareDir, cloneDir := setupBareAndClone(t)
|
|
restore := withGitDir(t, cloneDir)
|
|
defer restore()
|
|
|
|
d := &defaultOps{}
|
|
|
|
// Create and push a normal "feature" branch (content A).
|
|
gitExec(t, cloneDir, "checkout", "-b", "feature")
|
|
writeFile(t, cloneDir, "feature.txt", "A")
|
|
gitExec(t, cloneDir, "add", ".")
|
|
gitExec(t, cloneDir, "commit", "-m", "feature A")
|
|
gitExec(t, cloneDir, "push", "origin", "feature")
|
|
remoteFeatureBefore := remoteBranchSHA(t, bareDir, "feature")
|
|
|
|
// Create a local "+feature" branch off main with different content (B).
|
|
gitExec(t, cloneDir, "checkout", "main")
|
|
gitExec(t, cloneDir, "checkout", "-b", "+feature")
|
|
writeFile(t, cloneDir, "plus.txt", "B")
|
|
gitExec(t, cloneDir, "add", ".")
|
|
gitExec(t, cloneDir, "commit", "-m", "plus B")
|
|
localPlus := gitExec(t, cloneDir, "rev-parse", "refs/heads/+feature")
|
|
|
|
// Advance local "feature" (content C) but do NOT push it.
|
|
gitExec(t, cloneDir, "checkout", "feature")
|
|
writeFile(t, cloneDir, "feature.txt", "C")
|
|
gitExec(t, cloneDir, "add", ".")
|
|
gitExec(t, cloneDir, "commit", "-m", "feature C")
|
|
|
|
// Push "+feature" via the non-force, atomic path.
|
|
gitExec(t, cloneDir, "checkout", "+feature")
|
|
require.NoError(t, d.Push("origin", []string{"+feature"}, false, true))
|
|
|
|
// Remote "+feature" must be created from local "+feature" (B), and remote
|
|
// "feature" must be untouched (still A).
|
|
assert.Equal(t, localPlus, remoteBranchSHA(t, bareDir, "+feature"),
|
|
"remote +feature should be created from the +feature commit")
|
|
assert.Equal(t, remoteFeatureBefore, remoteBranchSHA(t, bareDir, "feature"),
|
|
"remote feature must not be force-updated")
|
|
}
|
|
|
|
func TestSplitCommitMessage(t *testing.T) {
|
|
tests := []struct {
|
|
name string
|
|
msg string
|
|
wantSubject string
|
|
wantBody string
|
|
}{
|
|
{
|
|
name: "single line",
|
|
msg: "Fix the bug",
|
|
wantSubject: "Fix the bug",
|
|
wantBody: "",
|
|
},
|
|
{
|
|
name: "subject and body with blank separator",
|
|
msg: "Fix the bug\n\nMore details about the fix.",
|
|
wantSubject: "Fix the bug",
|
|
wantBody: "More details about the fix.",
|
|
},
|
|
{
|
|
name: "multi-line without blank separator",
|
|
msg: "Fix the bug\nMore details\nEven more",
|
|
wantSubject: "Fix the bug",
|
|
wantBody: "More details\nEven more",
|
|
},
|
|
{
|
|
name: "body with leading and trailing blank lines trimmed",
|
|
msg: "Fix the bug\n\n\nSome body text\n\n",
|
|
wantSubject: "Fix the bug",
|
|
wantBody: "Some body text",
|
|
},
|
|
{
|
|
name: "whitespace-only body",
|
|
msg: "Fix the bug\n\n \n\n",
|
|
wantSubject: "Fix the bug",
|
|
wantBody: "",
|
|
},
|
|
{
|
|
name: "leading whitespace on message trimmed",
|
|
msg: "\n Fix the bug\n\nBody here",
|
|
wantSubject: "Fix the bug",
|
|
wantBody: "Body here",
|
|
},
|
|
}
|
|
|
|
for _, tt := range tests {
|
|
t.Run(tt.name, func(t *testing.T) {
|
|
subject, body := splitCommitMessage(tt.msg)
|
|
assert.Equal(t, tt.wantSubject, subject)
|
|
assert.Equal(t, tt.wantBody, body)
|
|
})
|
|
}
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Integration tests for saved remote (gh-stack.remote)
|
|
// ---------------------------------------------------------------------------
|
|
|
|
func TestIntegration_ResolveRemote_UsesSavedRemote(t *testing.T) {
|
|
_, cloneDir := setupBareAndClone(t)
|
|
restoreDir := withGitDir(t, cloneDir)
|
|
defer restoreDir()
|
|
|
|
// Create a branch without upstream tracking.
|
|
gitExec(t, cloneDir, "checkout", "-b", "feature")
|
|
|
|
// Add a second remote so there are multiple.
|
|
gitExec(t, cloneDir, "remote", "add", "upstream", cloneDir)
|
|
|
|
// Without saved remote, multiple remotes should return ErrMultipleRemotes.
|
|
_, err := ResolveRemote("feature")
|
|
var multi *ErrMultipleRemotes
|
|
require.ErrorAs(t, err, &multi)
|
|
|
|
// Save a remote preference.
|
|
gitExec(t, cloneDir, "config", "gh-stack.remote", "upstream")
|
|
|
|
// Now ResolveRemote should return the saved remote.
|
|
remote, err := ResolveRemote("feature")
|
|
require.NoError(t, err)
|
|
assert.Equal(t, "upstream", remote)
|
|
}
|
|
|
|
func TestIntegration_ResolveRemote_GitPushConfigTakesPrecedence(t *testing.T) {
|
|
_, cloneDir := setupBareAndClone(t)
|
|
restoreDir := withGitDir(t, cloneDir)
|
|
defer restoreDir()
|
|
|
|
// Add a second remote.
|
|
gitExec(t, cloneDir, "remote", "add", "upstream", cloneDir)
|
|
|
|
// Save gh-stack.remote to "upstream".
|
|
gitExec(t, cloneDir, "config", "gh-stack.remote", "upstream")
|
|
|
|
// Set standard git push config to "origin" — this should take precedence.
|
|
gitExec(t, cloneDir, "config", "remote.pushDefault", "origin")
|
|
|
|
remote, err := ResolveRemote("main")
|
|
require.NoError(t, err)
|
|
assert.Equal(t, "origin", remote)
|
|
}
|
|
|
|
func TestIntegration_SaveAndGetRemote(t *testing.T) {
|
|
_, cloneDir := setupBareAndClone(t)
|
|
restoreDir := withGitDir(t, cloneDir)
|
|
defer restoreDir()
|
|
|
|
// Initially no saved remote.
|
|
_, err := GetSavedRemote()
|
|
require.Error(t, err)
|
|
|
|
// Save a remote.
|
|
require.NoError(t, SaveRemote("upstream"))
|
|
|
|
// Should be retrievable.
|
|
saved, err := GetSavedRemote()
|
|
require.NoError(t, err)
|
|
assert.Equal(t, "upstream", saved)
|
|
|
|
// Clear it.
|
|
require.NoError(t, ClearRemote())
|
|
|
|
// Should be gone.
|
|
_, err = GetSavedRemote()
|
|
require.Error(t, err)
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Integration tests for cherry-pick in-progress detection and abort
|
|
// ---------------------------------------------------------------------------
|
|
|
|
// A conflicting cherry-pick must be detected as in-progress, and CherryPickAbort
|
|
// must fully restore the working tree/index so branch checkouts succeed again.
|
|
// This underpins modify's --abort recovery for fold-down (cherry-pick) conflicts.
|
|
func TestIntegration_CherryPickInProgressAndAbort(t *testing.T) {
|
|
_, cloneDir := setupBareAndClone(t)
|
|
restore := withGitDir(t, cloneDir)
|
|
defer restore()
|
|
|
|
// Ensure runSilent-based git commands have a committer identity.
|
|
gitExec(t, cloneDir, "config", "user.name", "Test")
|
|
gitExec(t, cloneDir, "config", "user.email", "test@test.com")
|
|
|
|
// feature edits conflict.txt one way; main edits it another way.
|
|
gitExec(t, cloneDir, "checkout", "-b", "feature")
|
|
writeFile(t, cloneDir, "conflict.txt", "feature change\n")
|
|
gitExec(t, cloneDir, "add", ".")
|
|
gitExec(t, cloneDir, "commit", "-m", "feature edit")
|
|
featureSHA := gitExec(t, cloneDir, "rev-parse", "feature")
|
|
|
|
gitExec(t, cloneDir, "checkout", "main")
|
|
writeFile(t, cloneDir, "conflict.txt", "main change\n")
|
|
gitExec(t, cloneDir, "add", ".")
|
|
gitExec(t, cloneDir, "commit", "-m", "main edit")
|
|
|
|
// No cherry-pick in progress before we start.
|
|
assert.False(t, IsCherryPickInProgress(), "no cherry-pick should be in progress initially")
|
|
|
|
// Cherry-picking feature onto main conflicts.
|
|
err := CherryPick([]string{featureSHA})
|
|
require.Error(t, err, "cherry-pick should conflict")
|
|
assert.True(t, IsCherryPickInProgress(), "cherry-pick should be in progress after a conflict")
|
|
|
|
// While mid-conflict, a plain checkout must fail (unmerged index).
|
|
_, coErr := gitExecMayFail(t, cloneDir, "checkout", "feature")
|
|
require.Error(t, coErr, "checkout should fail while cherry-pick index is unmerged")
|
|
|
|
// Aborting must fully restore: no longer in progress, clean tree, checkout works.
|
|
require.NoError(t, CherryPickAbort())
|
|
assert.False(t, IsCherryPickInProgress(), "cherry-pick should not be in progress after abort")
|
|
|
|
status, err := gitExecMayFail(t, cloneDir, "status", "--porcelain")
|
|
require.NoError(t, err)
|
|
assert.Empty(t, status, "working tree should be clean after abort")
|
|
|
|
_, coErr = gitExecMayFail(t, cloneDir, "checkout", "feature")
|
|
require.NoError(t, coErr, "checkout should succeed after abort restores a clean index")
|
|
}
|
|
|
|
// CherryPickQuit clears the sequencer state but intentionally leaves the index
|
|
// as-is, so a plain checkout still fails. This documents why Unwind uses the
|
|
// full --abort rather than --quit.
|
|
func TestIntegration_CherryPickQuitLeavesIndexUnmerged(t *testing.T) {
|
|
_, cloneDir := setupBareAndClone(t)
|
|
restore := withGitDir(t, cloneDir)
|
|
defer restore()
|
|
|
|
gitExec(t, cloneDir, "config", "user.name", "Test")
|
|
gitExec(t, cloneDir, "config", "user.email", "test@test.com")
|
|
|
|
gitExec(t, cloneDir, "checkout", "-b", "feature")
|
|
writeFile(t, cloneDir, "conflict.txt", "feature change\n")
|
|
gitExec(t, cloneDir, "add", ".")
|
|
gitExec(t, cloneDir, "commit", "-m", "feature edit")
|
|
featureSHA := gitExec(t, cloneDir, "rev-parse", "feature")
|
|
|
|
gitExec(t, cloneDir, "checkout", "main")
|
|
writeFile(t, cloneDir, "conflict.txt", "main change\n")
|
|
gitExec(t, cloneDir, "add", ".")
|
|
gitExec(t, cloneDir, "commit", "-m", "main edit")
|
|
|
|
require.Error(t, CherryPick([]string{featureSHA}))
|
|
require.True(t, IsCherryPickInProgress())
|
|
|
|
// --quit clears sequencer state (no longer "in progress") ...
|
|
CherryPickQuit()
|
|
assert.False(t, IsCherryPickInProgress(), "quit should clear cherry-pick sequencer state")
|
|
|
|
// ... but leaves the unmerged index behind, so checkout still fails.
|
|
_, coErr := gitExecMayFail(t, cloneDir, "checkout", "feature")
|
|
require.Error(t, coErr, "checkout should still fail after --quit because the index is unmerged")
|
|
}
|