From 5dacbb6293bcff48649fd9fcae6af8e1b76d23ad Mon Sep 17 00:00:00 2001 From: Jesse Duffield Date: Tue, 2 May 2023 19:05:42 +1000 Subject: [PATCH] merge master into refactor-better-encapsulation --- README.md | 2 +- docs/Config.md | 1 + go.mod | 2 +- go.sum | 4 +- pkg/app/daemon/daemon.go | 318 ++++++++++++++---- pkg/app/daemon/rebase.go | 64 ++++ pkg/commands/git_commands/deps_test.go | 6 + pkg/commands/git_commands/patch.go | 16 +- pkg/commands/git_commands/rebase.go | 217 +++++------- pkg/commands/git_commands/rebase_test.go | 3 +- pkg/config/user_config.go | 78 ++--- pkg/gui/context.go | 10 +- .../controllers/local_commits_controller.go | 14 +- pkg/gui/gui_common.go | 4 + pkg/gui/layout.go | 2 +- pkg/gui/presentation/commits.go | 2 +- pkg/gui/types/common.go | 1 + .../interactive_rebase/amend_fixup_commit.go | 50 +++ .../drop_todo_commit_with_update_ref.go | 25 +- ...ommit_with_update_ref_show_branch_heads.go | 62 ++++ pkg/integration/tests/test_list.go | 2 + pkg/utils/rebase_todo.go | 76 +++++ pkg/utils/rebase_todo_test.go | 108 ++++++ .../fsmiamoto/git-todo-parser/todo/parse.go | 10 +- vendor/modules.txt | 2 +- 25 files changed, 792 insertions(+), 287 deletions(-) create mode 100644 pkg/app/daemon/rebase.go create mode 100644 pkg/integration/tests/interactive_rebase/amend_fixup_commit.go create mode 100644 pkg/integration/tests/interactive_rebase/drop_todo_commit_with_update_ref_show_branch_heads.go diff --git a/README.md b/README.md index bb30e8aef..2e9483a54 100644 --- a/README.md +++ b/README.md @@ -22,7 +22,7 @@ A simple terminal UI for git commands, written in Go with the [gocui](https://gi

- +

## Elevator Pitch diff --git a/docs/Config.md b/docs/Config.md index 837230367..34e89c130 100644 --- a/docs/Config.md +++ b/docs/Config.md @@ -57,6 +57,7 @@ gui: showFileTree: true # for rendering changes files in a tree format showListFooter: true # for seeing the '5 of 20' message in list panels showRandomTip: true + experimentalShowBranchHeads: false # visualize branch heads with (*) in commits list showBottomLine: true # for hiding the bottom information line (unless it has important information to tell you) showCommandLog: true showIcons: false diff --git a/go.mod b/go.mod index f86062a69..098d19c67 100644 --- a/go.mod +++ b/go.mod @@ -9,7 +9,7 @@ require ( github.com/cli/safeexec v1.0.0 github.com/cloudfoundry/jibber_jabber v0.0.0-20151120183258-bcc4c8345a21 github.com/creack/pty v1.1.11 - github.com/fsmiamoto/git-todo-parser v0.0.4-0.20230403011024-617a5a7ce980 + github.com/fsmiamoto/git-todo-parser v0.0.4 github.com/fsnotify/fsnotify v1.4.7 github.com/gdamore/tcell/v2 v2.6.0 github.com/go-errors/errors v1.4.2 diff --git a/go.sum b/go.sum index e6a83d627..dd4a8b81e 100644 --- a/go.sum +++ b/go.sum @@ -30,8 +30,8 @@ github.com/fatih/color v1.7.1-0.20180516100307-2d684516a886/go.mod h1:Zm6kSWBoL9 github.com/fatih/color v1.9.0 h1:8xPHl4/q1VyqGIPif1F+1V3Y3lSmrq01EabUW3CoW5s= github.com/fatih/color v1.9.0/go.mod h1:eQcE1qtQxscV5RaZvpXrrb8Drkc3/DdQ+uUYCNjL+zU= github.com/flynn/go-shlex v0.0.0-20150515145356-3f9db97f8568/go.mod h1:xEzjJPgXI435gkrCt3MPfRiAkVrwSbHsst4LCFVfpJc= -github.com/fsmiamoto/git-todo-parser v0.0.4-0.20230403011024-617a5a7ce980 h1:ay9aM+Ay9I4LJttUVF4EFVmeNUkS9/snYVFK6lwieVQ= -github.com/fsmiamoto/git-todo-parser v0.0.4-0.20230403011024-617a5a7ce980/go.mod h1:B+AgTbNE2BARvJqzXygThzqxLIaEWvwr2sxKYYb0Fas= +github.com/fsmiamoto/git-todo-parser v0.0.4 h1:fzcGaoAFDHWzJRKw//CSZFrXucsLKplIvOSab3FtWWM= +github.com/fsmiamoto/git-todo-parser v0.0.4/go.mod h1:B+AgTbNE2BARvJqzXygThzqxLIaEWvwr2sxKYYb0Fas= github.com/fsnotify/fsnotify v1.4.7 h1:IXs+QLmnXW2CcXuY+8Mzv/fWEsPGWxqefPtCP5CnV9I= github.com/fsnotify/fsnotify v1.4.7/go.mod h1:jwhsz4b93w/PPRr/qN1Yymfu8t87LnFCMoQvtojpjFo= github.com/gdamore/encoding v1.0.0 h1:+7OoQ1Bc6eTm5niUzBa0Ctsh6JbMW6Ra+YNuAtDBdko= diff --git a/pkg/app/daemon/daemon.go b/pkg/app/daemon/daemon.go index e040e79c2..adc287309 100644 --- a/pkg/app/daemon/daemon.go +++ b/pkg/app/daemon/daemon.go @@ -1,13 +1,17 @@ package daemon import ( + "encoding/json" + "fmt" "log" "os" - "path/filepath" - "strings" + "strconv" + "github.com/fsmiamoto/git-todo-parser/todo" + "github.com/jesseduffield/lazygit/pkg/commands/models" "github.com/jesseduffield/lazygit/pkg/common" - "github.com/jesseduffield/lazygit/pkg/env" + "github.com/jesseduffield/lazygit/pkg/utils" + "github.com/samber/lo" ) // Sometimes lazygit will be invoked in daemon mode from a parent lazygit process. @@ -15,38 +19,58 @@ import ( // For example, if we want to ensure that a git command doesn't hang due to // waiting for an editor to save a commit message, we can tell git to invoke lazygit // as the editor via 'GIT_EDITOR=lazygit', and use the env var -// 'LAZYGIT_DAEMON_KIND=EXIT_IMMEDIATELY' to specify that we want to run lazygit -// as a daemon which simply exits immediately. Any additional arguments we want -// to pass to a daemon can be done via other env vars. +// 'LAZYGIT_DAEMON_KIND=1' (exit immediately) to specify that we want to run lazygit +// as a daemon which simply exits immediately. +// +// 'Daemon' is not the best name for this, because it's not a persistent background +// process, but it's close enough. -type DaemonKind string +type DaemonKind int const ( - InteractiveRebase DaemonKind = "INTERACTIVE_REBASE" - ExitImmediately DaemonKind = "EXIT_IMMEDIATELY" + // for when we fail to parse the daemon kind + DaemonKindUnknown DaemonKind = iota + + DaemonKindExitImmediately + DaemonKindCherryPick + DaemonKindMoveTodoUp + DaemonKindMoveTodoDown + DaemonKindInsertBreak + DaemonKindChangeTodoActions + DaemonKindMoveFixupCommitDown ) const ( DaemonKindEnvKey string = "LAZYGIT_DAEMON_KIND" - RebaseTODOEnvKey string = "LAZYGIT_REBASE_TODO" - // The `PrependLinesEnvKey` env variable is set to `true` to tell our daemon - // to prepend the content of `RebaseTODOEnvKey` to the default `git-rebase-todo` - // file instead of using it as a replacement. - PrependLinesEnvKey string = "LAZYGIT_PREPEND_LINES" + // Contains json-encoded arguments to the daemon + DaemonInstructionEnvKey string = "LAZYGIT_DAEMON_INSTRUCTION" ) -type Daemon interface { - Run() error +func getInstruction() Instruction { + jsonData := os.Getenv(DaemonInstructionEnvKey) + + mapping := map[DaemonKind]func(string) Instruction{ + DaemonKindExitImmediately: deserializeInstruction[*ExitImmediatelyInstruction], + DaemonKindCherryPick: deserializeInstruction[*CherryPickCommitsInstruction], + DaemonKindChangeTodoActions: deserializeInstruction[*ChangeTodoActionsInstruction], + DaemonKindMoveFixupCommitDown: deserializeInstruction[*MoveFixupCommitDownInstruction], + DaemonKindMoveTodoUp: deserializeInstruction[*MoveTodoUpInstruction], + DaemonKindMoveTodoDown: deserializeInstruction[*MoveTodoDownInstruction], + DaemonKindInsertBreak: deserializeInstruction[*InsertBreakInstruction], + } + + return mapping[getDaemonKind()](jsonData) } func Handle(common *common.Common) { - d := getDaemon(common) - if d == nil { + if !InDaemonMode() { return } - if err := d.Run(); err != nil { + instruction := getInstruction() + + if err := instruction.run(common); err != nil { log.Fatal(err) } @@ -54,73 +78,229 @@ func Handle(common *common.Common) { } func InDaemonMode() bool { - return getDaemonKind() != "" -} - -func getDaemon(common *common.Common) Daemon { - switch getDaemonKind() { - case InteractiveRebase: - return &rebaseDaemon{c: common} - case ExitImmediately: - return &exitImmediatelyDaemon{c: common} - } - - return nil + return getDaemonKind() != DaemonKindUnknown } func getDaemonKind() DaemonKind { - return DaemonKind(os.Getenv(DaemonKindEnvKey)) -} - -type rebaseDaemon struct { - c *common.Common -} - -func (self *rebaseDaemon) Run() error { - self.c.Log.Info("Lazygit invoked as interactive rebase demon") - self.c.Log.Info("args: ", os.Args) - path := os.Args[1] - - if strings.HasSuffix(path, "git-rebase-todo") { - return self.writeTodoFile(path) - } else if strings.HasSuffix(path, filepath.Join(gitDir(), "COMMIT_EDITMSG")) { // TODO: test - // if we are rebasing and squashing, we'll see a COMMIT_EDITMSG - // but in this case we don't need to edit it, so we'll just return - } else { - self.c.Log.Info("Lazygit demon did not match on any use cases") + intValue, err := strconv.Atoi(os.Getenv(DaemonKindEnvKey)) + if err != nil { + return DaemonKindUnknown } + return DaemonKind(intValue) +} + +// An Instruction is a command to be run by lazygit in daemon mode. +// It is serialized to json and passed to lazygit via environment variables +type Instruction interface { + Kind() DaemonKind + SerializedInstructions() string + + // runs the instruction + run(common *common.Common) error +} + +func serializeInstruction[T any](instruction T) string { + jsonData, err := json.Marshal(instruction) + if err != nil { + // this should never happen + panic(err) + } + + return string(jsonData) +} + +func deserializeInstruction[T Instruction](jsonData string) Instruction { + var instruction T + err := json.Unmarshal([]byte(jsonData), &instruction) + if err != nil { + panic(err) + } + + return instruction +} + +func ToEnvVars(instruction Instruction) []string { + return []string{ + fmt.Sprintf("%s=%d", DaemonKindEnvKey, instruction.Kind()), + fmt.Sprintf("%s=%s", DaemonInstructionEnvKey, instruction.SerializedInstructions()), + } +} + +type ExitImmediatelyInstruction struct{} + +func (self *ExitImmediatelyInstruction) Kind() DaemonKind { + return DaemonKindExitImmediately +} + +func (self *ExitImmediatelyInstruction) SerializedInstructions() string { + return serializeInstruction(self) +} + +func (self *ExitImmediatelyInstruction) run(common *common.Common) error { return nil } -func (self *rebaseDaemon) writeTodoFile(path string) error { - todoContent := []byte(os.Getenv(RebaseTODOEnvKey)) +func NewExitImmediatelyInstruction() Instruction { + return &ExitImmediatelyInstruction{} +} - prependLines := os.Getenv(PrependLinesEnvKey) != "" - if prependLines { - existingContent, err := os.ReadFile(path) - if err != nil { - return err +type CherryPickCommitsInstruction struct { + Todo string +} + +func NewCherryPickCommitsInstruction(commits []*models.Commit) Instruction { + todoLines := lo.Map(commits, func(commit *models.Commit, _ int) TodoLine { + return TodoLine{ + Action: "pick", + Commit: commit, + } + }) + + todo := TodoLinesToString(todoLines) + + return &CherryPickCommitsInstruction{ + Todo: todo, + } +} + +func (self *CherryPickCommitsInstruction) Kind() DaemonKind { + return DaemonKindCherryPick +} + +func (self *CherryPickCommitsInstruction) SerializedInstructions() string { + return serializeInstruction(self) +} + +func (self *CherryPickCommitsInstruction) run(common *common.Common) error { + return handleInteractiveRebase(common, func(path string) error { + return utils.PrependStrToTodoFile(path, []byte(self.Todo)) + }) +} + +type ChangeTodoActionsInstruction struct { + Changes []ChangeTodoAction +} + +func NewChangeTodoActionsInstruction(changes []ChangeTodoAction) Instruction { + return &ChangeTodoActionsInstruction{ + Changes: changes, + } +} + +func (self *ChangeTodoActionsInstruction) Kind() DaemonKind { + return DaemonKindChangeTodoActions +} + +func (self *ChangeTodoActionsInstruction) SerializedInstructions() string { + return serializeInstruction(self) +} + +func (self *ChangeTodoActionsInstruction) run(common *common.Common) error { + return handleInteractiveRebase(common, func(path string) error { + for _, c := range self.Changes { + if err := utils.EditRebaseTodo(path, c.Sha, todo.Pick, c.NewAction); err != nil { + return err + } } - todoContent = append(todoContent, existingContent...) + return nil + }) +} + +// Takes the sha of some commit, and the sha of a fixup commit that was created +// at the end of the branch, then moves the fixup commit down to right after the +// original commit, changing its type to "fixup" +type MoveFixupCommitDownInstruction struct { + OriginalSha string + FixupSha string +} + +func NewMoveFixupCommitDownInstruction(originalSha string, fixupSha string) Instruction { + return &MoveFixupCommitDownInstruction{ + OriginalSha: originalSha, + FixupSha: fixupSha, } - - return os.WriteFile(path, todoContent, 0o644) } -func gitDir() string { - dir := env.GetGitDirEnv() - if dir == "" { - return ".git" +func (self *MoveFixupCommitDownInstruction) Kind() DaemonKind { + return DaemonKindMoveFixupCommitDown +} + +func (self *MoveFixupCommitDownInstruction) SerializedInstructions() string { + return serializeInstruction(self) +} + +func (self *MoveFixupCommitDownInstruction) run(common *common.Common) error { + return handleInteractiveRebase(common, func(path string) error { + return utils.MoveFixupCommitDown(path, self.OriginalSha, self.FixupSha) + }) +} + +type MoveTodoUpInstruction struct { + Sha string +} + +func NewMoveTodoUpInstruction(sha string) Instruction { + return &MoveTodoUpInstruction{ + Sha: sha, } - return dir } -type exitImmediatelyDaemon struct { - c *common.Common +func (self *MoveTodoUpInstruction) Kind() DaemonKind { + return DaemonKindMoveTodoUp } -func (self *exitImmediatelyDaemon) Run() error { - return nil +func (self *MoveTodoUpInstruction) SerializedInstructions() string { + return serializeInstruction(self) +} + +func (self *MoveTodoUpInstruction) run(common *common.Common) error { + return handleInteractiveRebase(common, func(path string) error { + return utils.MoveTodoUp(path, self.Sha, todo.Pick) + }) +} + +type MoveTodoDownInstruction struct { + Sha string +} + +func NewMoveTodoDownInstruction(sha string) Instruction { + return &MoveTodoDownInstruction{ + Sha: sha, + } +} + +func (self *MoveTodoDownInstruction) Kind() DaemonKind { + return DaemonKindMoveTodoDown +} + +func (self *MoveTodoDownInstruction) SerializedInstructions() string { + return serializeInstruction(self) +} + +func (self *MoveTodoDownInstruction) run(common *common.Common) error { + return handleInteractiveRebase(common, func(path string) error { + return utils.MoveTodoDown(path, self.Sha, todo.Pick) + }) +} + +type InsertBreakInstruction struct{} + +func NewInsertBreakInstruction() Instruction { + return &InsertBreakInstruction{} +} + +func (self *InsertBreakInstruction) Kind() DaemonKind { + return DaemonKindInsertBreak +} + +func (self *InsertBreakInstruction) SerializedInstructions() string { + return serializeInstruction(self) +} + +func (self *InsertBreakInstruction) run(common *common.Common) error { + return handleInteractiveRebase(common, func(path string) error { + return utils.PrependStrToTodoFile(path, []byte("break\n")) + }) } diff --git a/pkg/app/daemon/rebase.go b/pkg/app/daemon/rebase.go new file mode 100644 index 000000000..8702f0f69 --- /dev/null +++ b/pkg/app/daemon/rebase.go @@ -0,0 +1,64 @@ +package daemon + +import ( + "os" + "path/filepath" + "strings" + + "github.com/fsmiamoto/git-todo-parser/todo" + "github.com/jesseduffield/generics/slices" + "github.com/jesseduffield/lazygit/pkg/commands/models" + "github.com/jesseduffield/lazygit/pkg/common" + "github.com/jesseduffield/lazygit/pkg/env" +) + +type TodoLine struct { + Action string + Commit *models.Commit +} + +func (self *TodoLine) ToString() string { + if self.Action == "break" { + return self.Action + "\n" + } else { + return self.Action + " " + self.Commit.Sha + " " + self.Commit.Name + "\n" + } +} + +func TodoLinesToString(todoLines []TodoLine) string { + lines := slices.Map(todoLines, func(todoLine TodoLine) string { + return todoLine.ToString() + }) + + return strings.Join(slices.Reverse(lines), "") +} + +type ChangeTodoAction struct { + Sha string + NewAction todo.TodoCommand +} + +func handleInteractiveRebase(common *common.Common, f func(path string) error) error { + common.Log.Info("Lazygit invoked as interactive rebase demon") + common.Log.Info("args: ", os.Args) + path := os.Args[1] + + if strings.HasSuffix(path, "git-rebase-todo") { + return f(path) + } else if strings.HasSuffix(path, filepath.Join(gitDir(), "COMMIT_EDITMSG")) { // TODO: test + // if we are rebasing and squashing, we'll see a COMMIT_EDITMSG + // but in this case we don't need to edit it, so we'll just return + } else { + common.Log.Info("Lazygit demon did not match on any use cases") + } + + return nil +} + +func gitDir() string { + dir := env.GetGitDirEnv() + if dir == "" { + return ".git" + } + return dir +} diff --git a/pkg/commands/git_commands/deps_test.go b/pkg/commands/git_commands/deps_test.go index df2c96253..bcf36b168 100644 --- a/pkg/commands/git_commands/deps_test.go +++ b/pkg/commands/git_commands/deps_test.go @@ -15,6 +15,7 @@ import ( type commonDeps struct { runner *oscommands.FakeCmdObjRunner userConfig *config.UserConfig + gitVersion *GitVersion gitConfig *git_config.FakeGitConfig getenv func(string) string removeFile func(string) error @@ -48,6 +49,11 @@ func buildGitCommon(deps commonDeps) *GitCommon { gitCommon.Common.UserConfig = config.GetDefaultConfig() } + gitCommon.version = deps.gitVersion + if gitCommon.version == nil { + gitCommon.version = &GitVersion{2, 0, 0, ""} + } + gitConfig := deps.gitConfig if gitConfig == nil { gitConfig = git_config.NewFakeGitConfig(nil) diff --git a/pkg/commands/git_commands/patch.go b/pkg/commands/git_commands/patch.go index 8c956529c..06e5e0f67 100644 --- a/pkg/commands/git_commands/patch.go +++ b/pkg/commands/git_commands/patch.go @@ -3,7 +3,9 @@ package git_commands import ( "fmt" + "github.com/fsmiamoto/git-todo-parser/todo" "github.com/go-errors/errors" + "github.com/jesseduffield/lazygit/pkg/app/daemon" "github.com/jesseduffield/lazygit/pkg/commands/models" "github.com/jesseduffield/lazygit/pkg/commands/patch" "github.com/jesseduffield/lazygit/pkg/commands/types/enums" @@ -103,18 +105,16 @@ func (self *PatchCommands) MovePatchToSelectedCommit(commits []*models.Commit, s baseIndex := sourceCommitIdx + 1 - todoLines := self.rebase.BuildTodoLines(commits[0:baseIndex], func(commit *models.Commit, i int) string { - if i == sourceCommitIdx || i == destinationCommitIdx { - return "edit" - } else { - return "pick" - } - }) + changes := []daemon.ChangeTodoAction{ + {Sha: commits[sourceCommitIdx].Sha, NewAction: todo.Edit}, + {Sha: commits[destinationCommitIdx].Sha, NewAction: todo.Edit}, + } + self.os.LogCommand(logTodoChanges(changes), false) err := self.rebase.PrepareInteractiveRebaseCommand(PrepareInteractiveRebaseCommandOpts{ baseShaOrRoot: commits[baseIndex].Sha, - todoLines: todoLines, overrideEditor: true, + instruction: daemon.NewChangeTodoActionsInstruction(changes), }).Run() if err != nil { return err diff --git a/pkg/commands/git_commands/rebase.go b/pkg/commands/git_commands/rebase.go index 5828ffbaa..50c599a66 100644 --- a/pkg/commands/git_commands/rebase.go +++ b/pkg/commands/git_commands/rebase.go @@ -12,6 +12,7 @@ import ( "github.com/jesseduffield/lazygit/pkg/commands/models" "github.com/jesseduffield/lazygit/pkg/commands/oscommands" "github.com/jesseduffield/lazygit/pkg/utils" + "github.com/samber/lo" ) type RebaseCommands struct { @@ -55,14 +56,15 @@ func (self *RebaseCommands) RewordCommit(commits []*models.Commit, index int, me } func (self *RebaseCommands) RewordCommitInEditor(commits []*models.Commit, index int) (oscommands.ICmdObj, error) { - todo, sha, err := self.BuildSingleActionTodo(commits, index, "reword") - if err != nil { - return nil, err - } + changes := []daemon.ChangeTodoAction{{ + Sha: commits[index].Sha, + NewAction: todo.Reword, + }} + self.os.LogCommand(logTodoChanges(changes), false) return self.PrepareInteractiveRebaseCommand(PrepareInteractiveRebaseCommandOpts{ - baseShaOrRoot: sha, - todoLines: todo, + baseShaOrRoot: getBaseShaOrRoot(commits, index+1), + instruction: daemon.NewChangeTodoActionsInstruction(changes), }), nil } @@ -99,96 +101,105 @@ func (self *RebaseCommands) GenericAmend(commits []*models.Commit, index int, f } func (self *RebaseCommands) MoveCommitDown(commits []*models.Commit, index int) error { - // not appending to original slice so that we don't mutate it - orderedCommits := append([]*models.Commit{}, commits[0:index]...) - orderedCommits = append(orderedCommits, commits[index+1], commits[index]) - - todoLines := self.BuildTodoLinesSingleAction(orderedCommits, "pick") - baseShaOrRoot := getBaseShaOrRoot(commits, index+2) + sha := commits[index].Sha + + self.os.LogCommand(fmt.Sprintf("Moving TODO down: %s", utils.ShortSha(sha)), false) + return self.PrepareInteractiveRebaseCommand(PrepareInteractiveRebaseCommandOpts{ baseShaOrRoot: baseShaOrRoot, - todoLines: todoLines, + instruction: daemon.NewMoveTodoDownInstruction(sha), overrideEditor: true, }).Run() } -func (self *RebaseCommands) InteractiveRebase(commits []*models.Commit, index int, action string) error { - todo, sha, err := self.BuildSingleActionTodo(commits, index, action) - if err != nil { - return err - } +func (self *RebaseCommands) MoveCommitUp(commits []*models.Commit, index int) error { + baseShaOrRoot := getBaseShaOrRoot(commits, index+1) + + sha := commits[index].Sha + + self.os.LogCommand(fmt.Sprintf("Moving TODO up: %s", utils.ShortSha(sha)), false) return self.PrepareInteractiveRebaseCommand(PrepareInteractiveRebaseCommandOpts{ - baseShaOrRoot: sha, - todoLines: todo, + baseShaOrRoot: baseShaOrRoot, + instruction: daemon.NewMoveTodoUpInstruction(sha), overrideEditor: true, }).Run() } -func (self *RebaseCommands) InteractiveRebaseBreakAfter(commits []*models.Commit, index int) error { - todo, sha, err := self.BuildSingleActionTodo(commits, index-1, "pick") - if err != nil { - return err +func (self *RebaseCommands) InteractiveRebase(commits []*models.Commit, index int, action todo.TodoCommand) error { + baseIndex := index + 1 + if action == todo.Squash || action == todo.Fixup { + baseIndex++ } - todo = append(todo, TodoLine{Action: "break", Commit: nil}) + baseShaOrRoot := getBaseShaOrRoot(commits, baseIndex) + + changes := []daemon.ChangeTodoAction{{ + Sha: commits[index].Sha, + NewAction: action, + }} + self.os.LogCommand(logTodoChanges(changes), false) + return self.PrepareInteractiveRebaseCommand(PrepareInteractiveRebaseCommandOpts{ - baseShaOrRoot: sha, - todoLines: todo, + baseShaOrRoot: baseShaOrRoot, overrideEditor: true, + instruction: daemon.NewChangeTodoActionsInstruction(changes), }).Run() } func (self *RebaseCommands) EditRebase(branchRef string) error { - commands := []TodoLine{{Action: "break"}} + self.os.LogCommand(fmt.Sprintf("Beginning interactive rebase at '%s'", branchRef), false) return self.PrepareInteractiveRebaseCommand(PrepareInteractiveRebaseCommandOpts{ baseShaOrRoot: branchRef, - todoLines: commands, - prepend: true, + instruction: daemon.NewInsertBreakInstruction(), }).Run() } +func logTodoChanges(changes []daemon.ChangeTodoAction) string { + changeTodoStr := strings.Join(slices.Map(changes, func(c daemon.ChangeTodoAction) string { + return fmt.Sprintf("%s:%s", c.Sha, c.NewAction) + }), "\n") + return fmt.Sprintf("Changing TODO actions: %s", changeTodoStr) +} + type PrepareInteractiveRebaseCommandOpts struct { baseShaOrRoot string - todoLines []TodoLine + instruction daemon.Instruction overrideEditor bool - prepend bool } // PrepareInteractiveRebaseCommand returns the cmd for an interactive rebase // we tell git to run lazygit to edit the todo list, and we pass the client // lazygit a todo string to write to the todo file func (self *RebaseCommands) PrepareInteractiveRebaseCommand(opts PrepareInteractiveRebaseCommandOpts) oscommands.ICmdObj { - todo := self.buildTodo(opts.todoLines) ex := oscommands.GetLazygitPath() - prependLines := "" - if opts.prepend { - prependLines = "TRUE" - } debug := "FALSE" if self.Debug { debug = "TRUE" } - cmdStr := fmt.Sprintf("git rebase --interactive --autostash --keep-empty --empty=keep --no-autosquash %s", opts.baseShaOrRoot) + rebaseMergesArg := " --rebase-merges" + if self.version.IsOlderThan(2, 22, 0) { + rebaseMergesArg = "" + } + cmdStr := fmt.Sprintf("git rebase --interactive --autostash --keep-empty --empty=keep --no-autosquash%s %s", + rebaseMergesArg, opts.baseShaOrRoot) self.Log.WithField("command", cmdStr).Debug("RunCommand") cmdObj := self.cmd.New(cmdStr) gitSequenceEditor := ex - if todo == "" { - gitSequenceEditor = "true" + + if opts.instruction != nil { + cmdObj.AddEnvVars(daemon.ToEnvVars(opts.instruction)...) } else { - self.os.LogCommand(fmt.Sprintf("Creating TODO file for interactive rebase: \n\n%s", todo), false) + gitSequenceEditor = "true" } cmdObj.AddEnvVars( - daemon.DaemonKindEnvKey+"="+string(daemon.InteractiveRebase), - daemon.RebaseTODOEnvKey+"="+todo, - daemon.PrependLinesEnvKey+"="+prependLines, "DEBUG="+debug, "LANG=en_US.UTF-8", // Force using EN as language "LC_ALL=en_US.UTF-8", // Force using EN as language @@ -202,63 +213,31 @@ func (self *RebaseCommands) PrepareInteractiveRebaseCommand(opts PrepareInteract return cmdObj } -// produces TodoLines where every commit is picked (or dropped for merge commits) except for the commit at the given index, which -// will have the given action applied to it. -func (self *RebaseCommands) BuildSingleActionTodo(commits []*models.Commit, actionIndex int, action string) ([]TodoLine, string, error) { - baseIndex := actionIndex + 1 - - if action == "squash" || action == "fixup" { - baseIndex++ - } - - todoLines := self.BuildTodoLines(commits[0:baseIndex], func(commit *models.Commit, i int) string { - if i == actionIndex { - return action - } else if commit.IsMerge() { - // your typical interactive rebase will actually drop merge commits by default. Damn git CLI, you scary! - // doing this means we don't need to worry about rebasing over merges which always causes problems. - // you typically shouldn't be doing rebases that pass over merge commits anyway. - return "drop" - } else { - return "pick" - } - }) - - baseShaOrRoot := getBaseShaOrRoot(commits, baseIndex) - - return todoLines, baseShaOrRoot, nil -} - // AmendTo amends the given commit with whatever files are staged -func (self *RebaseCommands) AmendTo(commit *models.Commit) error { +func (self *RebaseCommands) AmendTo(commits []*models.Commit, commitIndex int) error { + commit := commits[commitIndex] + if err := self.commit.CreateFixupCommit(commit.Sha); err != nil { return err } - return self.SquashAllAboveFixupCommits(commit) -} - -// EditRebaseTodo sets the action for a given rebase commit in the git-rebase-todo file -func (self *RebaseCommands) EditRebaseTodo(commit *models.Commit, action todo.TodoCommand) error { - fileName := filepath.Join(self.dotGitDir, "rebase-merge/git-rebase-todo") - todos, err := utils.ReadRebaseTodoFile(fileName) + // Get the sha of the commit we just created + fixupSha, err := self.cmd.New("git rev-parse --verify HEAD").RunWithOutput() if err != nil { return err } - for i := range todos { - t := &todos[i] - // Comparing just the sha is not enough; we need to compare both the - // action and the sha, as the sha could appear multiple times (e.g. in a - // pick and later in a merge) - if t.Command == commit.Action && t.Commit == commit.Sha { - t.Command = action - return utils.WriteRebaseTodoFile(fileName, todos) - } - } + return self.PrepareInteractiveRebaseCommand(PrepareInteractiveRebaseCommandOpts{ + baseShaOrRoot: getBaseShaOrRoot(commits, commitIndex+1), + overrideEditor: true, + instruction: daemon.NewMoveFixupCommitDownInstruction(commit.Sha, fixupSha), + }).Run() +} - // Should never get here - return fmt.Errorf("Todo %s not found in git-rebase-todo", commit.Sha) +// EditRebaseTodo sets the action for a given rebase commit in the git-rebase-todo file +func (self *RebaseCommands) EditRebaseTodo(commit *models.Commit, action todo.TodoCommand) error { + return utils.EditRebaseTodo( + filepath.Join(self.dotGitDir, "rebase-merge/git-rebase-todo"), commit.Sha, commit.Action, action) } // MoveTodoDown moves a rebase todo item down by one position @@ -304,15 +283,16 @@ func (self *RebaseCommands) BeginInteractiveRebaseForCommit(commits []*models.Co return errors.New(self.Tr.DisabledForGPG) } - todo, sha, err := self.BuildSingleActionTodo(commits, commitIndex, "edit") - if err != nil { - return err - } + changes := []daemon.ChangeTodoAction{{ + Sha: commits[commitIndex].Sha, + NewAction: todo.Edit, + }} + self.os.LogCommand(logTodoChanges(changes), false) return self.PrepareInteractiveRebaseCommand(PrepareInteractiveRebaseCommandOpts{ - baseShaOrRoot: sha, - todoLines: todo, + baseShaOrRoot: getBaseShaOrRoot(commits, commitIndex+1), overrideEditor: true, + instruction: daemon.NewChangeTodoActionsInstruction(changes), }).Run() } @@ -359,15 +339,16 @@ func (self *RebaseCommands) GenericMergeOrRebaseAction(commandType string, comma } func (self *RebaseCommands) runSkipEditorCommand(cmdObj oscommands.ICmdObj) error { + instruction := daemon.NewExitImmediatelyInstruction() lazyGitPath := oscommands.GetLazygitPath() return cmdObj. AddEnvVars( - daemon.DaemonKindEnvKey+"="+string(daemon.ExitImmediately), "GIT_EDITOR="+lazyGitPath, "GIT_SEQUENCE_EDITOR="+lazyGitPath, "EDITOR="+lazyGitPath, "VISUAL="+lazyGitPath, ). + AddEnvVars(daemon.ToEnvVars(instruction)...). Run() } @@ -401,47 +382,17 @@ func (self *RebaseCommands) DiscardOldFileChanges(commits []*models.Commit, comm // CherryPickCommits begins an interactive rebase with the given shas being cherry picked onto HEAD func (self *RebaseCommands) CherryPickCommits(commits []*models.Commit) error { - todoLines := self.BuildTodoLinesSingleAction(commits, "pick") + commitLines := lo.Map(commits, func(commit *models.Commit, _ int) string { + return fmt.Sprintf("%s %s", utils.ShortSha(commit.Sha), commit.Name) + }) + self.os.LogCommand(fmt.Sprintf("Cherry-picking commits:\n%s", strings.Join(commitLines, "\n")), false) return self.PrepareInteractiveRebaseCommand(PrepareInteractiveRebaseCommandOpts{ baseShaOrRoot: "HEAD", - todoLines: todoLines, + instruction: daemon.NewCherryPickCommitsInstruction(commits), }).Run() } -func (self *RebaseCommands) buildTodo(todoLines []TodoLine) string { - lines := slices.Map(todoLines, func(todoLine TodoLine) string { - return todoLine.ToString() - }) - - return strings.Join(slices.Reverse(lines), "") -} - -func (self *RebaseCommands) BuildTodoLines(commits []*models.Commit, f func(*models.Commit, int) string) []TodoLine { - return slices.MapWithIndex(commits, func(commit *models.Commit, i int) TodoLine { - return TodoLine{Action: f(commit, i), Commit: commit} - }) -} - -func (self *RebaseCommands) BuildTodoLinesSingleAction(commits []*models.Commit, action string) []TodoLine { - return self.BuildTodoLines(commits, func(commit *models.Commit, i int) string { - return action - }) -} - -type TodoLine struct { - Action string - Commit *models.Commit -} - -func (self *TodoLine) ToString() string { - if self.Action == "break" { - return self.Action + "\n" - } else { - return self.Action + " " + self.Commit.Sha + " " + self.Commit.Name + "\n" - } -} - // we can't start an interactive rebase from the first commit without passing the // '--root' arg func getBaseShaOrRoot(commits []*models.Commit, index int) string { diff --git a/pkg/commands/git_commands/rebase_test.go b/pkg/commands/git_commands/rebase_test.go index b2cd88323..1ef22ff5d 100644 --- a/pkg/commands/git_commands/rebase_test.go +++ b/pkg/commands/git_commands/rebase_test.go @@ -2,6 +2,7 @@ package git_commands import ( "regexp" + "strconv" "testing" "github.com/go-errors/errors" @@ -63,7 +64,7 @@ func TestRebaseSkipEditorCommand(t *testing.T) { `^EDITOR=.*$`, `^GIT_EDITOR=.*$`, `^GIT_SEQUENCE_EDITOR=.*$`, - "^" + daemon.DaemonKindEnvKey + "=" + string(daemon.ExitImmediately) + "$", + "^" + daemon.DaemonKindEnvKey + "=" + strconv.Itoa(int(daemon.DaemonKindExitImmediately)) + "$", } { regexStr := regexStr foundMatch := lo.ContainsBy(envVars, func(envVar string) bool { diff --git a/pkg/config/user_config.go b/pkg/config/user_config.go index fb36bb7ea..82361ddbd 100644 --- a/pkg/config/user_config.go +++ b/pkg/config/user_config.go @@ -27,32 +27,33 @@ type RefresherConfig struct { } type GuiConfig struct { - AuthorColors map[string]string `yaml:"authorColors"` - BranchColors map[string]string `yaml:"branchColors"` - ScrollHeight int `yaml:"scrollHeight"` - ScrollPastBottom bool `yaml:"scrollPastBottom"` - MouseEvents bool `yaml:"mouseEvents"` - SkipUnstageLineWarning bool `yaml:"skipUnstageLineWarning"` - SkipStashWarning bool `yaml:"skipStashWarning"` - SidePanelWidth float64 `yaml:"sidePanelWidth"` - ExpandFocusedSidePanel bool `yaml:"expandFocusedSidePanel"` - MainPanelSplitMode string `yaml:"mainPanelSplitMode"` - Language string `yaml:"language"` - TimeFormat string `yaml:"timeFormat"` - Theme ThemeConfig `yaml:"theme"` - CommitLength CommitLengthConfig `yaml:"commitLength"` - SkipNoStagedFilesWarning bool `yaml:"skipNoStagedFilesWarning"` - ShowListFooter bool `yaml:"showListFooter"` - ShowFileTree bool `yaml:"showFileTree"` - ShowRandomTip bool `yaml:"showRandomTip"` - ShowCommandLog bool `yaml:"showCommandLog"` - ShowBottomLine bool `yaml:"showBottomLine"` - ShowIcons bool `yaml:"showIcons"` - CommandLogSize int `yaml:"commandLogSize"` - SplitDiff string `yaml:"splitDiff"` - SkipRewordInEditorWarning bool `yaml:"skipRewordInEditorWarning"` - WindowSize string `yaml:"windowSize"` - Border string `yaml:"border"` + AuthorColors map[string]string `yaml:"authorColors"` + BranchColors map[string]string `yaml:"branchColors"` + ScrollHeight int `yaml:"scrollHeight"` + ScrollPastBottom bool `yaml:"scrollPastBottom"` + MouseEvents bool `yaml:"mouseEvents"` + SkipUnstageLineWarning bool `yaml:"skipUnstageLineWarning"` + SkipStashWarning bool `yaml:"skipStashWarning"` + SidePanelWidth float64 `yaml:"sidePanelWidth"` + ExpandFocusedSidePanel bool `yaml:"expandFocusedSidePanel"` + MainPanelSplitMode string `yaml:"mainPanelSplitMode"` + Language string `yaml:"language"` + TimeFormat string `yaml:"timeFormat"` + Theme ThemeConfig `yaml:"theme"` + CommitLength CommitLengthConfig `yaml:"commitLength"` + SkipNoStagedFilesWarning bool `yaml:"skipNoStagedFilesWarning"` + ShowListFooter bool `yaml:"showListFooter"` + ShowFileTree bool `yaml:"showFileTree"` + ShowRandomTip bool `yaml:"showRandomTip"` + ShowCommandLog bool `yaml:"showCommandLog"` + ShowBottomLine bool `yaml:"showBottomLine"` + ShowIcons bool `yaml:"showIcons"` + ExperimentalShowBranchHeads bool `yaml:"experimentalShowBranchHeads"` + CommandLogSize int `yaml:"commandLogSize"` + SplitDiff string `yaml:"splitDiff"` + SkipRewordInEditorWarning bool `yaml:"skipRewordInEditorWarning"` + WindowSize string `yaml:"windowSize"` + Border string `yaml:"border"` } type ThemeConfig struct { @@ -408,18 +409,19 @@ func GetDefaultConfig() *UserConfig { UnstagedChangesColor: []string{"red"}, DefaultFgColor: []string{"default"}, }, - CommitLength: CommitLengthConfig{Show: true}, - SkipNoStagedFilesWarning: false, - ShowListFooter: true, - ShowCommandLog: true, - ShowBottomLine: true, - ShowFileTree: true, - ShowRandomTip: true, - ShowIcons: false, - CommandLogSize: 8, - SplitDiff: "auto", - SkipRewordInEditorWarning: false, - Border: "single", + CommitLength: CommitLengthConfig{Show: true}, + SkipNoStagedFilesWarning: false, + ShowListFooter: true, + ShowCommandLog: true, + ShowBottomLine: true, + ShowFileTree: true, + ShowRandomTip: true, + ShowIcons: false, + ExperimentalShowBranchHeads: false, + CommandLogSize: 8, + SplitDiff: "auto", + SkipRewordInEditorWarning: false, + Border: "single", }, Git: GitConfig{ Paging: PagingConfig{ diff --git a/pkg/gui/context.go b/pkg/gui/context.go index 252815e18..d5a7f35a2 100644 --- a/pkg/gui/context.go +++ b/pkg/gui/context.go @@ -53,7 +53,7 @@ func (self *ContextMgr) Replace(c types.Context) error { defer self.Unlock() - return self.activateContext(c, types.OnFocusOpts{}) + return self.ActivateContext(c, types.OnFocusOpts{}) } func (self *ContextMgr) Push(c types.Context, opts ...types.OnFocusOpts) error { @@ -83,7 +83,7 @@ func (self *ContextMgr) Push(c types.Context, opts ...types.OnFocusOpts) error { return nil } - return self.activateContext(contextToActivate, singleOpts) + return self.ActivateContext(contextToActivate, singleOpts) } // Adjusts the context stack based on the context that's being pushed and @@ -162,7 +162,7 @@ func (self *ContextMgr) Pop() error { return err } - return self.activateContext(newContext, types.OnFocusOpts{}) + return self.ActivateContext(newContext, types.OnFocusOpts{}) } func (self *ContextMgr) RemoveContexts(contextsToRemove []types.Context) error { @@ -192,7 +192,7 @@ func (self *ContextMgr) RemoveContexts(contextsToRemove []types.Context) error { } // activate the item at the top of the stack - return self.activateContext(contextToActivate, types.OnFocusOpts{}) + return self.ActivateContext(contextToActivate, types.OnFocusOpts{}) } func (self *ContextMgr) deactivateContext(c types.Context, opts types.OnFocusLostOpts) error { @@ -218,7 +218,7 @@ func (self *ContextMgr) deactivateContext(c types.Context, opts types.OnFocusLos return nil } -func (self *ContextMgr) activateContext(c types.Context, opts types.OnFocusOpts) error { +func (self *ContextMgr) ActivateContext(c types.Context, opts types.OnFocusOpts) error { viewName := c.GetViewName() v, err := self.gui.c.GocuiGui().View(viewName) if err != nil { diff --git a/pkg/gui/controllers/local_commits_controller.go b/pkg/gui/controllers/local_commits_controller.go index 7ac6e8671..d3dbff9d4 100644 --- a/pkg/gui/controllers/local_commits_controller.go +++ b/pkg/gui/controllers/local_commits_controller.go @@ -217,7 +217,7 @@ func (self *LocalCommitsController) squashDown(commit *models.Commit) error { HandleConfirm: func() error { return self.c.WithWaitingStatus(self.c.Tr.SquashingStatus, func() error { self.c.LogAction(self.c.Tr.Actions.SquashCommitDown) - return self.interactiveRebase("squash") + return self.interactiveRebase(todo.Squash) }) }, }) @@ -242,7 +242,7 @@ func (self *LocalCommitsController) fixup(commit *models.Commit) error { HandleConfirm: func() error { return self.c.WithWaitingStatus(self.c.Tr.FixingStatus, func() error { self.c.LogAction(self.c.Tr.Actions.FixupCommit) - return self.interactiveRebase("fixup") + return self.interactiveRebase(todo.Fixup) }) }, }) @@ -338,7 +338,7 @@ func (self *LocalCommitsController) drop(commit *models.Commit) error { HandleConfirm: func() error { return self.c.WithWaitingStatus(self.c.Tr.DeletingStatus, func() error { self.c.LogAction(self.c.Tr.Actions.DropCommit) - return self.interactiveRebase("drop") + return self.interactiveRebase(todo.Drop) }) }, }) @@ -355,7 +355,7 @@ func (self *LocalCommitsController) edit(commit *models.Commit) error { return self.c.WithWaitingStatus(self.c.Tr.RebasingStatus, func() error { self.c.LogAction(self.c.Tr.Actions.EditCommit) - err := self.c.Git().Rebase.InteractiveRebaseBreakAfter(self.c.Model().Commits, self.context().GetSelectedLineIdx()) + err := self.c.Git().Rebase.EditRebase(commit.Sha) return self.c.Helpers().MergeAndRebase.CheckMergeOrRebase(err) }) } @@ -374,7 +374,7 @@ func (self *LocalCommitsController) pick(commit *models.Commit) error { return self.pullFiles() } -func (self *LocalCommitsController) interactiveRebase(action string) error { +func (self *LocalCommitsController) interactiveRebase(action todo.TodoCommand) error { err := self.c.Git().Rebase.InteractiveRebase(self.c.Model().Commits, self.context().GetSelectedLineIdx(), action) return self.c.Helpers().MergeAndRebase.CheckMergeOrRebase(err) } @@ -494,7 +494,7 @@ func (self *LocalCommitsController) moveUp(commit *models.Commit) error { return self.c.WithWaitingStatus(self.c.Tr.MovingStatus, func() error { self.c.LogAction(self.c.Tr.Actions.MoveCommitUp) - err := self.c.Git().Rebase.MoveCommitDown(self.c.Model().Commits, index-1) + err := self.c.Git().Rebase.MoveCommitUp(self.c.Model().Commits, index) if err == nil { self.context().MoveSelectedLine(-1) } @@ -520,7 +520,7 @@ func (self *LocalCommitsController) amendTo(commit *models.Commit) error { HandleConfirm: func() error { return self.c.WithWaitingStatus(self.c.Tr.AmendingStatus, func() error { self.c.LogAction(self.c.Tr.Actions.AmendCommit) - err := self.c.Git().Rebase.AmendTo(commit) + err := self.c.Git().Rebase.AmendTo(self.c.Model().Commits, self.context().GetView().SelectedLineIdx()) return self.c.Helpers().MergeAndRebase.CheckMergeOrRebase(err) }) }, diff --git a/pkg/gui/gui_common.go b/pkg/gui/gui_common.go index f5f977272..dfed29c44 100644 --- a/pkg/gui/gui_common.go +++ b/pkg/gui/gui_common.go @@ -76,6 +76,10 @@ func (self *guiCommon) Context() types.IContextMgr { return self.gui.State.ContextMgr } +func (self *guiCommon) ActivateContext(context types.Context) error { + return self.gui.State.ContextMgr.ActivateContext(context, types.OnFocusOpts{}) +} + func (self *guiCommon) GetAppState() *config.AppState { return self.gui.Config.GetAppState() } diff --git a/pkg/gui/layout.go b/pkg/gui/layout.go index 30177ac2f..5b3e6845f 100644 --- a/pkg/gui/layout.go +++ b/pkg/gui/layout.go @@ -182,7 +182,7 @@ func (gui *Gui) onInitialViewsCreationForRepo() error { } initialContext := gui.c.CurrentSideContext() - if err := gui.c.PushContext(initialContext); err != nil { + if err := gui.c.ActivateContext(initialContext); err != nil { return err } diff --git a/pkg/gui/presentation/commits.go b/pkg/gui/presentation/commits.go index 7e16197b5..6b83456b9 100644 --- a/pkg/gui/presentation/commits.go +++ b/pkg/gui/presentation/commits.go @@ -277,7 +277,7 @@ func displayCommit( } else { if len(commit.Tags) > 0 { tagString = theme.DiffTerminalColor.SetBold().Sprint(strings.Join(commit.Tags, " ")) + " " - } else if commit.ExtraInfo != "" { + } else if common.UserConfig.Gui.ExperimentalShowBranchHeads && commit.ExtraInfo != "" { tagString = style.FgMagenta.SetBold().Sprint("(*)") + " " } } diff --git a/pkg/gui/types/common.go b/pkg/gui/types/common.go index 90be93a32..3709d3c7b 100644 --- a/pkg/gui/types/common.go +++ b/pkg/gui/types/common.go @@ -67,6 +67,7 @@ type IGuiCommon interface { // TODO: replace the above context-based methods with just using Context() e.g. replace PushContext() with Context().Push() Context() IContextMgr + ActivateContext(context Context) error // enters search mode for the current view OpenSearch() diff --git a/pkg/integration/tests/interactive_rebase/amend_fixup_commit.go b/pkg/integration/tests/interactive_rebase/amend_fixup_commit.go new file mode 100644 index 000000000..0c39c756b --- /dev/null +++ b/pkg/integration/tests/interactive_rebase/amend_fixup_commit.go @@ -0,0 +1,50 @@ +package interactive_rebase + +import ( + "github.com/jesseduffield/lazygit/pkg/config" + . "github.com/jesseduffield/lazygit/pkg/integration/components" +) + +var AmendFixupCommit = NewIntegrationTest(NewIntegrationTestArgs{ + Description: "Amends a staged file to a fixup commit, and checks that other unrelated fixup commits are not auto-squashed.", + ExtraCmdArgs: "", + Skip: false, + SetupConfig: func(config *config.AppConfig) {}, + SetupRepo: func(shell *Shell) { + shell. + CreateNCommits(1). + CreateFileAndAdd("first-fixup-file", "").Commit("fixup! commit 01"). + CreateNCommitsStartingAt(2, 2). + CreateFileAndAdd("unrelated-fixup-file", "fixup 03").Commit("fixup! commit 03"). + CreateFileAndAdd("fixup-file", "fixup 01") + }, + Run: func(t *TestDriver, keys config.KeybindingConfig) { + t.Views().Commits(). + Focus(). + Lines( + Contains("fixup! commit 03"), + Contains("commit 03"), + Contains("commit 02"), + Contains("fixup! commit 01"), + Contains("commit 01"), + ). + NavigateToLine(Contains("fixup! commit 01")). + Press(keys.Commits.AmendToCommit). + Tap(func() { + t.ExpectPopup().Confirmation(). + Title(Equals("Amend Commit")). + Content(Contains("Are you sure you want to amend this commit with your staged files?")). + Confirm() + }). + Lines( + Contains("fixup! commit 03"), + Contains("commit 03"), + Contains("commit 02"), + Contains("fixup! commit 01").IsSelected(), + Contains("commit 01"), + ) + + t.Views().Main(). + Content(Contains("fixup 01")) + }, +}) diff --git a/pkg/integration/tests/interactive_rebase/drop_todo_commit_with_update_ref.go b/pkg/integration/tests/interactive_rebase/drop_todo_commit_with_update_ref.go index c247f1743..ace5bed40 100644 --- a/pkg/integration/tests/interactive_rebase/drop_todo_commit_with_update_ref.go +++ b/pkg/integration/tests/interactive_rebase/drop_todo_commit_with_update_ref.go @@ -23,31 +23,22 @@ var DropTodoCommitWithUpdateRef = NewIntegrationTest(NewIntegrationTestArgs{ t.Views().Commits(). Focus(). Lines( - Contains("(*) commit 06").IsSelected(), + Contains("commit 06").IsSelected(), Contains("commit 05"), Contains("commit 04"), - Contains("(*) commit 03"), + Contains("commit 03"), Contains("commit 02"), Contains("commit 01"), ). - // Once "e" is fixed we can just hit "e", but for now we need to - // manually do a command-line rebase - // NavigateToLine(Contains("commit 01")). - // Press(keys.Universal.Edit). - Tap(func() { - t.GlobalPress(keys.Universal.ExecuteCustomCommand) - t.ExpectPopup().Prompt(). - Title(Equals("Custom Command:")). - Type(`git -c core.editor="perl -i -lpe 'print \"break\" if $.==1'" rebase -i HEAD~5`). - Confirm() - }). + NavigateToLine(Contains("commit 01")). + Press(keys.Universal.Edit). Focus(). Lines( - Contains("pick").Contains("(*) commit 06"), + Contains("pick").Contains("commit 06"), Contains("pick").Contains("commit 05"), Contains("pick").Contains("commit 04"), Contains("update-ref").Contains("master"), - Contains("pick").Contains("(*) commit 03"), + Contains("pick").Contains("commit 03"), Contains("pick").Contains("commit 02"), Contains("<-- YOU ARE HERE --- commit 01"), ). @@ -59,9 +50,9 @@ var DropTodoCommitWithUpdateRef = NewIntegrationTest(NewIntegrationTestArgs{ t.Views().Commits(). IsFocused(). Lines( - Contains("(*) commit 06"), + Contains("commit 06"), Contains("commit 04"), - Contains("(*) commit 03"), + Contains("commit 03"), Contains("commit 02"), Contains("commit 01"), ) diff --git a/pkg/integration/tests/interactive_rebase/drop_todo_commit_with_update_ref_show_branch_heads.go b/pkg/integration/tests/interactive_rebase/drop_todo_commit_with_update_ref_show_branch_heads.go new file mode 100644 index 000000000..cb42ce989 --- /dev/null +++ b/pkg/integration/tests/interactive_rebase/drop_todo_commit_with_update_ref_show_branch_heads.go @@ -0,0 +1,62 @@ +package interactive_rebase + +import ( + "github.com/jesseduffield/lazygit/pkg/config" + . "github.com/jesseduffield/lazygit/pkg/integration/components" +) + +var DropTodoCommitWithUpdateRefShowBranchHeads = NewIntegrationTest(NewIntegrationTestArgs{ + Description: "Drops a commit during interactive rebase when there is an update-ref in the git-rebase-todo file (with experimentalShowBranchHeads on)", + ExtraCmdArgs: "", + Skip: false, + GitVersion: From("2.38.0"), + SetupConfig: func(config *config.AppConfig) { + config.UserConfig.Gui.ExperimentalShowBranchHeads = true + }, + SetupRepo: func(shell *Shell) { + shell. + CreateNCommits(3). + NewBranch("mybranch"). + CreateNCommitsStartingAt(3, 4) + + shell.SetConfig("rebase.updateRefs", "true") + }, + Run: func(t *TestDriver, keys config.KeybindingConfig) { + t.Views().Commits(). + Focus(). + Lines( + Contains("(*) commit 06").IsSelected(), + Contains("commit 05"), + Contains("commit 04"), + Contains("(*) commit 03"), + Contains("commit 02"), + Contains("commit 01"), + ). + NavigateToLine(Contains("commit 01")). + Press(keys.Universal.Edit). + Focus(). + Lines( + Contains("pick").Contains("(*) commit 06"), + Contains("pick").Contains("commit 05"), + Contains("pick").Contains("commit 04"), + Contains("update-ref").Contains("master"), + Contains("pick").Contains("(*) commit 03"), + Contains("pick").Contains("commit 02"), + Contains("<-- YOU ARE HERE --- commit 01"), + ). + NavigateToLine(Contains("commit 05")). + Press(keys.Universal.Remove) + + t.Common().ContinueRebase() + + t.Views().Commits(). + IsFocused(). + Lines( + Contains("(*) commit 06"), + Contains("commit 04"), + Contains("(*) commit 03"), + Contains("commit 02"), + Contains("commit 01"), + ) + }, +}) diff --git a/pkg/integration/tests/test_list.go b/pkg/integration/tests/test_list.go index 8c1d75c6e..b2cb12e14 100644 --- a/pkg/integration/tests/test_list.go +++ b/pkg/integration/tests/test_list.go @@ -88,10 +88,12 @@ var tests = []*components.IntegrationTest{ filter_by_path.TypeFile, interactive_rebase.AdvancedInteractiveRebase, interactive_rebase.AmendFirstCommit, + interactive_rebase.AmendFixupCommit, interactive_rebase.AmendHeadCommitDuringRebase, interactive_rebase.AmendMerge, interactive_rebase.AmendNonHeadCommitDuringRebase, interactive_rebase.DropTodoCommitWithUpdateRef, + interactive_rebase.DropTodoCommitWithUpdateRefShowBranchHeads, interactive_rebase.EditFirstCommit, interactive_rebase.EditNonTodoCommitDuringRebase, interactive_rebase.FixupFirstCommit, diff --git a/pkg/utils/rebase_todo.go b/pkg/utils/rebase_todo.go index 0b6a6a40c..15c06b1c4 100644 --- a/pkg/utils/rebase_todo.go +++ b/pkg/utils/rebase_todo.go @@ -9,6 +9,29 @@ import ( "github.com/samber/lo" ) +// Read a git-rebase-todo file, change the action for the given sha to +// newAction, and write it back +func EditRebaseTodo(filePath string, sha string, oldAction todo.TodoCommand, newAction todo.TodoCommand) error { + todos, err := ReadRebaseTodoFile(filePath) + if err != nil { + return err + } + + for i := range todos { + t := &todos[i] + // Comparing just the sha is not enough; we need to compare both the + // action and the sha, as the sha could appear multiple times (e.g. in a + // pick and later in a merge) + if t.Command == oldAction && equalShas(t.Commit, sha) { + t.Command = newAction + return WriteRebaseTodoFile(filePath, todos) + } + } + + // Should never get here + return fmt.Errorf("Todo %s not found in git-rebase-todo", sha) +} + func equalShas(a, b string) bool { return strings.HasPrefix(a, b) || strings.HasPrefix(b, a) } @@ -40,6 +63,16 @@ func WriteRebaseTodoFile(fileName string, todos []todo.Todo) error { return err } +func PrependStrToTodoFile(filePath string, linesToPrepend []byte) error { + existingContent, err := os.ReadFile(filePath) + if err != nil { + return err + } + + linesToPrepend = append(linesToPrepend, existingContent...) + return os.WriteFile(filePath, linesToPrepend, 0o644) +} + func MoveTodoDown(fileName string, sha string, action todo.TodoCommand) error { todos, err := ReadRebaseTodoFile(fileName) if err != nil { @@ -101,6 +134,49 @@ func moveTodoUp(todos []todo.Todo, sha string, action todo.TodoCommand) ([]todo. return rearrangedTodos, nil } +func MoveFixupCommitDown(fileName string, originalSha string, fixupSha string) error { + todos, err := ReadRebaseTodoFile(fileName) + if err != nil { + return err + } + + newTodos, err := moveFixupCommitDown(todos, originalSha, fixupSha) + if err != nil { + return err + } + + return WriteRebaseTodoFile(fileName, newTodos) +} + +func moveFixupCommitDown(todos []todo.Todo, originalSha string, fixupSha string) ([]todo.Todo, error) { + isOriginal := func(t todo.Todo) bool { + return t.Command == todo.Pick && equalShas(t.Commit, originalSha) + } + + isFixup := func(t todo.Todo) bool { + return t.Command == todo.Pick && equalShas(t.Commit, fixupSha) + } + + originalShaCount := lo.CountBy(todos, isOriginal) + if originalShaCount != 1 { + return nil, fmt.Errorf("Expected exactly one original SHA, found %d", originalShaCount) + } + + fixupShaCount := lo.CountBy(todos, isFixup) + if fixupShaCount != 1 { + return nil, fmt.Errorf("Expected exactly one fixup SHA, found %d", fixupShaCount) + } + + _, fixupIndex, _ := lo.FindIndexOf(todos, isFixup) + _, originalIndex, _ := lo.FindIndexOf(todos, isOriginal) + + newTodos := MoveElement(todos, fixupIndex, originalIndex+1) + + newTodos[originalIndex+1].Command = todo.Fixup + + return newTodos, nil +} + // We render a todo in the commits view if it's a commit or if it's an // update-ref. We don't render label, reset, or comment lines. func isRenderedTodo(t todo.Todo) bool { diff --git a/pkg/utils/rebase_todo_test.go b/pkg/utils/rebase_todo_test.go index a4b1a46d0..4f554e926 100644 --- a/pkg/utils/rebase_todo_test.go +++ b/pkg/utils/rebase_todo_test.go @@ -1,6 +1,7 @@ package utils import ( + "errors" "testing" "github.com/fsmiamoto/git-todo-parser/todo" @@ -228,3 +229,110 @@ func TestRebaseCommands_moveTodoUp(t *testing.T) { ) } } + +func TestRebaseCommands_moveFixupCommitDown(t *testing.T) { + scenarios := []struct { + name string + todos []todo.Todo + originalSha string + fixupSha string + expectedTodos []todo.Todo + expectedErr error + }{ + { + name: "fixup commit is the last commit", + todos: []todo.Todo{ + {Command: todo.Pick, Commit: "original"}, + {Command: todo.Pick, Commit: "fixup"}, + }, + originalSha: "original", + fixupSha: "fixup", + expectedTodos: []todo.Todo{ + {Command: todo.Pick, Commit: "original"}, + {Command: todo.Fixup, Commit: "fixup"}, + }, + expectedErr: nil, + }, + { + // TODO: is this something we actually want to support? + name: "fixup commit is separated from original commit", + todos: []todo.Todo{ + {Command: todo.Pick, Commit: "original"}, + {Command: todo.Pick, Commit: "other"}, + {Command: todo.Pick, Commit: "fixup"}, + }, + originalSha: "original", + fixupSha: "fixup", + expectedTodos: []todo.Todo{ + {Command: todo.Pick, Commit: "original"}, + {Command: todo.Fixup, Commit: "fixup"}, + {Command: todo.Pick, Commit: "other"}, + }, + expectedErr: nil, + }, + { + name: "More original SHAs than expected", + todos: []todo.Todo{ + {Command: todo.Pick, Commit: "original"}, + {Command: todo.Pick, Commit: "original"}, + {Command: todo.Pick, Commit: "fixup"}, + }, + originalSha: "original", + fixupSha: "fixup", + expectedTodos: nil, + expectedErr: errors.New("Expected exactly one original SHA, found 2"), + }, + { + name: "More fixup SHAs than expected", + todos: []todo.Todo{ + {Command: todo.Pick, Commit: "original"}, + {Command: todo.Pick, Commit: "fixup"}, + {Command: todo.Pick, Commit: "fixup"}, + }, + originalSha: "original", + fixupSha: "fixup", + expectedTodos: nil, + expectedErr: errors.New("Expected exactly one fixup SHA, found 2"), + }, + { + name: "No fixup SHAs found", + todos: []todo.Todo{ + {Command: todo.Pick, Commit: "original"}, + }, + originalSha: "original", + fixupSha: "fixup", + expectedTodos: nil, + expectedErr: errors.New("Expected exactly one fixup SHA, found 0"), + }, + { + name: "No original SHAs found", + todos: []todo.Todo{ + {Command: todo.Pick, Commit: "fixup"}, + }, + originalSha: "original", + fixupSha: "fixup", + expectedTodos: nil, + expectedErr: errors.New("Expected exactly one original SHA, found 0"), + }, + } + + for _, scenario := range scenarios { + t.Run(scenario.name, func(t *testing.T) { + actualTodos, actualErr := moveFixupCommitDown(scenario.todos, scenario.originalSha, scenario.fixupSha) + + if scenario.expectedErr == nil { + if !assert.NoError(t, actualErr) { + t.Errorf("Expected no error, got: %v", actualErr) + } + } else { + if !assert.EqualError(t, actualErr, scenario.expectedErr.Error()) { + t.Errorf("Expected err: %v, got: %v", scenario.expectedErr, actualErr) + } + } + + if !assert.EqualValues(t, actualTodos, scenario.expectedTodos) { + t.Errorf("Expected todos: %v, got: %v", scenario.expectedTodos, actualTodos) + } + }) + } +} diff --git a/vendor/github.com/fsmiamoto/git-todo-parser/todo/parse.go b/vendor/github.com/fsmiamoto/git-todo-parser/todo/parse.go index 97c60db9d..ab3dd9ea9 100644 --- a/vendor/github.com/fsmiamoto/git-todo-parser/todo/parse.go +++ b/vendor/github.com/fsmiamoto/git-todo-parser/todo/parse.go @@ -56,9 +56,11 @@ func parseLine(line string) (Todo, error) { fields := strings.Fields(line) + var commandLen int for i := Pick; i < Comment; i++ { if isCommand(i, fields[0]) { todo.Command = i + commandLen = len(fields[0]) fields = fields[1:] break } @@ -74,10 +76,14 @@ func parseLine(line string) (Todo, error) { } if todo.Command == Label || todo.Command == Reset { - if len(fields) == 0 { + restOfLine := strings.TrimSpace(line[commandLen:]) + if todo.Command == Reset && restOfLine == "[new root]" { + todo.Label = restOfLine + } else if len(fields) == 0 { return todo, ErrMissingLabel + } else { + todo.Label = fields[0] } - todo.Label = fields[0] return todo, nil } diff --git a/vendor/modules.txt b/vendor/modules.txt index 12028e79c..fd06a3ec6 100644 --- a/vendor/modules.txt +++ b/vendor/modules.txt @@ -30,7 +30,7 @@ github.com/emirpasic/gods/utils # github.com/fatih/color v1.9.0 ## explicit; go 1.13 github.com/fatih/color -# github.com/fsmiamoto/git-todo-parser v0.0.4-0.20230403011024-617a5a7ce980 +# github.com/fsmiamoto/git-todo-parser v0.0.4 ## explicit; go 1.13 github.com/fsmiamoto/git-todo-parser/todo # github.com/fsnotify/fsnotify v1.4.7