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 <noreply@anthropic.com>
This commit is contained in:
Stefan Haller
2026-11-08 15:17:17 +01:00
co-authored by Claude Opus 5.5
parent 6a0ef0b750
commit 7adcfff244
14 changed files with 380 additions and 29 deletions
+3
View File
@@ -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"))
}
}
+14 -1
View File
@@ -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
+48
View File
@@ -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)
})
}
}
+41 -28
View File
@@ -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 <operation>".
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
}
@@ -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)
+10
View File
@@ -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",
@@ -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"),
)
},
})
@@ -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"),
)
},
})
@@ -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"))
},
})
@@ -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"))
},
})
+37
View File
@@ -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"))
}
@@ -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"),
)
},
})
@@ -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")
}
+6
View File
@@ -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,