From df21b8cca0847a837607b2fa59b90c39d602d17e Mon Sep 17 00:00:00 2001 From: Stefan Haller Date: Sat, 7 Nov 2026 12:03:41 +0100 Subject: [PATCH] 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)