mirror of
https://github.com/jesseduffield/lazygit.git
synced 2026-11-13 08:13:32 -04:00
When a "git am" in progress, let the user continue, skip or abort it (#6196)
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".
This commit is contained in:
@@ -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)
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -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"))
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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) }),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 <operation>".
|
||||
func (self WorkingTreeState) OperationName(tr *i18n.TranslationSet) string {
|
||||
return map[EffectiveWorkingTreeState]string{
|
||||
WORKING_TREE_STATE_REBASING: tr.RebaseOperationName,
|
||||
WORKING_TREE_STATE_MERGING: tr.MergeOperationName,
|
||||
WORKING_TREE_STATE_CHERRY_PICKING: tr.CherryPickOperationName,
|
||||
WORKING_TREE_STATE_REVERTING: tr.RevertOperationName,
|
||||
WORKING_TREE_STATE_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
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
|
||||
@@ -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() {
|
||||
|
||||
@@ -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
|
||||
|
||||
+30
-8
@@ -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",
|
||||
|
||||
@@ -0,0 +1,38 @@
|
||||
package am
|
||||
|
||||
import (
|
||||
"github.com/jesseduffield/lazygit/pkg/config"
|
||||
. "github.com/jesseduffield/lazygit/pkg/integration/components"
|
||||
)
|
||||
|
||||
var AbortAfterConflictWhenContinuing = NewIntegrationTest(NewIntegrationTestArgs{
|
||||
Description: "Continue applying patches with git am, and abort when the next patch conflicts too",
|
||||
ExtraCmdArgs: []string{},
|
||||
Skip: false,
|
||||
SetupConfig: func(config *config.AppConfig) {},
|
||||
SetupRepo: func(shell *Shell) {
|
||||
setUpConflictingAm(shell, true, true)
|
||||
},
|
||||
Run: func(t *TestDriver, keys config.KeybindingConfig) {
|
||||
t.Shell().UpdateFileAndAdd("file", "resolved\n")
|
||||
|
||||
t.GlobalPress(keys.Universal.CreateRebaseOptionsMenu)
|
||||
t.ExpectPopup().Menu().
|
||||
Title(Equals("Applying patches")).
|
||||
Select(Contains("continue")).
|
||||
Confirm()
|
||||
|
||||
t.ExpectPopup().Menu().
|
||||
Title(Equals("Conflicts!")).
|
||||
Select(Contains("Abort applying patches")).
|
||||
Confirm()
|
||||
|
||||
t.Views().Status().Content(Equals("repo → master"))
|
||||
|
||||
t.Views().Commits().
|
||||
Lines(
|
||||
Contains("conflicting change"),
|
||||
Contains("base"),
|
||||
)
|
||||
},
|
||||
})
|
||||
@@ -0,0 +1,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"),
|
||||
)
|
||||
},
|
||||
})
|
||||
@@ -0,0 +1,34 @@
|
||||
package am
|
||||
|
||||
import (
|
||||
"github.com/jesseduffield/lazygit/pkg/config"
|
||||
. "github.com/jesseduffield/lazygit/pkg/integration/components"
|
||||
)
|
||||
|
||||
var ContinueWithoutResolving = NewIntegrationTest(NewIntegrationTestArgs{
|
||||
Description: "Continue applying patches with git am after a patch didn't apply, without doing anything about it",
|
||||
ExtraCmdArgs: []string{},
|
||||
Skip: false,
|
||||
SetupConfig: func(config *config.AppConfig) {},
|
||||
SetupRepo: func(shell *Shell) {
|
||||
setUpConflictingAm(shell, false, false)
|
||||
},
|
||||
Run: func(t *TestDriver, keys config.KeybindingConfig) {
|
||||
t.Views().Status().Content(Equals("(applying patches) repo → master"))
|
||||
|
||||
t.Views().Files().IsEmpty()
|
||||
|
||||
t.GlobalPress(keys.Universal.CreateRebaseOptionsMenu)
|
||||
t.ExpectPopup().Menu().
|
||||
Title(Equals("Applying patches")).
|
||||
Select(Contains("continue")).
|
||||
Confirm()
|
||||
|
||||
t.ExpectPopup().Alert().
|
||||
Title(Equals("Error")).
|
||||
Content(Contains("No changes - did you forget to use 'git add'?")).
|
||||
Confirm()
|
||||
|
||||
t.Views().Status().Content(Equals("(applying patches) repo → master"))
|
||||
},
|
||||
})
|
||||
@@ -0,0 +1,49 @@
|
||||
package am
|
||||
|
||||
import (
|
||||
"github.com/jesseduffield/lazygit/pkg/config"
|
||||
. "github.com/jesseduffield/lazygit/pkg/integration/components"
|
||||
"github.com/jesseduffield/lazygit/pkg/integration/tests/shared"
|
||||
)
|
||||
|
||||
var LockErrorPartwayThroughSkip = NewIntegrationTest(NewIntegrationTestArgs{
|
||||
Description: "Skip a conflicting patch while another git process takes index.lock before git am applies the patch after the next one",
|
||||
ExtraCmdArgs: []string{},
|
||||
Skip: false,
|
||||
SetupConfig: func(config *config.AppConfig) {},
|
||||
SetupRepo: func(shell *Shell) {
|
||||
shell.
|
||||
CreateFileAndAdd("file", "original\n").
|
||||
Commit("base").
|
||||
NewBranch("source").
|
||||
UpdateFileAndAdd("file", "source\n").
|
||||
Commit("change file").
|
||||
CreateFileAndAdd("file2", "two\n").
|
||||
Commit("add file2").
|
||||
CreateFileAndAdd("file3", "three\n").
|
||||
Commit("add file3").
|
||||
RunShellCommand("git format-patch --stdout master..source > ../patches.mbox").
|
||||
Checkout("master").
|
||||
UpdateFileAndAdd("file", "master\n").
|
||||
Commit("conflicting change").
|
||||
RunCommandExpectError([]string{"git", "am", "--3way", "../patches.mbox"})
|
||||
|
||||
// After skipping "change file", git am commits "add file2"; the lock
|
||||
// then stops it when it tries to apply "add file3".
|
||||
shared.HoldIndexLockAfterNextAppliedPatch(shell)
|
||||
},
|
||||
Run: func(t *TestDriver, keys config.KeybindingConfig) {
|
||||
t.GlobalPress(keys.Universal.CreateRebaseOptionsMenu)
|
||||
t.ExpectPopup().Menu().
|
||||
Title(Equals("Applying patches")).
|
||||
Select(Contains("skip")).
|
||||
Confirm()
|
||||
|
||||
t.ExpectPopup().Alert().
|
||||
Title(Equals("Error")).
|
||||
Content(Contains("index.lock': File exists.")).
|
||||
Confirm()
|
||||
|
||||
t.Views().Status().Content(Equals("(applying patches) repo → master"))
|
||||
},
|
||||
})
|
||||
@@ -0,0 +1,37 @@
|
||||
package am
|
||||
|
||||
import (
|
||||
. "github.com/jesseduffield/lazygit/pkg/integration/components"
|
||||
)
|
||||
|
||||
// setUpConflictingAm creates two commits on a branch, turns them into patches,
|
||||
// and applies these to master with "git am". The first patch conflicts. If
|
||||
// secondPatchConflicts is true, the second one conflicts with the resolution of
|
||||
// the first one too. With threeWay, "git am" gets the --3way option, so that
|
||||
// the conflicts show up in the files; otherwise it leaves the files alone.
|
||||
func setUpConflictingAm(shell *Shell, secondPatchConflicts bool, threeWay bool) {
|
||||
shell.
|
||||
CreateFileAndAdd("file", "original\n").
|
||||
Commit("base").
|
||||
NewBranch("source").
|
||||
UpdateFileAndAdd("file", "source\n").
|
||||
Commit("change file")
|
||||
|
||||
if secondPatchConflicts {
|
||||
shell.UpdateFileAndAdd("file", "source again\n").Commit("change file again")
|
||||
} else {
|
||||
shell.CreateFileAndAdd("file2", "two\n").Commit("add file2")
|
||||
}
|
||||
|
||||
shell.
|
||||
RunShellCommand("git format-patch --stdout master..source > ../patches.mbox").
|
||||
Checkout("master").
|
||||
UpdateFileAndAdd("file", "master\n").
|
||||
Commit("conflicting change")
|
||||
|
||||
amArgs := []string{"git", "am"}
|
||||
if threeWay {
|
||||
amArgs = append(amArgs, "--3way")
|
||||
}
|
||||
shell.RunCommandExpectError(append(amArgs, "../patches.mbox"))
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
package am
|
||||
|
||||
import (
|
||||
"github.com/jesseduffield/lazygit/pkg/config"
|
||||
. "github.com/jesseduffield/lazygit/pkg/integration/components"
|
||||
)
|
||||
|
||||
var SkipConflictingPatch = NewIntegrationTest(NewIntegrationTestArgs{
|
||||
Description: "Skip a patch that conflicts while applying patches with git am",
|
||||
ExtraCmdArgs: []string{},
|
||||
Skip: false,
|
||||
SetupConfig: func(config *config.AppConfig) {},
|
||||
SetupRepo: func(shell *Shell) {
|
||||
setUpConflictingAm(shell, false, true)
|
||||
},
|
||||
Run: func(t *TestDriver, keys config.KeybindingConfig) {
|
||||
t.Views().Status().Content(Equals("(applying patches) repo → master"))
|
||||
|
||||
t.GlobalPress(keys.Universal.CreateRebaseOptionsMenu)
|
||||
t.ExpectPopup().Menu().
|
||||
Title(Equals("Applying patches")).
|
||||
Select(Contains("skip")).
|
||||
Confirm()
|
||||
|
||||
t.Views().Status().Content(Equals("repo → master"))
|
||||
|
||||
t.Views().Commits().
|
||||
Lines(
|
||||
Contains("add file2"),
|
||||
Contains("conflicting change"),
|
||||
Contains("base"),
|
||||
)
|
||||
},
|
||||
})
|
||||
@@ -42,3 +42,11 @@ awk '$3 == "CHERRY_PICK_HEAD" && $2 !~ /^0+$/ { found = 1 } END { exit !found }'
|
||||
`+holdIndexLockScript)
|
||||
shell.MakeExecutable(".git/hooks/reference-transaction")
|
||||
}
|
||||
|
||||
// HoldIndexLockAfterNextAppliedPatch installs a post-applypatch hook that takes
|
||||
// index.lock (see holdIndexLockScript) after "git am" has committed the next
|
||||
// patch.
|
||||
func HoldIndexLockAfterNextAppliedPatch(shell *Shell) {
|
||||
shell.CreateFile(".git/hooks/post-applypatch", "#!/bin/sh\n"+holdIndexLockScript)
|
||||
shell.MakeExecutable(".git/hooks/post-applypatch")
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user