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/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/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 3e7bdbf1b..2207d3a77 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,53 +67,70 @@ 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()] +} + +// 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, + 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()] } 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 { - return self.Rebasing || self.CherryPicking || self.Reverting + return self.Rebasing || self.CherryPicking || self.Reverting || self.ApplyingPatches } 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/helpers/merge_and_rebase_helper.go b/pkg/gui/controllers/helpers/merge_and_rebase_helper.go index 106652ff3..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) @@ -283,7 +290,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 +302,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 +316,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 +334,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/gui/controllers/local_commits_controller.go b/pkg/gui/controllers/local_commits_controller.go index 1489e5e71..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) @@ -1180,9 +1189,18 @@ 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, 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 } func (self *LocalCommitsController) moveDown(selectedCommits []*models.Commit, startIdx int, endIdx int) error { @@ -1936,8 +1954,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 +1977,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 +2005,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 +2053,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() { 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 05a74b0fe..7a5a6d3d8 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 @@ -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 @@ -386,7 +388,7 @@ type TranslationSet struct { InformationTitle string SecondaryTitle string ReflogCommitsTitle string - ConflictsResolved string + ContinueAfterConflictsResolved string Continue string UnstagedFilesAfterConflictsResolved string RebasingTitle string @@ -414,6 +416,7 @@ type TranslationSet struct { PendingRebaseTodosSectionHeader string PendingCherryPicksSectionHeader string PendingRevertsSectionHeader string + PendingPatchesSectionHeader string CommitsSectionHeader string MoveCommitsHere string MovingCommitsHere string @@ -421,6 +424,7 @@ type TranslationSet struct { RewordNotSupported string ChangingThisActionIsNotAllowed string NotAllowedMidCherryPickOrRevert string + NotAllowedWhileApplyingPatches string PickIsOnlyAllowedDuringRebase string DroppingMergeRequiresSingleSelection string CherryPickCopy string @@ -475,6 +479,13 @@ type TranslationSet struct { LowercaseMergingStatus string LowercaseCherryPickingStatus string LowercaseRevertingStatus string + RebaseOperationName string + MergeOperationName string + CherryPickOperationName string + RevertOperationName string + ApplyPatchesOperationName string + LowercaseApplyingPatchesStatus string + ApplyingPatchesStatus string AmendingStatus string CherryPickingStatus string UndoingStatus string @@ -878,8 +889,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,13 +1582,14 @@ 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", 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", @@ -1590,6 +1602,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", @@ -1606,7 +1619,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", @@ -1639,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", @@ -1646,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)", @@ -1700,6 +1715,13 @@ 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", + ApplyPatchesOperationName: "applying patches", + LowercaseApplyingPatchesStatus: "applying patches", // lowercase because it shows up in parentheses + ApplyingPatchesStatus: "Applying patches", AmendingStatus: "Amending", CherryPickingStatus: "Cherry-picking", UndoingStatus: "Undoing", @@ -2100,8 +2122,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", 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..88c077b8b --- /dev/null +++ b/pkg/integration/tests/am/continue_after_resolving_conflicts.go @@ -0,0 +1,72 @@ +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.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) + 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,