From 7adcfff2446bc1aaf850bc59b97d8d5cec79e2f2 Mon Sep 17 00:00:00 2001 From: Stefan Haller Date: Sat, 7 Nov 2026 11:56:51 +0100 Subject: [PATCH] Show a "git am" in progress, and let the user continue, skip or abort it Users can copy commits from one repo to another with "git format-patch" and "git am", for example with custom commands. If "git am" stopped with a conflict, lazygit showed this as a rebase, because git am uses the rebase-apply directory too. The rebase options in the m menu didn't work either; git refuses to run "git rebase --continue" during an am. Recognize an am by the rebase-apply/applying file, and call this state "applying patches". The m menu then offers "git am --continue", "--abort" and "--skip". If "git am --continue" finds nothing to commit, it says "No changes - did you forget to use 'git add'?". Unlike for a rebase, don't skip the patch in that case. Without --3way, git am leaves the files alone if a patch doesn't apply, and skipping would lose the patch. If a lock error stops "git am --continue" or "--skip" after it has moved on to the next patch, don't run it again; this could skip that patch. Co-Authored-By: Claude Opus 5.5 --- pkg/commands/git_commands/rebase.go | 3 + pkg/commands/git_commands/status.go | 15 +++- pkg/commands/git_commands/status_test.go | 48 +++++++++++++ pkg/commands/models/working_tree_state.go | 69 +++++++++++-------- .../helpers/merge_and_rebase_helper.go | 7 ++ pkg/i18n/english.go | 10 +++ .../abort_after_conflict_when_continuing.go | 38 ++++++++++ .../am/continue_after_resolving_conflicts.go | 51 ++++++++++++++ .../tests/am/continue_without_resolving.go | 34 +++++++++ .../am/lock_error_partway_through_skip.go | 49 +++++++++++++ pkg/integration/tests/am/shared.go | 37 ++++++++++ .../tests/am/skip_conflicting_patch.go | 34 +++++++++ pkg/integration/tests/shared/index_lock.go | 8 +++ pkg/integration/tests/test_list.go | 6 ++ 14 files changed, 380 insertions(+), 29 deletions(-) create mode 100644 pkg/integration/tests/am/abort_after_conflict_when_continuing.go create mode 100644 pkg/integration/tests/am/continue_after_resolving_conflicts.go create mode 100644 pkg/integration/tests/am/continue_without_resolving.go create mode 100644 pkg/integration/tests/am/lock_error_partway_through_skip.go create mode 100644 pkg/integration/tests/am/shared.go create mode 100644 pkg/integration/tests/am/skip_conflicting_patch.go diff --git a/pkg/commands/git_commands/rebase.go b/pkg/commands/git_commands/rebase.go index c5e289e49..675f50c8c 100644 --- a/pkg/commands/git_commands/rebase.go +++ b/pkg/commands/git_commands/rebase.go @@ -515,6 +515,9 @@ func (self *RebaseCommands) GenericMergeOrRebaseAction(commandType string, comma case "cherry-pick", "revert": cmdObj = retryContinueOrSkipAfterLockError(cmdObj, filepath.Join(self.repoPaths.WorktreeGitDirPath(), "sequencer", "todo")) + case "am": + cmdObj = retryContinueOrSkipAfterLockError(cmdObj, + filepath.Join(self.repoPaths.WorktreeGitDirPath(), "rebase-apply", "next")) } } diff --git a/pkg/commands/git_commands/status.go b/pkg/commands/git_commands/status.go index f6b1fd460..f65990359 100644 --- a/pkg/commands/git_commands/status.go +++ b/pkg/commands/git_commands/status.go @@ -30,6 +30,7 @@ func (self *StatusCommands) WorkingTreeState() models.WorkingTreeState { result.Merging, _ = self.IsInMergeState() result.CherryPicking, _ = self.IsInCherryPick() result.Reverting, _ = self.IsInRevert() + result.ApplyingPatches, _ = self.IsApplyingPatches() return result } @@ -42,7 +43,19 @@ func (self *StatusCommands) IsInRebase() (bool, error) { if err == nil && exists { return true, nil } - return self.os.FileExists(filepath.Join(self.repoPaths.WorktreeGitDirPath(), "rebase-apply")) + + // "git am" uses the rebase-apply directory too + exists, err = self.os.FileExists(filepath.Join(self.repoPaths.WorktreeGitDirPath(), "rebase-apply")) + if err != nil || !exists { + return false, err + } + isApplyingPatches, err := self.IsApplyingPatches() + return !isApplyingPatches, err +} + +// IsApplyingPatches tells whether a "git am" is in progress +func (self *StatusCommands) IsApplyingPatches() (bool, error) { + return self.os.FileExists(filepath.Join(self.repoPaths.WorktreeGitDirPath(), "rebase-apply", "applying")) } // IsInMergeState states whether we are still mid-merge diff --git a/pkg/commands/git_commands/status_test.go b/pkg/commands/git_commands/status_test.go index 45987f912..8292e4b89 100644 --- a/pkg/commands/git_commands/status_test.go +++ b/pkg/commands/git_commands/status_test.go @@ -222,3 +222,51 @@ func TestRebaseStoppedBeforeCommittingPick(t *testing.T) { }) } } + +func TestStatusWorkingTreeStateOfRebaseApplyDir(t *testing.T) { + scenarios := []struct { + testName string + files map[string]string + expectedRebasing bool + expectedApplyingPatches bool + }{ + { + testName: "git am", + files: map[string]string{"rebase-apply/applying": ""}, + expectedRebasing: false, + expectedApplyingPatches: true, + }, + { + testName: "rebase that uses the apply backend", + files: map[string]string{"rebase-apply/rebasing": ""}, + expectedRebasing: true, + expectedApplyingPatches: false, + }, + { + testName: "git am while an interactive rebase is stopped", + files: map[string]string{ + "rebase-apply/applying": "", + "rebase-merge/git-rebase-todo": "", + }, + expectedRebasing: true, + expectedApplyingPatches: true, + }, + } + + for _, s := range scenarios { + t.Run(s.testName, func(t *testing.T) { + repoDir := t.TempDir() + for path, content := range s.files { + fullPath := filepath.Join(repoDir, ".git", path) + assert.NoError(t, os.MkdirAll(filepath.Dir(fullPath), 0o755)) + assert.NoError(t, os.WriteFile(fullPath, []byte(content), 0o644)) + } + instance := buildStatusCommands(commonDeps{repoPaths: MockRepoPaths(repoDir)}) + + state := instance.WorkingTreeState() + + assert.Equal(t, s.expectedRebasing, state.Rebasing) + assert.Equal(t, s.expectedApplyingPatches, state.ApplyingPatches) + }) + } +} diff --git a/pkg/commands/models/working_tree_state.go b/pkg/commands/models/working_tree_state.go index 12b07b1f1..827e84277 100644 --- a/pkg/commands/models/working_tree_state.go +++ b/pkg/commands/models/working_tree_state.go @@ -4,17 +4,20 @@ import "github.com/jesseduffield/lazygit/pkg/i18n" // The state of the working tree. Several of these can be true at once. // In particular, the concrete multi-state combinations that can occur in -// practice are Rebasing+CherryPicking, and Rebasing+Reverting. Theoretically, I -// guess Rebasing+Merging could also happen, but it probably won't in practice. +// practice are Rebasing+CherryPicking, Rebasing+Reverting, and +// Rebasing+ApplyingPatches. Theoretically, I guess Rebasing+Merging could also +// happen, but it probably won't in practice. type WorkingTreeState struct { Rebasing bool Merging bool CherryPicking bool Reverting bool + // git am + ApplyingPatches bool } func (self WorkingTreeState) Any() bool { - return self.Rebasing || self.Merging || self.CherryPicking || self.Reverting + return self.Rebasing || self.Merging || self.CherryPicking || self.Reverting || self.ApplyingPatches } func (self WorkingTreeState) None() bool { @@ -30,6 +33,7 @@ const ( WORKING_TREE_STATE_MERGING WORKING_TREE_STATE_CHERRY_PICKING WORKING_TREE_STATE_REVERTING + WORKING_TREE_STATE_APPLYING_PATCHES ) // Effective returns the "current" state; if several states are true at once, @@ -52,6 +56,9 @@ func (self WorkingTreeState) Effective() EffectiveWorkingTreeState { if self.Merging { return WORKING_TREE_STATE_MERGING } + if self.ApplyingPatches { + return WORKING_TREE_STATE_APPLYING_PATCHES + } if self.Rebasing { return WORKING_TREE_STATE_REBASING } @@ -60,37 +67,41 @@ func (self WorkingTreeState) Effective() EffectiveWorkingTreeState { func (self WorkingTreeState) Title(tr *i18n.TranslationSet) string { return map[EffectiveWorkingTreeState]string{ - WORKING_TREE_STATE_REBASING: tr.RebasingStatus, - WORKING_TREE_STATE_MERGING: tr.MergingStatus, - WORKING_TREE_STATE_CHERRY_PICKING: tr.CherryPickingStatus, - WORKING_TREE_STATE_REVERTING: tr.RevertingStatus, + WORKING_TREE_STATE_REBASING: tr.RebasingStatus, + WORKING_TREE_STATE_MERGING: tr.MergingStatus, + WORKING_TREE_STATE_CHERRY_PICKING: tr.CherryPickingStatus, + WORKING_TREE_STATE_REVERTING: tr.RevertingStatus, + WORKING_TREE_STATE_APPLYING_PATCHES: tr.ApplyingPatchesStatus, }[self.Effective()] } func (self WorkingTreeState) LowerCaseTitle(tr *i18n.TranslationSet) string { return map[EffectiveWorkingTreeState]string{ - WORKING_TREE_STATE_REBASING: tr.LowercaseRebasingStatus, - WORKING_TREE_STATE_MERGING: tr.LowercaseMergingStatus, - WORKING_TREE_STATE_CHERRY_PICKING: tr.LowercaseCherryPickingStatus, - WORKING_TREE_STATE_REVERTING: tr.LowercaseRevertingStatus, + WORKING_TREE_STATE_REBASING: tr.LowercaseRebasingStatus, + WORKING_TREE_STATE_MERGING: tr.LowercaseMergingStatus, + WORKING_TREE_STATE_CHERRY_PICKING: tr.LowercaseCherryPickingStatus, + WORKING_TREE_STATE_REVERTING: tr.LowercaseRevertingStatus, + WORKING_TREE_STATE_APPLYING_PATCHES: tr.LowercaseApplyingPatchesStatus, }[self.Effective()] } func (self WorkingTreeState) OptionsMenuTitle(tr *i18n.TranslationSet) string { return map[EffectiveWorkingTreeState]string{ - WORKING_TREE_STATE_REBASING: tr.RebaseOptionsTitle, - WORKING_TREE_STATE_MERGING: tr.MergeOptionsTitle, - WORKING_TREE_STATE_CHERRY_PICKING: tr.CherryPickOptionsTitle, - WORKING_TREE_STATE_REVERTING: tr.RevertOptionsTitle, + WORKING_TREE_STATE_REBASING: tr.RebaseOptionsTitle, + WORKING_TREE_STATE_MERGING: tr.MergeOptionsTitle, + WORKING_TREE_STATE_CHERRY_PICKING: tr.CherryPickOptionsTitle, + WORKING_TREE_STATE_REVERTING: tr.RevertOptionsTitle, + WORKING_TREE_STATE_APPLYING_PATCHES: tr.ApplyPatchesOptionsTitle, }[self.Effective()] } func (self WorkingTreeState) OptionsMapTitle(tr *i18n.TranslationSet) string { return map[EffectiveWorkingTreeState]string{ - WORKING_TREE_STATE_REBASING: tr.ViewRebaseOptions, - WORKING_TREE_STATE_MERGING: tr.ViewMergeOptions, - WORKING_TREE_STATE_CHERRY_PICKING: tr.ViewCherryPickOptions, - WORKING_TREE_STATE_REVERTING: tr.ViewRevertOptions, + WORKING_TREE_STATE_REBASING: tr.ViewRebaseOptions, + WORKING_TREE_STATE_MERGING: tr.ViewMergeOptions, + WORKING_TREE_STATE_CHERRY_PICKING: tr.ViewCherryPickOptions, + WORKING_TREE_STATE_REVERTING: tr.ViewRevertOptions, + WORKING_TREE_STATE_APPLYING_PATCHES: tr.ViewApplyPatchesOptions, }[self.Effective()] } @@ -98,19 +109,21 @@ func (self WorkingTreeState) OptionsMapTitle(tr *i18n.TranslationSet) string { // sentences like "Abort ". func (self WorkingTreeState) OperationName(tr *i18n.TranslationSet) string { return map[EffectiveWorkingTreeState]string{ - WORKING_TREE_STATE_REBASING: tr.RebaseOperationName, - WORKING_TREE_STATE_MERGING: tr.MergeOperationName, - WORKING_TREE_STATE_CHERRY_PICKING: tr.CherryPickOperationName, - WORKING_TREE_STATE_REVERTING: tr.RevertOperationName, + WORKING_TREE_STATE_REBASING: tr.RebaseOperationName, + WORKING_TREE_STATE_MERGING: tr.MergeOperationName, + WORKING_TREE_STATE_CHERRY_PICKING: tr.CherryPickOperationName, + WORKING_TREE_STATE_REVERTING: tr.RevertOperationName, + WORKING_TREE_STATE_APPLYING_PATCHES: tr.ApplyPatchesOperationName, }[self.Effective()] } func (self WorkingTreeState) CommandName() string { return map[EffectiveWorkingTreeState]string{ - WORKING_TREE_STATE_REBASING: "rebase", - WORKING_TREE_STATE_MERGING: "merge", - WORKING_TREE_STATE_CHERRY_PICKING: "cherry-pick", - WORKING_TREE_STATE_REVERTING: "revert", + WORKING_TREE_STATE_REBASING: "rebase", + WORKING_TREE_STATE_MERGING: "merge", + WORKING_TREE_STATE_CHERRY_PICKING: "cherry-pick", + WORKING_TREE_STATE_REVERTING: "revert", + WORKING_TREE_STATE_APPLYING_PATCHES: "am", }[self.Effective()] } @@ -119,5 +132,5 @@ func (self WorkingTreeState) CanShowTodos() bool { } func (self WorkingTreeState) CanSkip() bool { - return self.Rebasing || self.CherryPicking || self.Reverting + return self.Rebasing || self.CherryPicking || self.Reverting || self.ApplyingPatches } diff --git a/pkg/gui/controllers/helpers/merge_and_rebase_helper.go b/pkg/gui/controllers/helpers/merge_and_rebase_helper.go index 12b98e3ce..4ce378bfe 100644 --- a/pkg/gui/controllers/helpers/merge_and_rebase_helper.go +++ b/pkg/gui/controllers/helpers/merge_and_rebase_helper.go @@ -235,6 +235,13 @@ func (self *MergeAndRebaseHelper) CheckMergeOrRebaseWithRefreshOptions(result er if result == nil { return nil } else if strings.Contains(result.Error(), "No changes - did you forget to use") { + if self.c.Git().Status.WorkingTreeState().ApplyingPatches { + // "git am" says this not only if resolving the conflicts of a patch + // left nothing to commit, but also if the patch didn't apply at + // all. Without --3way, it leaves the files alone then, and skipping + // would lose the patch. Show git's explanation instead. + return result + } return self.genericMergeCommandImpl(REBASE_OPTION_SKIP, false, true) } else if strings.Contains(result.Error(), "The previous cherry-pick is now empty") { return self.genericMergeCommandImpl(REBASE_OPTION_SKIP, false, true) diff --git a/pkg/i18n/english.go b/pkg/i18n/english.go index d168db094..3e813fd94 100644 --- a/pkg/i18n/english.go +++ b/pkg/i18n/english.go @@ -359,6 +359,7 @@ type TranslationSet struct { ViewRebaseOptions string ViewCherryPickOptions string ViewRevertOptions string + ViewApplyPatchesOptions string NotMergingOrRebasing string AlreadyRebasing string NotMidRebase string @@ -371,6 +372,7 @@ type TranslationSet struct { RebaseOptionsTitle string CherryPickOptionsTitle string RevertOptionsTitle string + ApplyPatchesOptionsTitle string CommitSummaryTitle string CommitDescriptionTitle string CommitDescriptionSubTitle string @@ -479,6 +481,9 @@ type TranslationSet struct { MergeOperationName string CherryPickOperationName string RevertOperationName string + ApplyPatchesOperationName string + LowercaseApplyingPatchesStatus string + ApplyingPatchesStatus string AmendingStatus string CherryPickingStatus string UndoingStatus string @@ -1582,6 +1587,7 @@ func EnglishTranslationSet() *TranslationSet { ViewRebaseOptions: "View rebase options", ViewCherryPickOptions: "View cherry-pick options", ViewRevertOptions: "View revert options", + ViewApplyPatchesOptions: "View 'am' options", NotMergingOrRebasing: "You are currently neither rebasing nor merging", AlreadyRebasing: "Can't perform this action during a rebase", NotMidRebase: "This action only works during an interactive rebase", @@ -1594,6 +1600,7 @@ func EnglishTranslationSet() *TranslationSet { RebaseOptionsTitle: "Rebase options", CherryPickOptionsTitle: "Cherry-pick options", RevertOptionsTitle: "Revert options", + ApplyPatchesOptionsTitle: "Applying patches", CommitSummaryTitle: "Commit summary", CommitDescriptionTitle: "Commit description", CommitDescriptionSubTitle: "Press {{.togglePanelKeyBinding}} to toggle focus, {{.commitMenuKeybinding}} to open menu", @@ -1708,6 +1715,9 @@ func EnglishTranslationSet() *TranslationSet { MergeOperationName: "the merge", CherryPickOperationName: "the cherry-pick", RevertOperationName: "the revert", + ApplyPatchesOperationName: "applying patches", + LowercaseApplyingPatchesStatus: "applying patches", // lowercase because it shows up in parentheses + ApplyingPatchesStatus: "Applying patches", AmendingStatus: "Amending", CherryPickingStatus: "Cherry-picking", UndoingStatus: "Undoing", diff --git a/pkg/integration/tests/am/abort_after_conflict_when_continuing.go b/pkg/integration/tests/am/abort_after_conflict_when_continuing.go new file mode 100644 index 000000000..17a3924e7 --- /dev/null +++ b/pkg/integration/tests/am/abort_after_conflict_when_continuing.go @@ -0,0 +1,38 @@ +package am + +import ( + "github.com/jesseduffield/lazygit/pkg/config" + . "github.com/jesseduffield/lazygit/pkg/integration/components" +) + +var AbortAfterConflictWhenContinuing = NewIntegrationTest(NewIntegrationTestArgs{ + Description: "Continue applying patches with git am, and abort when the next patch conflicts too", + ExtraCmdArgs: []string{}, + Skip: false, + SetupConfig: func(config *config.AppConfig) {}, + SetupRepo: func(shell *Shell) { + setUpConflictingAm(shell, true, true) + }, + Run: func(t *TestDriver, keys config.KeybindingConfig) { + t.Shell().UpdateFileAndAdd("file", "resolved\n") + + t.GlobalPress(keys.Universal.CreateRebaseOptionsMenu) + t.ExpectPopup().Menu(). + Title(Equals("Applying patches")). + Select(Contains("continue")). + Confirm() + + t.ExpectPopup().Menu(). + Title(Equals("Conflicts!")). + Select(Contains("Abort applying patches")). + Confirm() + + t.Views().Status().Content(Equals("repo → master")) + + t.Views().Commits(). + Lines( + Contains("conflicting change"), + Contains("base"), + ) + }, +}) diff --git a/pkg/integration/tests/am/continue_after_resolving_conflicts.go b/pkg/integration/tests/am/continue_after_resolving_conflicts.go new file mode 100644 index 000000000..e817a750e --- /dev/null +++ b/pkg/integration/tests/am/continue_after_resolving_conflicts.go @@ -0,0 +1,51 @@ +package am + +import ( + "github.com/jesseduffield/lazygit/pkg/config" + . "github.com/jesseduffield/lazygit/pkg/integration/components" +) + +var ContinueAfterResolvingConflicts = NewIntegrationTest(NewIntegrationTestArgs{ + Description: "Continue applying patches with git am after resolving the conflicts of one of them", + ExtraCmdArgs: []string{}, + Skip: false, + SetupConfig: func(config *config.AppConfig) {}, + SetupRepo: func(shell *Shell) { + setUpConflictingAm(shell, false, true) + }, + Run: func(t *TestDriver, keys config.KeybindingConfig) { + t.Views().Status().Content(Equals("(applying patches) repo → master")) + t.Views().Information().Content(Contains("Applying patches")) + t.Views().Options().Content(Contains("View 'am' options: m")) + + t.Views().Files(). + IsFocused(). + Lines( + Contains("UU").Contains("file"), + ) + + t.Shell().UpdateFileAndAdd("file", "resolved\n") + + t.GlobalPress(keys.Universal.CreateRebaseOptionsMenu) + t.ExpectPopup().Menu(). + Title(Equals("Applying patches")). + Lines( + Contains("continue"), + Contains("abort"), + Contains("skip"), + Contains("Cancel"), + ). + Select(Contains("continue")). + Confirm() + + t.Views().Status().Content(Equals("repo → master")) + + t.Views().Commits(). + Lines( + Contains("add file2"), + Contains("change file"), + Contains("conflicting change"), + Contains("base"), + ) + }, +}) diff --git a/pkg/integration/tests/am/continue_without_resolving.go b/pkg/integration/tests/am/continue_without_resolving.go new file mode 100644 index 000000000..7f5a6de06 --- /dev/null +++ b/pkg/integration/tests/am/continue_without_resolving.go @@ -0,0 +1,34 @@ +package am + +import ( + "github.com/jesseduffield/lazygit/pkg/config" + . "github.com/jesseduffield/lazygit/pkg/integration/components" +) + +var ContinueWithoutResolving = NewIntegrationTest(NewIntegrationTestArgs{ + Description: "Continue applying patches with git am after a patch didn't apply, without doing anything about it", + ExtraCmdArgs: []string{}, + Skip: false, + SetupConfig: func(config *config.AppConfig) {}, + SetupRepo: func(shell *Shell) { + setUpConflictingAm(shell, false, false) + }, + Run: func(t *TestDriver, keys config.KeybindingConfig) { + t.Views().Status().Content(Equals("(applying patches) repo → master")) + + t.Views().Files().IsEmpty() + + t.GlobalPress(keys.Universal.CreateRebaseOptionsMenu) + t.ExpectPopup().Menu(). + Title(Equals("Applying patches")). + Select(Contains("continue")). + Confirm() + + t.ExpectPopup().Alert(). + Title(Equals("Error")). + Content(Contains("No changes - did you forget to use 'git add'?")). + Confirm() + + t.Views().Status().Content(Equals("(applying patches) repo → master")) + }, +}) diff --git a/pkg/integration/tests/am/lock_error_partway_through_skip.go b/pkg/integration/tests/am/lock_error_partway_through_skip.go new file mode 100644 index 000000000..ad6dedee7 --- /dev/null +++ b/pkg/integration/tests/am/lock_error_partway_through_skip.go @@ -0,0 +1,49 @@ +package am + +import ( + "github.com/jesseduffield/lazygit/pkg/config" + . "github.com/jesseduffield/lazygit/pkg/integration/components" + "github.com/jesseduffield/lazygit/pkg/integration/tests/shared" +) + +var LockErrorPartwayThroughSkip = NewIntegrationTest(NewIntegrationTestArgs{ + Description: "Skip a conflicting patch while another git process takes index.lock before git am applies the patch after the next one", + ExtraCmdArgs: []string{}, + Skip: false, + SetupConfig: func(config *config.AppConfig) {}, + SetupRepo: func(shell *Shell) { + shell. + CreateFileAndAdd("file", "original\n"). + Commit("base"). + NewBranch("source"). + UpdateFileAndAdd("file", "source\n"). + Commit("change file"). + CreateFileAndAdd("file2", "two\n"). + Commit("add file2"). + CreateFileAndAdd("file3", "three\n"). + Commit("add file3"). + RunShellCommand("git format-patch --stdout master..source > ../patches.mbox"). + Checkout("master"). + UpdateFileAndAdd("file", "master\n"). + Commit("conflicting change"). + RunCommandExpectError([]string{"git", "am", "--3way", "../patches.mbox"}) + + // After skipping "change file", git am commits "add file2"; the lock + // then stops it when it tries to apply "add file3". + shared.HoldIndexLockAfterNextAppliedPatch(shell) + }, + Run: func(t *TestDriver, keys config.KeybindingConfig) { + t.GlobalPress(keys.Universal.CreateRebaseOptionsMenu) + t.ExpectPopup().Menu(). + Title(Equals("Applying patches")). + Select(Contains("skip")). + Confirm() + + t.ExpectPopup().Alert(). + Title(Equals("Error")). + Content(Contains("index.lock': File exists.")). + Confirm() + + t.Views().Status().Content(Equals("(applying patches) repo → master")) + }, +}) diff --git a/pkg/integration/tests/am/shared.go b/pkg/integration/tests/am/shared.go new file mode 100644 index 000000000..7be96a5df --- /dev/null +++ b/pkg/integration/tests/am/shared.go @@ -0,0 +1,37 @@ +package am + +import ( + . "github.com/jesseduffield/lazygit/pkg/integration/components" +) + +// setUpConflictingAm creates two commits on a branch, turns them into patches, +// and applies these to master with "git am". The first patch conflicts. If +// secondPatchConflicts is true, the second one conflicts with the resolution of +// the first one too. With threeWay, "git am" gets the --3way option, so that +// the conflicts show up in the files; otherwise it leaves the files alone. +func setUpConflictingAm(shell *Shell, secondPatchConflicts bool, threeWay bool) { + shell. + CreateFileAndAdd("file", "original\n"). + Commit("base"). + NewBranch("source"). + UpdateFileAndAdd("file", "source\n"). + Commit("change file") + + if secondPatchConflicts { + shell.UpdateFileAndAdd("file", "source again\n").Commit("change file again") + } else { + shell.CreateFileAndAdd("file2", "two\n").Commit("add file2") + } + + shell. + RunShellCommand("git format-patch --stdout master..source > ../patches.mbox"). + Checkout("master"). + UpdateFileAndAdd("file", "master\n"). + Commit("conflicting change") + + amArgs := []string{"git", "am"} + if threeWay { + amArgs = append(amArgs, "--3way") + } + shell.RunCommandExpectError(append(amArgs, "../patches.mbox")) +} diff --git a/pkg/integration/tests/am/skip_conflicting_patch.go b/pkg/integration/tests/am/skip_conflicting_patch.go new file mode 100644 index 000000000..e830d456d --- /dev/null +++ b/pkg/integration/tests/am/skip_conflicting_patch.go @@ -0,0 +1,34 @@ +package am + +import ( + "github.com/jesseduffield/lazygit/pkg/config" + . "github.com/jesseduffield/lazygit/pkg/integration/components" +) + +var SkipConflictingPatch = NewIntegrationTest(NewIntegrationTestArgs{ + Description: "Skip a patch that conflicts while applying patches with git am", + ExtraCmdArgs: []string{}, + Skip: false, + SetupConfig: func(config *config.AppConfig) {}, + SetupRepo: func(shell *Shell) { + setUpConflictingAm(shell, false, true) + }, + Run: func(t *TestDriver, keys config.KeybindingConfig) { + t.Views().Status().Content(Equals("(applying patches) repo → master")) + + t.GlobalPress(keys.Universal.CreateRebaseOptionsMenu) + t.ExpectPopup().Menu(). + Title(Equals("Applying patches")). + Select(Contains("skip")). + Confirm() + + t.Views().Status().Content(Equals("repo → master")) + + t.Views().Commits(). + Lines( + Contains("add file2"), + Contains("conflicting change"), + Contains("base"), + ) + }, +}) diff --git a/pkg/integration/tests/shared/index_lock.go b/pkg/integration/tests/shared/index_lock.go index c8414813f..5d2caf12a 100644 --- a/pkg/integration/tests/shared/index_lock.go +++ b/pkg/integration/tests/shared/index_lock.go @@ -42,3 +42,11 @@ awk '$3 == "CHERRY_PICK_HEAD" && $2 !~ /^0+$/ { found = 1 } END { exit !found }' `+holdIndexLockScript) shell.MakeExecutable(".git/hooks/reference-transaction") } + +// HoldIndexLockAfterNextAppliedPatch installs a post-applypatch hook that takes +// index.lock (see holdIndexLockScript) after "git am" has committed the next +// patch. +func HoldIndexLockAfterNextAppliedPatch(shell *Shell) { + shell.CreateFile(".git/hooks/post-applypatch", "#!/bin/sh\n"+holdIndexLockScript) + shell.MakeExecutable(".git/hooks/post-applypatch") +} diff --git a/pkg/integration/tests/test_list.go b/pkg/integration/tests/test_list.go index 9dc3e4ff4..f2369036f 100644 --- a/pkg/integration/tests/test_list.go +++ b/pkg/integration/tests/test_list.go @@ -4,6 +4,7 @@ package tests import ( "github.com/jesseduffield/lazygit/pkg/integration/components" + "github.com/jesseduffield/lazygit/pkg/integration/tests/am" "github.com/jesseduffield/lazygit/pkg/integration/tests/bisect" "github.com/jesseduffield/lazygit/pkg/integration/tests/branch" "github.com/jesseduffield/lazygit/pkg/integration/tests/cherry_pick" @@ -35,6 +36,11 @@ import ( ) var tests = []*components.IntegrationTest{ + am.AbortAfterConflictWhenContinuing, + am.ContinueAfterResolvingConflicts, + am.ContinueWithoutResolving, + am.LockErrorPartwayThroughSkip, + am.SkipConflictingPatch, bisect.Basic, bisect.CheckOutBranchBeforeBisecting, bisect.ChooseTerms,