Skip to content

Commit dc9ee18

Browse files
authored
Adjust selection after squashing fixups (#3338)
- **PR Description** Keep the same commit selected after squashing fixup commits, and after creating fixup commits. Edge case: it is now possible to have a range of commits selected when squashing fixups (by using the "in current branch" version of the command), and in that case we don't bother preserving the range. It would be possible, but would require more code, and I don't think it's worth it, so I'm simply collapsing the range in that case.
2 parents bbc6802 + bb26979 commit dc9ee18

File tree

6 files changed

+277
-20
lines changed

6 files changed

+277
-20
lines changed

pkg/gui/controllers/local_commits_controller.go

Lines changed: 72 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ package controllers
22

33
import (
44
"fmt"
5+
"strings"
56

67
"github.com/fsmiamoto/git-todo-parser/todo"
78
"github.com/go-errors/errors"
@@ -811,11 +812,14 @@ func (self *LocalCommitsController) createFixupCommit(commit *models.Commit) err
811812
HandleConfirm: func() error {
812813
return self.c.Helpers().WorkingTree.WithEnsureCommitableFiles(func() error {
813814
self.c.LogAction(self.c.Tr.Actions.CreateFixupCommit)
814-
if err := self.c.Git().Commit.CreateFixupCommit(commit.Sha); err != nil {
815-
return self.c.Error(err)
816-
}
815+
return self.c.WithWaitingStatusSync(self.c.Tr.CreatingFixupCommitStatus, func() error {
816+
if err := self.c.Git().Commit.CreateFixupCommit(commit.Sha); err != nil {
817+
return self.c.Error(err)
818+
}
817819

818-
return self.c.Refresh(types.RefreshOptions{Mode: types.ASYNC})
820+
self.context().MoveSelectedLine(1)
821+
return self.c.Refresh(types.RefreshOptions{Mode: types.SYNC})
822+
})
819823
})
820824
},
821825
})
@@ -844,37 +848,89 @@ func (self *LocalCommitsController) squashFixupCommits() error {
844848
}
845849

846850
func (self *LocalCommitsController) squashAllFixupsAboveSelectedCommit(commit *models.Commit) error {
847-
return self.c.WithWaitingStatus(self.c.Tr.SquashingStatus, func(gocui.Task) error {
848-
self.c.LogAction(self.c.Tr.Actions.SquashAllAboveFixupCommits)
849-
err := self.c.Git().Rebase.SquashAllAboveFixupCommits(commit)
850-
return self.c.Helpers().MergeAndRebase.CheckMergeOrRebase(err)
851-
})
851+
return self.squashFixupsImpl(commit, self.context().GetSelectedLineIdx())
852852
}
853853

