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 <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 20c6ae4c86
commit df21b8cca0
10 changed files with 294 additions and 4 deletions
@@ -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 {
@@ -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 <hash> 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
}
}
}
@@ -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 <jane@example.com>\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?= <juergen@example.com>\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)
})
}
}
+7
View File
@@ -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) }),
}
}
+1 -1
View File
@@ -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 {
+14
View File
@@ -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,
@@ -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
}
+1 -1
View File
@@ -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
+4
View File
@@ -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)",
@@ -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)