From 6a0ef0b7502e36df319c7beabe7c32a003127798 Mon Sep 17 00:00:00 2001 From: Stefan Haller Date: Sat, 7 Nov 2026 11:52:29 +0100 Subject: [PATCH 1/4] Describe the operation in progress with a translated phrase The texts for aborting or continuing a merge, rebase, cherry-pick or revert insert the git command name into the sentence, as in "Abort the rebase". We are going to support "git am" next, and "Abort the am" makes no sense. Give each operation a translated phrase like "the rebase" instead, and use it as a placeholder in these texts. Rename the texts too. The existing translations still use %s, and this way lazygit ignores them until they are updated. This changes the wording of the abort confirmation slightly, from "Abort rebase" to "Abort the rebase", and from "abort the current rebase" to "abort the rebase". Co-Authored-By: Claude Opus 5.5 --- pkg/commands/models/working_tree_state.go | 11 +++++++++ .../helpers/merge_and_rebase_helper.go | 19 +++++++++------ pkg/gui/controllers/helpers/mode_helper.go | 5 +++- pkg/i18n/english.go | 24 ++++++++++++------- 4 files changed, 43 insertions(+), 16 deletions(-) diff --git a/pkg/commands/models/working_tree_state.go b/pkg/commands/models/working_tree_state.go index 3e7bdbf1b..12b07b1f1 100644 --- a/pkg/commands/models/working_tree_state.go +++ b/pkg/commands/models/working_tree_state.go @@ -94,6 +94,17 @@ func (self WorkingTreeState) OptionsMapTitle(tr *i18n.TranslationSet) string { }[self.Effective()] } +// OperationName returns a phrase for the current operation that we can use in +// 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, + }[self.Effective()] +} + func (self WorkingTreeState) CommandName() string { return map[EffectiveWorkingTreeState]string{ WORKING_TREE_STATE_REBASING: "rebase", diff --git a/pkg/gui/controllers/helpers/merge_and_rebase_helper.go b/pkg/gui/controllers/helpers/merge_and_rebase_helper.go index 106652ff3..12b98e3ce 100644 --- a/pkg/gui/controllers/helpers/merge_and_rebase_helper.go +++ b/pkg/gui/controllers/helpers/merge_and_rebase_helper.go @@ -283,7 +283,7 @@ func (self *MergeAndRebaseHelper) CheckForConflicts(result error) error { } func (self *MergeAndRebaseHelper) PromptForConflictHandling() error { - mode := self.c.Git().Status.WorkingTreeState().CommandName() + operation := self.c.Git().Status.WorkingTreeState().OperationName(self.c.Tr) return self.c.Menu(types.CreateMenuOptions{ Title: self.c.Tr.FoundConflictsTitle, Items: []*types.MenuItem{ @@ -295,7 +295,8 @@ func (self *MergeAndRebaseHelper) PromptForConflictHandling() error { }, }, { - Label: fmt.Sprintf(self.c.Tr.AbortMenuItem, mode), + Label: utils.ResolvePlaceholderString(self.c.Tr.AbortOperationMenuItem, + map[string]string{"operation": operation}), OnPress: func() error { return self.genericMergeCommand(REBASE_OPTION_ABORT) }, @@ -308,10 +309,12 @@ func (self *MergeAndRebaseHelper) PromptForConflictHandling() error { func (self *MergeAndRebaseHelper) AbortMergeOrRebaseWithConfirm() error { // prompt user to confirm that they want to abort, then do it - mode := self.c.Git().Status.WorkingTreeState().CommandName() + placeholders := map[string]string{ + "operation": self.c.Git().Status.WorkingTreeState().OperationName(self.c.Tr), + } self.c.Confirm(types.ConfirmOpts{ - Title: fmt.Sprintf(self.c.Tr.AbortTitle, mode), - Prompt: fmt.Sprintf(self.c.Tr.AbortPrompt, mode), + Title: utils.ResolvePlaceholderString(self.c.Tr.AbortOperationTitle, placeholders), + Prompt: utils.ResolvePlaceholderString(self.c.Tr.AbortOperationPrompt, placeholders), HandleConfirm: func() error { return self.genericMergeCommand(REBASE_OPTION_ABORT) }, @@ -324,8 +327,10 @@ func (self *MergeAndRebaseHelper) AbortMergeOrRebaseWithConfirm() error { func (self *MergeAndRebaseHelper) PromptToContinueRebase() { self.continueRebasePromptShowing = true self.c.Confirm(types.ConfirmOpts{ - Title: self.c.Tr.Continue, - Prompt: fmt.Sprintf(self.c.Tr.ConflictsResolved, self.c.Git().Status.WorkingTreeState().CommandName()), + Title: self.c.Tr.Continue, + Prompt: utils.ResolvePlaceholderString(self.c.Tr.ContinueAfterConflictsResolved, map[string]string{ + "operation": self.c.Git().Status.WorkingTreeState().OperationName(self.c.Tr), + }), HandleClose: func() error { self.continueRebasePromptShowing = false return nil diff --git a/pkg/gui/controllers/helpers/mode_helper.go b/pkg/gui/controllers/helpers/mode_helper.go index a96164891..efedde3d0 100644 --- a/pkg/gui/controllers/helpers/mode_helper.go +++ b/pkg/gui/controllers/helpers/mode_helper.go @@ -7,6 +7,7 @@ import ( "github.com/jesseduffield/lazygit/pkg/gocui" "github.com/jesseduffield/lazygit/pkg/gui/style" "github.com/jesseduffield/lazygit/pkg/gui/types" + "github.com/jesseduffield/lazygit/pkg/utils" "github.com/samber/lo" ) @@ -140,7 +141,9 @@ func (self *ModeHelper) Statuses() []ModeStatus { ) }, CancelLabel: func() string { - return fmt.Sprintf(self.c.Tr.AbortTitle, self.c.Git().Status.WorkingTreeState().CommandName()) + return utils.ResolvePlaceholderString(self.c.Tr.AbortOperationTitle, map[string]string{ + "operation": self.c.Git().Status.WorkingTreeState().OperationName(self.c.Tr), + }) }, Reset: self.mergeAndRebaseHelper.AbortMergeOrRebaseWithConfirm, }, diff --git a/pkg/i18n/english.go b/pkg/i18n/english.go index 05a74b0fe..d168db094 100644 --- a/pkg/i18n/english.go +++ b/pkg/i18n/english.go @@ -350,7 +350,7 @@ type TranslationSet struct { FastForwarding string FoundConflictsTitle string ViewConflictsMenuItem string - AbortMenuItem string + AbortOperationMenuItem string PickHunk string PickBothHunks string ViewMergeRebaseOptions string @@ -386,7 +386,7 @@ type TranslationSet struct { InformationTitle string SecondaryTitle string ReflogCommitsTitle string - ConflictsResolved string + ContinueAfterConflictsResolved string Continue string UnstagedFilesAfterConflictsResolved string RebasingTitle string @@ -475,6 +475,10 @@ type TranslationSet struct { LowercaseMergingStatus string LowercaseCherryPickingStatus string LowercaseRevertingStatus string + RebaseOperationName string + MergeOperationName string + CherryPickOperationName string + RevertOperationName string AmendingStatus string CherryPickingStatus string UndoingStatus string @@ -878,8 +882,8 @@ type TranslationSet struct { MustSpecifyOriginError string GitOutput string GitCommandFailed string - AbortTitle string - AbortPrompt string + AbortOperationTitle string + AbortOperationPrompt string OpenLogMenu string OpenLogMenuTooltip string LogMenuTitle string @@ -1571,7 +1575,7 @@ func EnglishTranslationSet() *TranslationSet { FastForwarding: "Fast-forwarding", FoundConflictsTitle: "Conflicts!", ViewConflictsMenuItem: "View conflicts", - AbortMenuItem: "Abort the %s", + AbortOperationMenuItem: "Abort {{.operation}}", ViewMergeRebaseOptions: "View merge/rebase options", ViewMergeRebaseOptionsTooltip: "View options to abort/continue/skip the current merge/rebase.", ViewMergeOptions: "View merge options", @@ -1606,7 +1610,7 @@ func EnglishTranslationSet() *TranslationSet { SecondaryTitle: "Secondary", ReflogCommitsTitle: "Reflog", GlobalTitle: "Global keybindings", - ConflictsResolved: "All merge conflicts resolved. Continue the %s?", + ContinueAfterConflictsResolved: "All merge conflicts resolved. Continue {{.operation}}?", Continue: "Continue", UnstagedFilesAfterConflictsResolved: "Files have been modified since conflicts were resolved. Auto-stage them and continue?", Keybindings: "Keybindings", @@ -1700,6 +1704,10 @@ func EnglishTranslationSet() *TranslationSet { LowercaseMergingStatus: "merging", // lowercase because it shows up in parentheses LowercaseCherryPickingStatus: "cherry-picking", // lowercase because it shows up in parentheses LowercaseRevertingStatus: "reverting", // lowercase because it shows up in parentheses + RebaseOperationName: "the rebase", + MergeOperationName: "the merge", + CherryPickOperationName: "the cherry-pick", + RevertOperationName: "the revert", AmendingStatus: "Amending", CherryPickingStatus: "Cherry-picking", UndoingStatus: "Undoing", @@ -2100,8 +2108,8 @@ func EnglishTranslationSet() *TranslationSet { MustSpecifyOriginError: "Must specify a remote if specifying a branch", GitOutput: "Git output:", GitCommandFailed: "Git command failed. Check command log for details (open with %s)", - AbortTitle: "Abort %s", - AbortPrompt: "Are you sure you want to abort the current %s?", + AbortOperationTitle: "Abort {{.operation}}", + AbortOperationPrompt: "Are you sure you want to abort {{.operation}}?", OpenLogMenu: "View log options", OpenLogMenuTooltip: "View options for commit log e.g. changing sort order, hiding the git graph, showing the whole git graph.", LogMenuTitle: "Commit Log Options", From 7adcfff2446bc1aaf850bc59b97d8d5cec79e2f2 Mon Sep 17 00:00:00 2001 From: Stefan Haller Date: Sat, 7 Nov 2026 11:56:51 +0100 Subject: [PATCH 2/4] 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, From 20c6ae4c8623ce458cf0b47a740f94573cbad162 Mon Sep 17 00:00:00 2001 From: Stefan Haller Date: Sat, 7 Nov 2026 12:00:01 +0100 Subject: [PATCH 3/4] Return the reason why todos can't be edited from a single function All callers turned the result of isCherryPickingOrReverting into the same disabled reason. The next commit adds a second reason for "git am", so return the reason itself. Co-Authored-By: Claude Opus 5.5 --- .../controllers/local_commits_controller.go | 28 +++++++++++-------- 1 file changed, 17 insertions(+), 11 deletions(-) diff --git a/pkg/gui/controllers/local_commits_controller.go b/pkg/gui/controllers/local_commits_controller.go index 1489e5e71..d74e44d18 100644 --- a/pkg/gui/controllers/local_commits_controller.go +++ b/pkg/gui/controllers/local_commits_controller.go @@ -1180,9 +1180,15 @@ func (self *LocalCommitsController) isRebasing() bool { return self.c.Model().WorkingTreeStateAtLastCommitRefresh.Any() } -func (self *LocalCommitsController) isCherryPickingOrReverting() bool { - return self.c.Model().WorkingTreeStateAtLastCommitRefresh.CherryPicking || - self.c.Model().WorkingTreeStateAtLastCommitRefresh.Reverting +// todosNotEditableReason returns why the todos in the commits panel can't be +// edited, if they can't. This is the case during a cherry-pick or revert; +// git has no way of changing their todos. +func (self *LocalCommitsController) todosNotEditableReason() *types.DisabledReason { + if self.c.Model().WorkingTreeStateAtLastCommitRefresh.CherryPicking || + self.c.Model().WorkingTreeStateAtLastCommitRefresh.Reverting { + return &types.DisabledReason{Text: self.c.Tr.NotAllowedMidCherryPickOrRevert} + } + return nil } func (self *LocalCommitsController) moveDown(selectedCommits []*models.Commit, startIdx int, endIdx int) error { @@ -1936,8 +1942,8 @@ func (self *LocalCommitsController) canMoveUp(selectedCommits []*models.Commit, // Ensures that if we are mid-rebase, we're only selecting valid commits (non-conflict TODO commits) func (self *LocalCommitsController) midRebaseCommandEnabled(selectedCommits []*models.Commit, startIdx int, endIdx int) *types.DisabledReason { - if self.isCherryPickingOrReverting() { - return &types.DisabledReason{Text: self.c.Tr.NotAllowedMidCherryPickOrRevert} + if reason := self.todosNotEditableReason(); reason != nil { + return reason } if !self.isRebasing() { @@ -1959,8 +1965,8 @@ func (self *LocalCommitsController) midRebaseCommandEnabled(selectedCommits []*m // Ensures that if we are mid-rebase, we're only selecting commits that can be moved func (self *LocalCommitsController) midRebaseMoveCommandEnabled(selectedCommits []*models.Commit, startIdx int, endIdx int) *types.DisabledReason { - if self.isCherryPickingOrReverting() { - return &types.DisabledReason{Text: self.c.Tr.NotAllowedMidCherryPickOrRevert} + if reason := self.todosNotEditableReason(); reason != nil { + return reason } if !self.isRebasing() { @@ -1987,8 +1993,8 @@ func (self *LocalCommitsController) midRebaseMoveCommandEnabled(selectedCommits } func (self *LocalCommitsController) canDropCommits(selectedCommits []*models.Commit, startIdx int, endIdx int) *types.DisabledReason { - if self.isCherryPickingOrReverting() { - return &types.DisabledReason{Text: self.c.Tr.NotAllowedMidCherryPickOrRevert} + if reason := self.todosNotEditableReason(); reason != nil { + return reason } if !self.isRebasing() { @@ -2035,8 +2041,8 @@ func isChangeOfRebaseTodoAllowed(oldAction todo.TodoCommand) bool { } func (self *LocalCommitsController) pickEnabled(selectedCommits []*models.Commit, startIdx int, endIdx int) *types.DisabledReason { - if self.isCherryPickingOrReverting() { - return &types.DisabledReason{Text: self.c.Tr.NotAllowedMidCherryPickOrRevert} + if reason := self.todosNotEditableReason(); reason != nil { + return reason } if !self.isRebasing() { From df21b8cca0847a837607b2fa59b90c39d602d17e Mon Sep 17 00:00:00 2001 From: Stefan Haller Date: Sat, 7 Nov 2026 12:03:41 +0100 Subject: [PATCH 4/4] Show the patches that "git am" hasn't applied yet Show them in the commits panel under a "Pending patches" header, like the todos of a rebase or cherry-pick. The patches usually come from another repository, so we can't look up their commits. Instead, take the subject, author and date from the patch files in .git/rebase-apply, and show the patch itself in the main view. Git has no way of editing these todos, so disable the actions that would edit them. Co-Authored-By: Claude Opus 5.5 --- pkg/commands/git_commands/commit_loader.go | 5 + pkg/commands/git_commands/pending_patches.go | 118 ++++++++++++++++++ .../git_commands/pending_patches_test.go | 109 ++++++++++++++++ pkg/commands/models/commit.go | 7 ++ pkg/commands/models/working_tree_state.go | 2 +- pkg/gui/context/local_commits_context.go | 14 +++ .../controllers/local_commits_controller.go | 16 ++- pkg/gui/presentation/commits.go | 2 +- pkg/i18n/english.go | 4 + .../am/continue_after_resolving_conflicts.go | 21 ++++ 10 files changed, 294 insertions(+), 4 deletions(-) create mode 100644 pkg/commands/git_commands/pending_patches.go create mode 100644 pkg/commands/git_commands/pending_patches_test.go diff --git a/pkg/commands/git_commands/commit_loader.go b/pkg/commands/git_commands/commit_loader.go index c0b6cf580..ddb7ef213 100644 --- a/pkg/commands/git_commands/commit_loader.go +++ b/pkg/commands/git_commands/commit_loader.go @@ -173,6 +173,11 @@ func (self *CommitLoader) MergeRebasingCommits(hashPool *utils.StringPool, commi addConflictedRebasingCommit = false } + if workingTreeState.ApplyingPatches { + result = append(self.getPendingPatches(hashPool), result...) + addConflictedRebasingCommit = false + } + if workingTreeState.Rebasing { rebasingCommits, err := self.getHydratedRebasingCommits(hashPool, commits, addConflictedRebasingCommit) if err != nil { diff --git a/pkg/commands/git_commands/pending_patches.go b/pkg/commands/git_commands/pending_patches.go new file mode 100644 index 000000000..899b13987 --- /dev/null +++ b/pkg/commands/git_commands/pending_patches.go @@ -0,0 +1,118 @@ +package git_commands + +import ( + "bytes" + "fmt" + "mime" + "net/mail" + "path/filepath" + "strconv" + "strings" + + "github.com/jesseduffield/lazygit/pkg/commands/models" + "github.com/jesseduffield/lazygit/pkg/utils" + "github.com/stefanhaller/git-todo-parser/todo" +) + +// getPendingPatches returns the patches that "git am" hasn't applied yet; the +// last of them is the one that it stopped at. They usually come from another +// repository, so they have no hashes that we could show or look up. +func (self *CommitLoader) getPendingPatches(hashPool *utils.StringPool) []*models.Commit { + dir := filepath.Join(self.repoPaths.WorktreeGitDirPath(), "rebase-apply") + + readNumber := func(name string) (int, error) { + content, err := self.readFile(filepath.Join(dir, name)) + if err != nil { + return 0, err + } + return strconv.Atoi(strings.TrimSpace(string(content))) + } + + next, err := readNumber("next") + if err != nil { + self.Log.Errorf("error occurred reading rebase-apply/next: %s", err.Error()) + return nil + } + last, err := readNumber("last") + if err != nil { + self.Log.Errorf("error occurred reading rebase-apply/last: %s", err.Error()) + return nil + } + + commits := []*models.Commit{} + for number := next; number <= last; number++ { + // git names the patch files with (at least) four digits + patchFile := filepath.Join(dir, fmt.Sprintf("%04d", number)) + content, err := self.readFile(patchFile) + if err != nil { + self.Log.Errorf("error occurred reading %s: %s", patchFile, err.Error()) + continue + } + + status := models.StatusApplyingPatch + if number == next { + status = models.StatusConflicted + } + commits = utils.Prepend(commits, patchToCommit(hashPool, content, patchFile, status)) + } + + return commits +} + +// patchToCommit makes a commit for the list of pending patches from a patch +// file that "git am" has split off from the mailbox it is applying. +func patchToCommit(hashPool *utils.StringPool, content []byte, patchFile string, status models.CommitStatus) *models.Commit { + opts := models.NewCommitOpts{ + Status: status, + Action: todo.Pick, + PatchFile: patchFile, + } + + // The file starts with a line like "From Mon Sep 17 00:00:00 2001", + // which isn't a header + if bytes.HasPrefix(content, []byte("From ")) { + _, content, _ = bytes.Cut(content, []byte("\n")) + } + + message, err := mail.ReadMessage(bytes.NewReader(content)) + if err != nil { + return models.NewCommit(hashPool, opts) + } + + subject := message.Header.Get("Subject") + if decodedSubject, err := new(mime.WordDecoder).DecodeHeader(subject); err == nil { + subject = decodedSubject + } + opts.Name = cleanUpPatchSubject(subject) + + if author, err := mail.ParseAddress(message.Header.Get("From")); err == nil { + opts.AuthorName = author.Name + opts.AuthorEmail = author.Address + } + + if date, err := message.Header.Date(); err == nil { + opts.UnixTimestamp = date.Unix() + } + + return models.NewCommit(hashPool, opts) +} + +// cleanUpPatchSubject removes the parts of a patch's subject that "git am" +// doesn't use for the commit message, like "[PATCH 1/2]" or "Re:". +func cleanUpPatchSubject(subject string) string { + for { + subject = strings.TrimSpace(subject) + switch { + case strings.HasPrefix(subject, "["): + end := strings.Index(subject, "]") + if end < 0 { + return subject + } + subject = subject[end+1:] + case len(subject) >= 3 && strings.EqualFold(subject[:3], "re:"): + subject = subject[3:] + default: + return subject + } + } +} diff --git a/pkg/commands/git_commands/pending_patches_test.go b/pkg/commands/git_commands/pending_patches_test.go new file mode 100644 index 000000000..6e7ed9264 --- /dev/null +++ b/pkg/commands/git_commands/pending_patches_test.go @@ -0,0 +1,109 @@ +package git_commands + +import ( + "errors" + "path/filepath" + "testing" + + "github.com/jesseduffield/lazygit/pkg/commands/models" + "github.com/jesseduffield/lazygit/pkg/common" + "github.com/jesseduffield/lazygit/pkg/utils" + "github.com/samber/lo" + "github.com/stretchr/testify/assert" +) + +func TestGetPendingPatches(t *testing.T) { + dir := filepath.Join("/repo", ".git", "rebase-apply") + files := map[string]string{ + filepath.Join(dir, "next"): "2\n", + filepath.Join(dir, "last"): "3\n", + filepath.Join(dir, "0001"): "From 1111111 Mon Sep 17 00:00:00 2001\nSubject: [PATCH 1/3] one\n\n", + filepath.Join(dir, "0002"): "From 2222222 Mon Sep 17 00:00:00 2001\nSubject: [PATCH 2/3] two\n\n", + filepath.Join(dir, "0003"): "From 3333333 Mon Sep 17 00:00:00 2001\nSubject: [PATCH 3/3] three\n\n", + } + loader := &CommitLoader{ + Common: common.NewDummyCommon(), + GitCommon: &GitCommon{repoPaths: MockRepoPaths("/repo")}, + readFile: func(filename string) ([]byte, error) { + if content, ok := files[filename]; ok { + return []byte(content), nil + } + return nil, errors.New("no such file") + }, + } + + commits := loader.getPendingPatches(&utils.StringPool{}) + + assert.Equal(t, []string{"three", "two"}, lo.Map(commits, func(c *models.Commit, _ int) string { return c.Name })) + assert.Equal(t, []models.CommitStatus{models.StatusApplyingPatch, models.StatusConflicted}, + []models.CommitStatus{commits[0].Status, commits[1].Status}) + assert.Equal(t, filepath.Join(dir, "0003"), commits[0].PatchFile) + assert.Equal(t, filepath.Join(dir, "0002"), commits[1].PatchFile) +} + +func TestPatchToCommit(t *testing.T) { + scenarios := []struct { + testName string + content string + expectedName string + expectedAuthorName string + expectedAuthorEmail string + expectedUnixTimestamp int64 + }{ + { + testName: "patch made by git format-patch", + content: "From 1234567 Mon Sep 17 00:00:00 2001\n" + + "From: Jane Doe \n" + + "Date: Sat, 7 Nov 2026 11:25:00 +0100\n" + + "Subject: [PATCH 2/2] Add a file\n" + + "\n" + + "---\n" + + " file | 1 +\n", + expectedName: "Add a file", + expectedAuthorName: "Jane Doe", + expectedAuthorEmail: "jane@example.com", + expectedUnixTimestamp: 1794047100, + }, + { + testName: "subject and author name with non-ASCII characters", + content: "From 1234567 Mon Sep 17 00:00:00 2001\n" + + "From: =?UTF-8?q?J=C3=BCrgen?= \n" + + "Subject: [PATCH] =?UTF-8?q?Fix=20the=20=C3=A4?=\n" + + "\n", + expectedName: "Fix the ä", + expectedAuthorName: "Jürgen", + expectedAuthorEmail: "juergen@example.com", + }, + { + testName: "long subject that spans several lines", + content: "From 1234567 Mon Sep 17 00:00:00 2001\n" + + "Subject: [PATCH v2 1/3] A subject that is long enough to be\n" + + " folded\n" + + "\n", + expectedName: "A subject that is long enough to be folded", + }, + { + testName: "reply", + content: "Subject: Re: [PATCH] Fix it\n\n", + expectedName: "Fix it", + }, + { + testName: "not a mail message", + content: "garbage", + expectedName: "", + }, + } + + for _, s := range scenarios { + t.Run(s.testName, func(t *testing.T) { + commit := patchToCommit(&utils.StringPool{}, []byte(s.content), "/repo/.git/rebase-apply/0001", models.StatusApplyingPatch) + + assert.Equal(t, s.expectedName, commit.Name) + assert.Equal(t, s.expectedAuthorName, commit.AuthorName) + assert.Equal(t, s.expectedAuthorEmail, commit.AuthorEmail) + assert.Equal(t, s.expectedUnixTimestamp, commit.UnixTimestamp) + assert.Equal(t, "", commit.Hash()) + assert.Equal(t, "/repo/.git/rebase-apply/0001", commit.PatchFile) + }) + } +} diff --git a/pkg/commands/models/commit.go b/pkg/commands/models/commit.go index 69aca8d73..c5b72bf55 100644 --- a/pkg/commands/models/commit.go +++ b/pkg/commands/models/commit.go @@ -20,6 +20,7 @@ const ( StatusMerged StatusRebasing StatusCherryPickingOrReverting + StatusApplyingPatch StatusConflicted StatusReflog ) @@ -62,6 +63,10 @@ type Commit struct { Action todo.TodoCommand ActionFlag string // e.g. "-C" for fixup -C Divergence Divergence // set to DivergenceNone unless we are showing the divergence view + + // For a patch that "git am" hasn't applied yet, the file that contains + // it; empty for all other commits. + PatchFile string } type NewCommitOpts struct { @@ -77,6 +82,7 @@ type NewCommitOpts struct { UnixTimestamp int64 Divergence Divergence Parents []string + PatchFile string } func NewCommit(hashPool *utils.StringPool, opts NewCommitOpts) *Commit { @@ -92,6 +98,7 @@ func NewCommit(hashPool *utils.StringPool, opts NewCommitOpts) *Commit { AuthorEmail: opts.AuthorEmail, UnixTimestamp: opts.UnixTimestamp, Divergence: opts.Divergence, + PatchFile: opts.PatchFile, parents: lo.Map(opts.Parents, func(s string, _ int) *string { return hashPool.Add(s) }), } } diff --git a/pkg/commands/models/working_tree_state.go b/pkg/commands/models/working_tree_state.go index 827e84277..2207d3a77 100644 --- a/pkg/commands/models/working_tree_state.go +++ b/pkg/commands/models/working_tree_state.go @@ -128,7 +128,7 @@ func (self WorkingTreeState) CommandName() string { } func (self WorkingTreeState) CanShowTodos() bool { - return self.Rebasing || self.CherryPicking || self.Reverting + return self.Rebasing || self.CherryPicking || self.Reverting || self.ApplyingPatches } func (self WorkingTreeState) CanSkip() bool { diff --git a/pkg/gui/context/local_commits_context.go b/pkg/gui/context/local_commits_context.go index 6d63c1efd..ac3ae1ab2 100644 --- a/pkg/gui/context/local_commits_context.go +++ b/pkg/gui/context/local_commits_context.go @@ -227,6 +227,20 @@ func NewLocalCommitsContext(c *ContextCommon) *LocalCommitsContext { }) } + if c.Model().WorkingTreeStateAtLastCommitRefresh.ApplyingPatches { + _, firstPatch, found := lo.FindIndexOf( + c.Model().Commits, func(c *models.Commit) bool { + return c.PatchFile != "" + }) + if !found { + firstPatch = 0 + } + result = append(result, &NonModelItem{ + Index: firstPatch, + Content: formatListSectionHeader(c.Tr.PendingPatchesSectionHeader), + }) + } + result = addCommitDropIndicator( result, dropIndicator, diff --git a/pkg/gui/controllers/local_commits_controller.go b/pkg/gui/controllers/local_commits_controller.go index d74e44d18..9b2ae40b4 100644 --- a/pkg/gui/controllers/local_commits_controller.go +++ b/pkg/gui/controllers/local_commits_controller.go @@ -1,6 +1,7 @@ package controllers import ( + "os" "strings" "time" @@ -718,6 +719,14 @@ func (self *LocalCommitsController) GetOnRenderToMain() func() { } else if commit.Action == todo.Exec { task = types.NewRenderStringTask( self.c.Tr.ExecCommandHere + "\n\n" + commit.Name) + } else if commit.PatchFile != "" { + // We have no commit for a patch that "git am" hasn't applied + // yet, so we show the patch itself + patch, err := os.ReadFile(commit.PatchFile) + if err != nil { + self.c.Log.Error(err) + } + task = types.NewRenderStringTask(string(patch)) } else { refRange := self.context().GetSelectedRefRangeForDiffFiles() task = self.c.Helpers().Diff.GetUpdateTaskForRenderingCommitsDiff(commit, refRange) @@ -1181,13 +1190,16 @@ func (self *LocalCommitsController) isRebasing() bool { } // todosNotEditableReason returns why the todos in the commits panel can't be -// edited, if they can't. This is the case during a cherry-pick or revert; -// git has no way of changing their todos. +// edited, if they can't. This is the case during a cherry-pick or revert, or +// while applying patches with "git am"; git has no way of changing their todos. func (self *LocalCommitsController) todosNotEditableReason() *types.DisabledReason { if self.c.Model().WorkingTreeStateAtLastCommitRefresh.CherryPicking || self.c.Model().WorkingTreeStateAtLastCommitRefresh.Reverting { return &types.DisabledReason{Text: self.c.Tr.NotAllowedMidCherryPickOrRevert} } + if self.c.Model().WorkingTreeStateAtLastCommitRefresh.ApplyingPatches { + return &types.DisabledReason{Text: self.c.Tr.NotAllowedWhileApplyingPatches} + } return nil } diff --git a/pkg/gui/presentation/commits.go b/pkg/gui/presentation/commits.go index 8058ad03a..20660513d 100644 --- a/pkg/gui/presentation/commits.go +++ b/pkg/gui/presentation/commits.go @@ -554,7 +554,7 @@ func getHashColor( hashColor = style.FgYellow case models.StatusMerged: hashColor = style.FgGreen - case models.StatusRebasing, models.StatusCherryPickingOrReverting, models.StatusConflicted: + case models.StatusRebasing, models.StatusCherryPickingOrReverting, models.StatusApplyingPatch, models.StatusConflicted: hashColor = style.FgBlue case models.StatusReflog: hashColor = style.FgBlue diff --git a/pkg/i18n/english.go b/pkg/i18n/english.go index 3e813fd94..7a5a6d3d8 100644 --- a/pkg/i18n/english.go +++ b/pkg/i18n/english.go @@ -416,6 +416,7 @@ type TranslationSet struct { PendingRebaseTodosSectionHeader string PendingCherryPicksSectionHeader string PendingRevertsSectionHeader string + PendingPatchesSectionHeader string CommitsSectionHeader string MoveCommitsHere string MovingCommitsHere string @@ -423,6 +424,7 @@ type TranslationSet struct { RewordNotSupported string ChangingThisActionIsNotAllowed string NotAllowedMidCherryPickOrRevert string + NotAllowedWhileApplyingPatches string PickIsOnlyAllowedDuringRebase string DroppingMergeRequiresSingleSelection string CherryPickCopy string @@ -1650,6 +1652,7 @@ func EnglishTranslationSet() *TranslationSet { PendingRebaseTodosSectionHeader: "Pending rebase todos", PendingCherryPicksSectionHeader: "Pending cherry-picks", PendingRevertsSectionHeader: "Pending reverts", + PendingPatchesSectionHeader: "Pending patches", CommitsSectionHeader: "Commits", MoveCommitsHere: "drop here", MovingCommitsHere: "moving commits here", @@ -1657,6 +1660,7 @@ func EnglishTranslationSet() *TranslationSet { RewordNotSupported: "Rewording commits while interactively rebasing is not currently supported", ChangingThisActionIsNotAllowed: "Changing this kind of rebase todo entry is not allowed", NotAllowedMidCherryPickOrRevert: "This action is not allowed while cherry-picking or reverting", + NotAllowedWhileApplyingPatches: "This action is not allowed while applying patches", PickIsOnlyAllowedDuringRebase: "This action is only allowed while rebasing", DroppingMergeRequiresSingleSelection: "Dropping a merge commit requires a single selected item", CherryPickCopy: "Copy (cherry-pick)", diff --git a/pkg/integration/tests/am/continue_after_resolving_conflicts.go b/pkg/integration/tests/am/continue_after_resolving_conflicts.go index e817a750e..88c077b8b 100644 --- a/pkg/integration/tests/am/continue_after_resolving_conflicts.go +++ b/pkg/integration/tests/am/continue_after_resolving_conflicts.go @@ -24,6 +24,27 @@ var ContinueAfterResolvingConflicts = NewIntegrationTest(NewIntegrationTestArgs{ Contains("UU").Contains("file"), ) + t.Views().Commits(). + Focus(). + Lines( + Contains("─── Pending patches"), + Contains("pick").Contains("add file2"), + Contains("pick").Contains("<-- CONFLICT --- change file").IsSelected(), + Contains("─── Commits"), + Contains("conflicting change"), + Contains("base"), + ). + NavigateToLine(Contains("add file2")). + Tap(func() { + t.Views().Main().Content( + Contains("Subject: [PATCH 2/2] add file2"). + Contains("+two")) + }). + Press(keys.Universal.Remove). + Tap(func() { + t.ExpectToast(Equals("Disabled: This action is not allowed while applying patches")) + }) + t.Shell().UpdateFileAndAdd("file", "resolved\n") t.GlobalPress(keys.Universal.CreateRebaseOptionsMenu)