854854
func (self *LocalCommitsController) squashAllFixupsInCurrentBranch() error {
855-
commit, err := self.findCommitForSquashFixupsInCurrentBranch()
855+
commit, rebaseStartIdx, err := self.findCommitForSquashFixupsInCurrentBranch()
856856
if err != nil {
857857
return self.c.Error(err)
858858
}
859859

860-
return self.c.WithWaitingStatus(self.c.Tr.SquashingStatus, func(gocui.Task) error {
860+
return self.squashFixupsImpl(commit, rebaseStartIdx)
861+
}
862+
863+
func (self *LocalCommitsController) squashFixupsImpl(commit *models.Commit, rebaseStartIdx int) error {
864+
selectionOffset := countSquashableCommitsAbove(self.c.Model().Commits, self.context().GetSelectedLineIdx(), rebaseStartIdx)
865+
return self.c.WithWaitingStatusSync(self.c.Tr.SquashingStatus, func() error {
861866
self.c.LogAction(self.c.Tr.Actions.SquashAllAboveFixupCommits)
862867
err := self.c.Git().Rebase.SquashAllAboveFixupCommits(commit)
863-
return self.c.Helpers().MergeAndRebase.CheckMergeOrRebase(err)
868+
self.context().MoveSelectedLine(-selectionOffset)
869+
return self.c.Helpers().MergeAndRebase.CheckMergeOrRebaseWithRefreshOptions(
870+
err, types.RefreshOptions{Mode: types.SYNC})
864871
})
865872
}
866873

867-
func (self *LocalCommitsController) findCommitForSquashFixupsInCurrentBranch() (*models.Commit, error) {
874+
func (self *LocalCommitsController) findCommitForSquashFixupsInCurrentBranch() (*models.Commit, int, error) {
868875
commits := self.c.Model().Commits
869876
_, index, ok := lo.FindIndexOf(commits, func(c *models.Commit) bool {
870877
return c.IsMerge() || c.Status == models.StatusMerged
871878
})
872879

873880
if !ok || index == 0 {
874-
return nil, errors.New(self.c.Tr.CannotSquashCommitsInCurrentBranch)
881+
return nil, -1, errors.New(self.c.Tr.CannotSquashCommitsInCurrentBranch)
882+
}
883+
884+
return commits[index-1], index - 1, nil
885+
}
886+
887+
// Anticipate how many commits above the selectedIdx are going to get squashed
888+
// by the SquashAllAboveFixupCommits call, so that we can adjust the selection
889+
// afterwards. Let's hope we're matching git's behavior correctly here.
890+
func countSquashableCommitsAbove(commits []*models.Commit, selectedIdx int, rebaseStartIdx int) int {
891+
result := 0
892+
893+
// For each commit _above_ the selection, ...
894+
for i, commit := range commits[0:selectedIdx] {
895+
// ... see if it is a fixup commit, and get the base subject it applies to
896+
if baseSubject, isFixup := isFixupCommit(commit.Name); isFixup {
897+
// Then, for each commit after the fixup, up to and including the
898+
// rebase start commit, see if we find the base commit
899+
for _, baseCommit := range commits[i+1 : rebaseStartIdx+1] {
900+
if strings.HasPrefix(baseCommit.Name, baseSubject) {
901+
result++
902+
}
903+
}
904+
}
905+
}
906+
return result
907+
}
908+
909+
// Check whether the given subject line is the subject of a fixup commit, and
910+
// returns (trimmedSubject, true) if so (where trimmedSubject is the subject
911+
// with all fixup prefixes removed), or (subject, false) if not.
912+
func isFixupCommit(subject string) (string, bool) {
913+
prefixes := []string{"fixup! ", "squash! ", "amend! "}
914+
trimPrefix := func(s string) (string, bool) {
915+
for _, prefix := range prefixes {
916+
if strings.HasPrefix(s, prefix) {
917+
return strings.TrimPrefix(s, prefix), true
918+
}
919+
}
920+
return s, false
921+
}
922+
923+
if subject, wasTrimmed := trimPrefix(subject); wasTrimmed {
924+
for {
925+
// handle repeated prefixes like "fixup! amend! fixup! Subject"
926+
if subject, wasTrimmed = trimPrefix(subject); !wasTrimmed {
927+
break
928+
}
929+
}
930+
return subject, true
875931
}
876932

877-
return commits[index-1], nil
933+
return subject, false
878934
}
879935

880936
func (self *LocalCommitsController) createTag(commit *models.Commit) error {
@@ -1067,7 +1123,7 @@ func (self *LocalCommitsController) canFindCommitForQuickStart() *types.Disabled
10671123
}
10681124

10691125
func (self *LocalCommitsController) canFindCommitForSquashFixupsInCurrentBranch() *types.DisabledReason {
1070-
if _, err := self.findCommitForSquashFixupsInCurrentBranch(); err != nil {
1126+
if _, _, err := self.findCommitForSquashFixupsInCurrentBranch(); err != nil {
10711127
return &types.DisabledReason{Text: err.Error()}
10721128
}
10731129

Lines changed: 141 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,141 @@
1+
package controllers
2+
3+
import (
4+
"testing"
5+
6+
"github.com/jesseduffield/lazygit/pkg/commands/models"
7+
"github.com/stretchr/testify/assert"
8+
)
9+
10+
func Test_countSquashableCommitsAbove(t *testing.T) {
11+
scenarios := []struct {
12+
name string
13+
commits []*models.Commit
14+
selectedIdx int
15+
rebaseStartIdx int
16+
expectedResult int
17+
}{
18+
{
19+
name: "no squashable commits",
20+
commits: []*models.Commit{
21+
{Name: "abc"},
22+
{Name: "def"},
23+
{Name: "ghi"},
24+
},
25+
selectedIdx: 2,
26+
rebaseStartIdx: 2,
27+
expectedResult: 0,
28+
},
29+
{
30+
name: "some squashable commits, including for the selected commit",
31+
commits: []*models.Commit{
32+
{Name: "fixup! def"},
33+
{Name: "fixup! ghi"},
34+
{Name: "abc"},
35+
{Name: "def"},
36+
{Name: "ghi"},
37+
},
38+
selectedIdx: 4,
39+
rebaseStartIdx: 4,
40+
expectedResult: 2,
41+
},
42+
{
43+
name: "base commit is below rebase start",
44+
commits: []*models.Commit{
45+
{Name: "fixup! def"},
46+
{Name: "abc"},
47+
{Name: "def"},
48+
},
49+
selectedIdx: 1,
50+
rebaseStartIdx: 1,
51+
expectedResult: 0,
52+
},
53+
{
54+
name: "base commit does not exist at all",
55+
commits: []*models.Commit{
56+
{Name: "fixup! xyz"},
57+
{Name: "abc"},
58+
{Name: "def"},
59+
},
60+
selectedIdx: 2,
61+
rebaseStartIdx: 2,
62+
expectedResult: 0,
63+
},
64+
{
65+
name: "selected commit is in the middle of fixups",
66+
commits: []*models.Commit{
67+
{Name: "fixup! def"},
68+
{Name: "abc"},
69+
{Name: "fixup! ghi"},
70+
{Name: "def"},
71+
{Name: "ghi"},
72+
},
73+
selectedIdx: 1,
74+
rebaseStartIdx: 4,
75+
expectedResult: 1,
76+
},
77+
{
78+
name: "selected commit is after rebase start",
79+
commits: []*models.Commit{
80+
{Name: "fixup! def"},
81+
{Name: "abc"},
82+
{Name: "def"},
83+
{Name: "ghi"},
84+
},
85+
selectedIdx: 3,
86+
rebaseStartIdx: 2,
87+
expectedResult: 1,
88+
},
89+
}
90+
for _, s := range scenarios {
91+
t.Run(s.name, func(t *testing.T) {
92+
assert.Equal(t, s.expectedResult, countSquashableCommitsAbove(s.commits, s.selectedIdx, s.rebaseStartIdx))
93+
})
94+
}
95+
}
96+
97+
func Test_isFixupCommit(t *testing.T) {
98+
scenarios := []struct {
99+
subject string
100+
expectedTrimmedSubject string
101+
expectedIsFixup bool
102+
}{
103+
{
104+
subject: "Bla",
105+
expectedTrimmedSubject: "Bla",
106+
expectedIsFixup: false,
107+
},
108+
{
109+
subject: "fixup Bla",
110+
expectedTrimmedSubject: "fixup Bla",
111+
expectedIsFixup: false,
112+
},
113+
{
114+
subject: "fixup! Bla",
115+
expectedTrimmedSubject: "Bla",
116+
expectedIsFixup: true,
117+
},
118+
{
119+
subject: "fixup! fixup! Bla",
120+
expectedTrimmedSubject: "Bla",
121+
expectedIsFixup: true,
122+
},
123+
{
124+
subject: "amend! squash! Bla",
125+
expectedTrimmedSubject: "Bla",
126+
expectedIsFixup: true,
127+
},
128+
{
129+
subject: "fixup!",
130+
expectedTrimmedSubject: "fixup!",
131+
expectedIsFixup: false,
132+
},
133+
}
134+
for _, s := range scenarios {
135+
t.Run(s.subject, func(t *testing.T) {
136+
trimmedSubject, isFixupCommit := isFixupCommit(s.subject)
137+
assert.Equal(t, s.expectedTrimmedSubject, trimmedSubject)
138+
assert.Equal(t, s.expectedIsFixup, isFixupCommit)
139+
})
140+
}
141+
}

pkg/i18n/english.go

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -341,6 +341,7 @@ type TranslationSet struct {
341341
CheckingOutStatus string
342342
CommittingStatus string
343343
RevertingStatus string
344+
CreatingFixupCommitStatus string
344345
CommitFiles string
345346
SubCommitsDynamicTitle string
346347
CommitFilesDynamicTitle string
@@ -1289,6 +1290,7 @@ func EnglishTranslationSet() TranslationSet {
12891290
CheckingOutStatus: "Checking out",
12901291
CommittingStatus: "Committing",
12911292
RevertingStatus: "Reverting",
1293+
CreatingFixupCommitStatus: "Creating fixup commit",
12921294
CommitFiles: "Commit files",
12931295
SubCommitsDynamicTitle: "Commits (%s)",
12941296
CommitFilesDynamicTitle: "Diff files (%s)",
Lines changed: 56 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,56 @@
1+
package interactive_rebase
2+
3+
import (
4+
"github.com/jesseduffield/lazygit/pkg/config"
5+
. "github.com/jesseduffield/lazygit/pkg/integration/components"
6+
)
7+
8+
var SquashFixupsAbove = NewIntegrationTest(NewIntegrationTestArgs{
9+
Description: "Squashes all fixups above a commit and checks that the selected line stays correct.",
10+
ExtraCmdArgs: []string{},
11+
Skip: false,
12+
SetupConfig: func(config *config.AppConfig) {},
13+
SetupRepo: func(shell *Shell) {
14+
shell.
15+
CreateNCommits(3).
16+
CreateFileAndAdd("fixup-file", "fixup content")
17+
},
18+
Run: func(t *TestDriver, keys config.KeybindingConfig) {
19+
t.Views().Commits().
20+
Focus().
21+
Lines(
22+
Contains("commit 03"),
23+
Contains("commit 02"),
24+
Contains("commit 01"),
25+
).
26+
NavigateToLine(Contains("commit 02")).
27+
Press(keys.Commits.CreateFixupCommit).
28+
Tap(func() {
29+
t.ExpectPopup().Confirmation().
30+
Title(Equals("Create fixup commit")).
31+
Content(Contains("Are you sure you want to create a fixup! commit for commit")).
32+
Confirm()
33+
}).
34+
Lines(
35+
Contains("fixup! commit 02"),
36+
Contains("commit 03"),
37+
Contains("commit 02").IsSelected(),
38+
Contains("commit 01"),
39+
).
40+
Press(keys.Commits.SquashAboveCommits).
41+
Tap(func() {
42+
t.ExpectPopup().Menu().
43+
Title(Equals("Apply fixup commits")).
44+
Select(Contains("Above the selected commit")).
45+
Confirm()
46+
}).
47+
Lines(
48+
Contains("commit 03"),
49+
Contains("commit 02").IsSelected(),
50+
Contains("commit 01"),
51+
)
52+
53+
t.Views().Main().
54+
Content(Contains("fixup content"))
55+
},
56+
})

pkg/integration/tests/interactive_rebase/squash_fixups_in_current_branch.go

Lines changed: 5 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -27,10 +27,12 @@ var SquashFixupsInCurrentBranch = NewIntegrationTest(NewIntegrationTestArgs{
2727
Run: func(t *TestDriver, keys config.KeybindingConfig) {
2828
t.Views().Commits().
2929
Focus().
30+
SelectNextItem().
31+
SelectNextItem().
3032
Lines(
3133
Contains("fixup! commit 01"),
3234
Contains("commit 02"),
33-
Contains("commit 01"),
35+
Contains("commit 01").IsSelected(),
3436
Contains("fixup! master commit"),
3537
Contains("master commit"),
3638
).
@@ -43,11 +45,10 @@ var SquashFixupsInCurrentBranch = NewIntegrationTest(NewIntegrationTestArgs{
4345
}).
4446
Lines(
4547
Contains("commit 02"),
46-
Contains("commit 01"),
48+
Contains("commit 01").IsSelected(),
4749
Contains("fixup! master commit"),
4850
Contains("master commit"),
49-
).
50-
NavigateToLine(Contains("commit 01"))
51+
)
5152

5253
t.Views().Main().
5354
Content(Contains("fixup content"))

pkg/integration/tests/test_list.go

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -188,6 +188,7 @@ var tests = []*components.IntegrationTest{
188188
interactive_rebase.RewordYouAreHereCommitWithEditor,
189189
interactive_rebase.SquashDownFirstCommit,
190190
interactive_rebase.SquashDownSecondCommit,
191+
interactive_rebase.SquashFixupsAbove,
191192
interactive_rebase.SquashFixupsAboveFirstCommit,
192193
interactive_rebase.SquashFixupsInCurrentBranch,
193194
interactive_rebase.SwapInRebaseWithConflict,

0 commit comments

Comments
 (0)