1
0
mirror of https://github.com/jesseduffield/lazygit.git synced 2024-11-24 08:52:21 +02:00

Add command to find base commit for creating a fixup

This commit is contained in:
Stefan Haller 2023-08-01 14:54:56 +02:00
parent 33f933ba21
commit 8ca78412ac
23 changed files with 420 additions and 0 deletions

View File

@ -209,6 +209,7 @@ keybinding:
commitChangesWithoutHook: 'w' # commit changes without pre-commit hook
amendLastCommit: 'A'
commitChangesWithEditor: 'C'
findBaseCommitForFixup: '<c-f>'
confirmDiscard: 'x'
ignoreFile: 'i'
refreshFiles: 'r'

64
docs/Fixup_Commits.md Normal file
View File

@ -0,0 +1,64 @@
# Fixup Commits
## Background
There's this common scenario that you have a PR in review, the reviewer is
requesting some changes, and you make those changes and would normally simply
squash them into the original commit that they came from. If you do that,
however, there's no way for the reviewer to see what you changed. You could just
make a separate commit with those changes at the end of the branch, but this is
not ideal because it results in a git history that is not very clean.
To help with this, git has a concept of fixup commits: you do make a separate
commit, but the subject of this commit is the string "fixup! " followed by the
original commit subject. This both tells the reviewer what's going on (you are
making a change that you later will squash into the designated commit), and it
provides an easy way to actually perform this squash operation when you are
ready to do that (before merging).
## Creating fixup commits
You could of course create fixup commits manually by typing in the commit
message with the prefix yourself. But lazygit has an easier way to do that:
in the Commits view, select the commit that you want to create a fixup for, and
press shift-F (for "Create fixup commit for this commit"). This automatically
creates a commit with the appropriate subject line.
Don't confuse this with the lowercase "f" command ("Fixup commit"); that one
squashes the selected commit into its parent, this is not what we want here.
## Squashing fixup commits
When you're ready to merge the branch and want to squash all these fixup commits
that you created, that's very easy to do: select the first commit of your branch
and hit shift-S (for "Squash all 'fixup!' commits above selected commit
(autosquash)"). Boom, done.
## Finding the commit to create a fixup for
When you are making changes to code that you changed earlier in a long branch,
it can be tedious to find the commit to squash it into. Lazygit has a command to
help you with this, too: in the Files view, press ctrl-f to select the right
base commit in the Commits view automatically. From there, you can either press
shift-F to create a fixup commit for it, or shift-A to amend your changes into
the commit if you haven't published your branch yet.
This command works in many cases, and when it does it almost feels like magic,
but it's important to understand its limitations because it doesn't always work.
The way it works is that it looks at the deleted lines of your current
modifications, blames them to find out which commit those lines come from, and
if they all come from the same commit, it selects it. So here are cases where it
doesn't work:
- Your current diff has only added lines, but no deleted lines. In this case
there's no way for lazygit to know which commit you want to add them to.
- The deleted lines belong to multiple different commits. In this case you can
help lazygit by staging a set of files or hunks that all belong to the same
commit; if some changes are staged, the ctrl-f command works only on those.
- The found commit is already on master; in this case, lazygit refuses to select
it, because it doesn't make sense to create fixups for it, let alone amend to
it.
To sum it up: the command works great if you are changing code again that you
changed or added earlier in the same branch. This is a common enough case to
make the command useful.

View File

@ -123,6 +123,7 @@ _Legend: `<c-b>` means ctrl+b, `<a-b>` means alt+b, `B` means shift+b_
<kbd>w</kbd>: Commit changes without pre-commit hook
<kbd>A</kbd>: Amend last commit
<kbd>C</kbd>: Commit changes using git editor
<kbd>&lt;c-f&gt;</kbd>: Find base commit for fixup
<kbd>e</kbd>: Edit file
<kbd>o</kbd>: Open file
<kbd>i</kbd>: Ignore or exclude file

View File

@ -196,6 +196,7 @@ _Legend: `<c-b>` means ctrl+b, `<a-b>` means alt+b, `B` means shift+b_
<kbd>w</kbd>: pre-commitフックを実行せずに変更をコミット
<kbd>A</kbd>: 最新のコミットにamend
<kbd>C</kbd>: gitエディタを使用して変更をコミット
<kbd>&lt;c-f&gt;</kbd>: Find base commit for fixup
<kbd>e</kbd>: ファイルを編集
<kbd>o</kbd>: ファイルを開く
<kbd>i</kbd>: ファイルをignore

View File

@ -336,6 +336,7 @@ _Legend: `<c-b>` means ctrl+b, `<a-b>` means alt+b, `B` means shift+b_
<kbd>w</kbd>: Commit changes without pre-commit hook
<kbd>A</kbd>: 마지맛 커밋 수정
<kbd>C</kbd>: Git 편집기를 사용하여 변경 내용을 커밋합니다.
<kbd>&lt;c-f&gt;</kbd>: Find base commit for fixup
<kbd>e</kbd>: 파일 편집
<kbd>o</kbd>: 파일 닫기
<kbd>i</kbd>: Ignore file

View File

@ -56,6 +56,7 @@ _Legend: `<c-b>` means ctrl+b, `<a-b>` means alt+b, `B` means shift+b_
<kbd>w</kbd>: Commit veranderingen zonder pre-commit hook
<kbd>A</kbd>: Wijzig laatste commit
<kbd>C</kbd>: Commit veranderingen met de git editor
<kbd>&lt;c-f&gt;</kbd>: Find base commit for fixup
<kbd>e</kbd>: Verander bestand
<kbd>o</kbd>: Open bestand
<kbd>i</kbd>: Ignore or exclude file

View File

@ -157,6 +157,7 @@ _Legend: `<c-b>` means ctrl+b, `<a-b>` means alt+b, `B` means shift+b_
<kbd>w</kbd>: Zatwierdź zmiany bez skryptu pre-commit
<kbd>A</kbd>: Zmień ostatni commit
<kbd>C</kbd>: Zatwierdź zmiany używając edytora
<kbd>&lt;c-f&gt;</kbd>: Find base commit for fixup
<kbd>e</kbd>: Edytuj plik
<kbd>o</kbd>: Otwórz plik
<kbd>i</kbd>: Ignore or exclude file

View File

@ -330,6 +330,7 @@ _Связки клавиш_
<kbd>w</kbd>: Закоммитить изменения без предварительного хука коммита
<kbd>A</kbd>: Правка последнего коммита
<kbd>C</kbd>: Сохранить изменения с помощью редактора git
<kbd>&lt;c-f&gt;</kbd>: Find base commit for fixup
<kbd>e</kbd>: Редактировать файл
<kbd>o</kbd>: Открыть файл
<kbd>i</kbd>: Игнорировать или исключить файл

View File

@ -204,6 +204,7 @@ _Legend: `<c-b>` means ctrl+b, `<a-b>` means alt+b, `B` means shift+b_
<kbd>w</kbd>: 提交更改而无需预先提交钩子
<kbd>A</kbd>: 修补最后一次提交
<kbd>C</kbd>: 提交更改(使用编辑器编辑提交信息)
<kbd>&lt;c-f&gt;</kbd>: Find base commit for fixup
<kbd>e</kbd>: 编辑文件
<kbd>o</kbd>: 打开文件
<kbd>i</kbd>: 忽略文件

View File

@ -299,6 +299,7 @@ _說明:`<c-b>` 表示 Ctrl+B、`<a-b>` 表示 Alt+B,`B`表示 Shift+B_
<kbd>w</kbd>: 沒有預提交 hook 就提交更改
<kbd>A</kbd>: 修正上次提交
<kbd>C</kbd>: 使用 git 編輯器提交變更
<kbd>&lt;c-f&gt;</kbd>: Find base commit for fixup
<kbd>e</kbd>: 編輯檔案
<kbd>o</kbd>: 開啟檔案
<kbd>i</kbd>: 忽略或排除檔案

View File

@ -21,6 +21,7 @@ import (
// GitCommand is our main git interface
type GitCommand struct {
Blame *git_commands.BlameCommands
Branch *git_commands.BranchCommands
Commit *git_commands.CommitCommands
Config *git_commands.ConfigCommands
@ -160,6 +161,7 @@ func NewGitCommandAux(
patchCommands := git_commands.NewPatchCommands(gitCommon, rebaseCommands, commitCommands, statusCommands, stashCommands, patchBuilder)
bisectCommands := git_commands.NewBisectCommands(gitCommon)
worktreeCommands := git_commands.NewWorktreeCommands(gitCommon)
blameCommands := git_commands.NewBlameCommands(gitCommon)
branchLoader := git_commands.NewBranchLoader(cmn, cmd, branchCommands.CurrentBranchInfo, configCommands)
commitFileLoader := git_commands.NewCommitFileLoader(cmn, cmd)
@ -171,6 +173,7 @@ func NewGitCommandAux(
tagLoader := git_commands.NewTagLoader(cmn, cmd)
return &GitCommand{
Blame: blameCommands,
Branch: branchCommands,
Commit: commitCommands,
Config: configCommands,

View File

@ -0,0 +1,33 @@
package git_commands
import (
"fmt"
)
type BlameCommands struct {
*GitCommon
}
func NewBlameCommands(gitCommon *GitCommon) *BlameCommands {
return &BlameCommands{
GitCommon: gitCommon,
}
}
// Blame a range of lines. For each line, output the hash of the commit where
// the line last changed, then a space, then a description of the commit (author
// and date), another space, and then the line. For example:
//
// ac90ebac688fe8bc2ffd922157a9d2c54681d2aa (Stefan Haller 2023-08-01 14:54:56 +0200 11) func NewBlameCommands(gitCommon *GitCommon) *BlameCommands {
// ac90ebac688fe8bc2ffd922157a9d2c54681d2aa (Stefan Haller 2023-08-01 14:54:56 +0200 12) return &BlameCommands{
// ac90ebac688fe8bc2ffd922157a9d2c54681d2aa (Stefan Haller 2023-08-01 14:54:56 +0200 13) GitCommon: gitCommon,
func (self *BlameCommands) BlameLineRange(filename string, commit string, firstLine int, numLines int) (string, error) {
cmdArgs := NewGitCmd("blame").
Arg("-l").
Arg(fmt.Sprintf("-L%d,+%d", firstLine, numLines)).
Arg(commit).
Arg("--").
Arg(filename)
return self.cmd.New(cmdArgs.ToArgv()).RunWithOutput()
}

View File

@ -198,6 +198,20 @@ func (self *CommitCommands) GetCommitMessagesFirstLine(shas []string) (string, e
return self.cmd.New(cmdArgs).DontLog().RunWithOutput()
}
// Example output:
//
// cd50c79ae Preserve the commit message correctly even if the description has blank lines
// 3ebba5f32 Add test demonstrating a bug with preserving the commit message
// 9a423c388 Remove unused function
func (self *CommitCommands) GetShasAndCommitMessagesFirstLine(shas []string) (string, error) {
cmdArgs := NewGitCmd("show").
Arg("--no-patch", "--pretty=format:%h %s").
Arg(shas...).
ToArgv()
return self.cmd.New(cmdArgs).DontLog().RunWithOutput()
}
func (self *CommitCommands) GetCommitsOneline(shas []string) (string, error) {
cmdArgs := NewGitCmd("show").
Arg("--no-patch", "--oneline").

View File

@ -78,3 +78,11 @@ func (self *DiffCommands) OpenDiffToolCmdObj(opts DiffToolCmdOptions) oscommands
Arg("--", opts.Filepath).
ToArgv())
}
func (self *DiffCommands) DiffIndexCmdObj(diffArgs ...string) oscommands.ICmdObj {
return self.cmd.New(
NewGitCmd("diff-index").
Arg("--submodule", "--no-ext-diff", "--no-color", "--patch").
Arg(diffArgs...).ToArgv(),
)
}

View File

@ -366,6 +366,7 @@ type KeybindingFilesConfig struct {
CommitChangesWithoutHook string `yaml:"commitChangesWithoutHook"`
AmendLastCommit string `yaml:"amendLastCommit"`
CommitChangesWithEditor string `yaml:"commitChangesWithEditor"`
FindBaseCommitForFixup string `yaml:"findBaseCommitForFixup"`
ConfirmDiscard string `yaml:"confirmDiscard"`
IgnoreFile string `yaml:"ignoreFile"`
RefreshFiles string `yaml:"refreshFiles"`
@ -762,6 +763,7 @@ func GetDefaultConfig() *UserConfig {
CommitChangesWithoutHook: "w",
AmendLastCommit: "A",
CommitChangesWithEditor: "C",
FindBaseCommitForFixup: "<c-f>",
IgnoreFile: "i",
RefreshFiles: "r",
StashAllChanges: "s",

View File

@ -103,6 +103,7 @@ func (gui *Gui) resetHelpersAndControllers() {
CherryPick: cherryPickHelper,
Upstream: helpers.NewUpstreamHelper(helperCommon, suggestionsHelper.GetRemoteBranchesSuggestionsFunc),
AmendHelper: helpers.NewAmendHelper(helperCommon, gpgHelper),
FixupHelper: helpers.NewFixupHelper(helperCommon),
Commits: commitsHelper,
Snake: helpers.NewSnakeHelper(helperCommon),
Diff: diffHelper,

View File

@ -64,6 +64,12 @@ func (self *FilesController) GetKeybindings(opts types.KeybindingsOpts) []*types
Handler: self.c.Helpers().WorkingTree.HandleCommitEditorPress,
Description: self.c.Tr.CommitChangesWithEditor,
},
{
Key: opts.GetKey(opts.Config.Files.FindBaseCommitForFixup),
Handler: self.c.Helpers().FixupHelper.HandleFindBaseCommitForFixupPress,
Description: self.c.Tr.FindBaseCommitForFixup,
Tooltip: self.c.Tr.FindBaseCommitForFixupTooltip,
},
{
Key: opts.GetKey(opts.Config.Universal.Edit),
Handler: self.checkSelectedFileNode(self.edit),

View File

@ -0,0 +1,178 @@
package helpers
import (
"regexp"
"strings"
"sync"
"github.com/jesseduffield/generics/set"
"github.com/jesseduffield/lazygit/pkg/commands/models"
"github.com/jesseduffield/lazygit/pkg/gui/types"
"github.com/jesseduffield/lazygit/pkg/utils"
"github.com/samber/lo"
)
type FixupHelper struct {
c *HelperCommon
}
func NewFixupHelper(
c *HelperCommon,
) *FixupHelper {
return &FixupHelper{
c: c,
}
}
type deletedLineInfo struct {
filename string
startLineIdx int
numLines int
}
func (self *FixupHelper) HandleFindBaseCommitForFixupPress() error {
diff, hasStagedChanges, err := self.getDiff()
if err != nil {
return err
}
if diff == "" {
return self.c.ErrorMsg(self.c.Tr.NoChangedFiles)
}
deletedLineInfos := self.parseDiff(diff)
if len(deletedLineInfos) == 0 {
return self.c.ErrorMsg(self.c.Tr.NoDeletedLinesInDiff)
}
shas := self.blameDeletedLines(deletedLineInfos)
if len(shas) == 0 {
// This should never happen
return self.c.ErrorMsg(self.c.Tr.NoBaseCommitsFound)
}
if len(shas) > 1 {
subjects, err := self.c.Git().Commit.GetShasAndCommitMessagesFirstLine(shas)
if err != nil {
return err
}
message := lo.Ternary(hasStagedChanges,
self.c.Tr.MultipleBaseCommitsFoundStaged,
self.c.Tr.MultipleBaseCommitsFoundUnstaged)
return self.c.ErrorMsg(message + "\n\n" + subjects)
}
commit, index, ok := lo.FindIndexOf(self.c.Model().Commits, func(commit *models.Commit) bool {
return commit.Sha == shas[0]
})
if !ok {
commits := self.c.Model().Commits
if commits[len(commits)-1].Status == models.StatusMerged {
// If the commit is not found, it's most likely because it's already
// merged, and more than 300 commits away. Check if the last known
// commit is already merged; if so, show the "already merged" error.
return self.c.ErrorMsg(self.c.Tr.BaseCommitIsAlreadyOnMainBranch)
}
// If we get here, the current branch must have more then 300 commits. Unlikely...
return self.c.ErrorMsg(self.c.Tr.BaseCommitIsNotInCurrentView)
}
if commit.Status == models.StatusMerged {
return self.c.ErrorMsg(self.c.Tr.BaseCommitIsAlreadyOnMainBranch)
}
if !useIndex {
if err := self.c.Git().WorkingTree.StageAll(); err != nil {
return err
}
_ = self.c.Refresh(types.RefreshOptions{Mode: types.SYNC, Scope: []types.RefreshableView{types.FILES}})
}
self.c.Contexts().LocalCommits.SetSelectedLineIdx(index)
return self.c.PushContext(self.c.Contexts().LocalCommits)
}
func (self *FixupHelper) getDiff() (string, bool, error) {
args := []string{"-U0", "--ignore-submodules=all", "HEAD", "--"}
// Try staged changes first
hasStagedChanges := true
diff, err := self.c.Git().Diff.DiffIndexCmdObj(append([]string{"--cached"}, args...)...).RunWithOutput()
if err == nil && diff == "" {
hasStagedChanges = false
// If there are no staged changes, try unstaged changes
diff, err = self.c.Git().Diff.DiffIndexCmdObj(args...).RunWithOutput()
}
return diff, hasStagedChanges, err
}
func (self *FixupHelper) parseDiff(diff string) []*deletedLineInfo {
lines := strings.Split(strings.TrimSuffix(diff, "\n"), "\n")
deletedLineInfos := []*deletedLineInfo{}
hunkHeaderRegexp := regexp.MustCompile(`@@ -(\d+)(?:,\d+)? \+\d+(?:,\d+)? @@`)
var filename string
var currentLineInfo *deletedLineInfo
finishHunk := func() {
if currentLineInfo != nil && currentLineInfo.numLines > 0 {
deletedLineInfos = append(deletedLineInfos, currentLineInfo)
}
}
for _, line := range lines {
if strings.HasPrefix(line, "diff --git") {
finishHunk()
currentLineInfo = nil
} else if strings.HasPrefix(line, "--- ") {
// For some reason, the line ends with a tab character if the file
// name contains spaces
filename = strings.TrimRight(line[6:], "\t")
} else if strings.HasPrefix(line, "@@ ") {
finishHunk()
match := hunkHeaderRegexp.FindStringSubmatch(line)
startIdx := utils.MustConvertToInt(match[1])
currentLineInfo = &deletedLineInfo{filename, startIdx, 0}
} else if currentLineInfo != nil && line[0] == '-' {
currentLineInfo.numLines++
}
}
finishHunk()
return deletedLineInfos
}
// returns the list of commit hashes that introduced the lines which have now been deleted
func (self *FixupHelper) blameDeletedLines(deletedLineInfos []*deletedLineInfo) []string {
var wg sync.WaitGroup
shaChan := make(chan string)
for _, info := range deletedLineInfos {
wg.Add(1)
go func(info *deletedLineInfo) {
defer wg.Done()
blameOutput, err := self.c.Git().Blame.BlameLineRange(info.filename, "HEAD", info.startLineIdx, info.numLines)
if err != nil {
self.c.Log.Errorf("Error blaming file '%s': %v", info.filename, err)
return
}
blameLines := strings.Split(strings.TrimSuffix(blameOutput, "\n"), "\n")
for _, line := range blameLines {
shaChan <- strings.Split(line, " ")[0]
}
}(info)
}
go func() {
wg.Wait()
close(shaChan)
}()
result := set.New[string]()
for sha := range shaChan {
result.Add(sha)
}
return result.ToSlice()
}

View File

@ -33,6 +33,7 @@ type Helpers struct {
GPG *GpgHelper
Upstream *UpstreamHelper
AmendHelper *AmendHelper
FixupHelper *FixupHelper
Commits *CommitsHelper
Snake *SnakeHelper
// lives in context package because our contexts need it to render to main
@ -70,6 +71,7 @@ func NewStubHelpers() *Helpers {
GPG: &GpgHelper{},
Upstream: &UpstreamHelper{},
AmendHelper: &AmendHelper{},
FixupHelper: &FixupHelper{},
Commits: &CommitsHelper{},
Snake: &SnakeHelper{},
Diff: &DiffHelper{},

View File

@ -39,6 +39,14 @@ type TranslationSet struct {
SureToAmend string
NoCommitToAmend string
CommitChangesWithEditor string
FindBaseCommitForFixup string
FindBaseCommitForFixupTooltip string
NoDeletedLinesInDiff string
NoBaseCommitsFound string
MultipleBaseCommitsFoundStaged string
MultipleBaseCommitsFoundUnstaged string
BaseCommitIsAlreadyOnMainBranch string
BaseCommitIsNotInCurrentView string
StatusTitle string
GlobalTitle string
Menu string
@ -858,6 +866,14 @@ func EnglishTranslationSet() TranslationSet {
SureToAmend: "Are you sure you want to amend last commit? Afterwards, you can change the commit message from the commits panel.",
NoCommitToAmend: "There's no commit to amend.",
CommitChangesWithEditor: "Commit changes using git editor",
FindBaseCommitForFixup: "Find base commit for fixup",
FindBaseCommitForFixupTooltip: "Find the commit that your current changes are building upon, for the sake of amending/fixing up the commit. This spares you from having to look through your branch's commits one-by-one to see which commit should be amended/fixed up. See docs: <https://github.com/jesseduffield/lazygit/tree/master/docs/Fixup_Commits.md>",
NoDeletedLinesInDiff: "No deleted lines in diff",
NoBaseCommitsFound: "No base commits found",
MultipleBaseCommitsFoundStaged: "Multiple base commits found. (Try staging fewer changes at once)",
MultipleBaseCommitsFoundUnstaged: "Multiple base commits found. (Try staging some of the changes)",
BaseCommitIsAlreadyOnMainBranch: "The base commit for this change is already on the main branch",
BaseCommitIsNotInCurrentView: "Base commit is not in current view",
StatusTitle: "Status",
Menu: "Menu",
Execute: "Execute",

View File

@ -0,0 +1,79 @@
package commit
import (
"github.com/jesseduffield/lazygit/pkg/config"
. "github.com/jesseduffield/lazygit/pkg/integration/components"
)
var FindBaseCommitForFixup = NewIntegrationTest(NewIntegrationTestArgs{
Description: "Finds the base commit to create a fixup for",
ExtraCmdArgs: []string{},
Skip: false,
SetupConfig: func(config *config.AppConfig) {},
SetupRepo: func(shell *Shell) {
shell.NewBranch("mybranch").
EmptyCommit("1st commit").
CreateFileAndAdd("file1", "file1 content\n").
Commit("2nd commit").
CreateFileAndAdd("file2", "file2 content\n").
Commit("3rd commit").
UpdateFile("file1", "file1 changed content").
UpdateFile("file2", "file2 changed content")
},
Run: func(t *TestDriver, keys config.KeybindingConfig) {
t.Views().Commits().
Lines(
Contains("3rd commit"),
Contains("2nd commit"),
Contains("1st commit"),
)
// Two changes from different commits: this fails
t.Views().Files().
Focus().
Press(keys.Files.FindBaseCommitForFixup)
t.ExpectPopup().Alert().
Title(Equals("Error")).
Content(
Contains("Multiple base commits found").
Contains("2nd commit").
Contains("3rd commit"),
).
Confirm()
// Stage only one of the files: this succeeds
t.Views().Files().
IsFocused().
NavigateToLine(Contains("file1")).
PressPrimaryAction().
Press(keys.Files.FindBaseCommitForFixup)
t.Views().Commits().
IsFocused().
Lines(
Contains("3rd commit"),
Contains("2nd commit").IsSelected(),
Contains("1st commit"),
).
Press(keys.Commits.AmendToCommit)
t.ExpectPopup().Confirmation().
Title(Equals("Amend commit")).
Content(Contains("Are you sure you want to amend this commit with your staged files?")).
Confirm()
// Now only the other file is modified (and unstaged); this works now
t.Views().Files().
Focus().
Press(keys.Files.FindBaseCommitForFixup)
t.Views().Commits().
IsFocused().
Lines(
Contains("3rd commit").IsSelected(),
Contains("2nd commit"),
Contains("1st commit"),
)
},
})

View File

@ -70,6 +70,7 @@ var tests = []*components.IntegrationTest{
commit.CommitWithPrefix,
commit.CreateTag,
commit.DiscardOldFileChange,
commit.FindBaseCommitForFixup,
commit.Highlight,
commit.History,
commit.HistoryComplex,

View File

@ -914,6 +914,10 @@
"type": "string",
"default": "C"
},
"findBaseCommitForFixup": {
"type": "string",
"default": "\u003cc-f\u003e"
},
"confirmDiscard": {
"type": "string",
"default": "x"