mirror of
https://github.com/jesseduffield/lazygit.git
synced 2024-12-14 11:23:09 +02:00
404 lines
12 KiB
Go
404 lines
12 KiB
Go
package git_commands
|
|
|
|
import (
|
|
"fmt"
|
|
"os"
|
|
"path/filepath"
|
|
"strings"
|
|
|
|
"github.com/go-errors/errors"
|
|
"github.com/jesseduffield/generics/slices"
|
|
"github.com/jesseduffield/lazygit/pkg/app/daemon"
|
|
"github.com/jesseduffield/lazygit/pkg/commands/models"
|
|
"github.com/jesseduffield/lazygit/pkg/commands/oscommands"
|
|
)
|
|
|
|
type RebaseCommands struct {
|
|
*GitCommon
|
|
commit *CommitCommands
|
|
workingTree *WorkingTreeCommands
|
|
|
|
onSuccessfulContinue func() error
|
|
}
|
|
|
|
func NewRebaseCommands(
|
|
gitCommon *GitCommon,
|
|
commitCommands *CommitCommands,
|
|
workingTreeCommands *WorkingTreeCommands,
|
|
) *RebaseCommands {
|
|
return &RebaseCommands{
|
|
GitCommon: gitCommon,
|
|
commit: commitCommands,
|
|
workingTree: workingTreeCommands,
|
|
}
|
|
}
|
|
|
|
func (self *RebaseCommands) RewordCommit(commits []*models.Commit, index int, message string) error {
|
|
if index == 0 {
|
|
// we've selected the top commit so no rebase is required
|
|
return self.commit.RewordLastCommit(message)
|
|
}
|
|
|
|
err := self.BeginInteractiveRebaseForCommit(commits, index)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
// now the selected commit should be our head so we'll amend it with the new message
|
|
err = self.commit.RewordLastCommit(message)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
return self.ContinueRebase()
|
|
}
|
|
|
|
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
|
|
}
|
|
|
|
return self.PrepareInteractiveRebaseCommand(sha, todo, false), nil
|
|
}
|
|
|
|
func (self *RebaseCommands) ResetCommitAuthor(commits []*models.Commit, index int) error {
|
|
return self.GenericAmend(commits, index, func() error {
|
|
return self.commit.ResetAuthor()
|
|
})
|
|
}
|
|
|
|
func (self *RebaseCommands) SetCommitAuthor(commits []*models.Commit, index int, value string) error {
|
|
return self.GenericAmend(commits, index, func() error {
|
|
return self.commit.SetAuthor(value)
|
|
})
|
|
}
|
|
|
|
func (self *RebaseCommands) GenericAmend(commits []*models.Commit, index int, f func() error) error {
|
|
if index == 0 {
|
|
// we've selected the top commit so no rebase is required
|
|
return f()
|
|
}
|
|
|
|
err := self.BeginInteractiveRebaseForCommit(commits, index)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
// now the selected commit should be our head so we'll amend it
|
|
err = f()
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
return self.ContinueRebase()
|
|
}
|
|
|
|
func (self *RebaseCommands) MoveCommitDown(commits []*models.Commit, index int) error {
|
|
// we must ensure that we have at least two commits after the selected one
|
|
if len(commits) <= index+2 {
|
|
// assuming they aren't picking the bottom commit
|
|
return errors.New(self.Tr.NoRoom)
|
|
}
|
|
|
|
orderedCommits := append(commits[0:index], commits[index+1], commits[index])
|
|
|
|
todoLines := self.BuildTodoLinesSingleAction(orderedCommits, "pick")
|
|
|
|
return self.PrepareInteractiveRebaseCommand(commits[index+2].Sha, todoLines, 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
|
|
}
|
|
|
|
return self.PrepareInteractiveRebaseCommand(sha, todo, true).Run()
|
|
}
|
|
|
|
// 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(baseSha string, todoLines []TodoLine, overrideEditor bool) oscommands.ICmdObj {
|
|
todo := self.buildTodo(todoLines)
|
|
ex := oscommands.GetLazygitPath()
|
|
|
|
debug := "FALSE"
|
|
if self.Debug {
|
|
debug = "TRUE"
|
|
}
|
|
|
|
cmdStr := fmt.Sprintf("git rebase --interactive --autostash --keep-empty %s", baseSha)
|
|
self.Log.WithField("command", cmdStr).Debug("RunCommand")
|
|
|
|
cmdObj := self.cmd.New(cmdStr)
|
|
|
|
gitSequenceEditor := ex
|
|
if todo == "" {
|
|
gitSequenceEditor = "true"
|
|
} else {
|
|
self.os.LogCommand(fmt.Sprintf("Creating TODO file for interactive rebase: \n\n%s", todo), false)
|
|
}
|
|
|
|
cmdObj.AddEnvVars(
|
|
daemon.DaemonKindEnvKey+"="+string(daemon.InteractiveRebase),
|
|
daemon.RebaseTODOEnvKey+"="+todo,
|
|
"DEBUG="+debug,
|
|
"LANG=en_US.UTF-8", // Force using EN as language
|
|
"LC_ALL=en_US.UTF-8", // Force using EN as language
|
|
"GIT_SEQUENCE_EDITOR="+gitSequenceEditor,
|
|
)
|
|
|
|
if overrideEditor {
|
|
cmdObj.AddEnvVars("GIT_EDITOR=" + ex)
|
|
}
|
|
|
|
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 len(commits) <= baseIndex {
|
|
return nil, "", errors.New(self.Tr.CannotRebaseOntoFirstCommit)
|
|
}
|
|
|
|
if action == "squash" || action == "fixup" {
|
|
baseIndex++
|
|
|
|
if len(commits) <= baseIndex {
|
|
return nil, "", errors.New(self.Tr.CannotSquashOntoSecondCommit)
|
|
}
|
|
}
|
|
|
|
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"
|
|
}
|
|
})
|
|
|
|
return todoLines, commits[baseIndex].Sha, nil
|
|
}
|
|
|
|
// AmendTo amends the given commit with whatever files are staged
|
|
func (self *RebaseCommands) AmendTo(sha string) error {
|
|
if err := self.commit.CreateFixupCommit(sha); err != nil {
|
|
return err
|
|
}
|
|
|
|
return self.SquashAllAboveFixupCommits(sha)
|
|
}
|
|
|
|
// EditRebaseTodo sets the action at a given index in the git-rebase-todo file
|
|
func (self *RebaseCommands) EditRebaseTodo(index int, action string) error {
|
|
fileName := filepath.Join(self.dotGitDir, "rebase-merge/git-rebase-todo")
|
|
bytes, err := os.ReadFile(fileName)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
content := strings.Split(string(bytes), "\n")
|
|
commitCount := self.getTodoCommitCount(content)
|
|
|
|
// we have the most recent commit at the bottom whereas the todo file has
|
|
// it at the bottom, so we need to subtract our index from the commit count
|
|
contentIndex := commitCount - 1 - index
|
|
splitLine := strings.Split(content[contentIndex], " ")
|
|
content[contentIndex] = action + " " + strings.Join(splitLine[1:], " ")
|
|
result := strings.Join(content, "\n")
|
|
|
|
return os.WriteFile(fileName, []byte(result), 0o644)
|
|
}
|
|
|
|
func (self *RebaseCommands) getTodoCommitCount(content []string) int {
|
|
// count lines that are not blank and are not comments
|
|
commitCount := 0
|
|
for _, line := range content {
|
|
if line != "" && !strings.HasPrefix(line, "#") {
|
|
commitCount++
|
|
}
|
|
}
|
|
return commitCount
|
|
}
|
|
|
|
// MoveTodoDown moves a rebase todo item down by one position
|
|
func (self *RebaseCommands) MoveTodoDown(index int) error {
|
|
fileName := filepath.Join(self.dotGitDir, "rebase-merge/git-rebase-todo")
|
|
bytes, err := os.ReadFile(fileName)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
content := strings.Split(string(bytes), "\n")
|
|
commitCount := self.getTodoCommitCount(content)
|
|
contentIndex := commitCount - 1 - index
|
|
|
|
rearrangedContent := append(content[0:contentIndex-1], content[contentIndex], content[contentIndex-1])
|
|
rearrangedContent = append(rearrangedContent, content[contentIndex+1:]...)
|
|
result := strings.Join(rearrangedContent, "\n")
|
|
|
|
return os.WriteFile(fileName, []byte(result), 0o644)
|
|
}
|
|
|
|
// SquashAllAboveFixupCommits squashes all fixup! commits above the given one
|
|
func (self *RebaseCommands) SquashAllAboveFixupCommits(sha string) error {
|
|
return self.runSkipEditorCommand(
|
|
self.cmd.New(
|
|
fmt.Sprintf(
|
|
"git rebase --interactive --rebase-merges --autostash --autosquash %s^",
|
|
sha,
|
|
),
|
|
),
|
|
)
|
|
}
|
|
|
|
// BeginInteractiveRebaseForCommit starts an interactive rebase to edit the current
|
|
// commit and pick all others. After this you'll want to call `self.ContinueRebase()
|
|
func (self *RebaseCommands) BeginInteractiveRebaseForCommit(commits []*models.Commit, commitIndex int) error {
|
|
if len(commits)-1 < commitIndex {
|
|
return errors.New("index outside of range of commits")
|
|
}
|
|
|
|
// we can make this GPG thing possible it just means we need to do this in two parts:
|
|
// one where we handle the possibility of a credential request, and the other
|
|
// where we continue the rebase
|
|
if self.config.UsingGpg() {
|
|
return errors.New(self.Tr.DisabledForGPG)
|
|
}
|
|
|
|
todo, sha, err := self.BuildSingleActionTodo(commits, commitIndex, "edit")
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
return self.PrepareInteractiveRebaseCommand(sha, todo, true).Run()
|
|
}
|
|
|
|
// RebaseBranch interactive rebases onto a branch
|
|
func (self *RebaseCommands) RebaseBranch(branchName string) error {
|
|
return self.PrepareInteractiveRebaseCommand(branchName, nil, false).Run()
|
|
}
|
|
|
|
func (self *RebaseCommands) GenericMergeOrRebaseActionCmdObj(commandType string, command string) oscommands.ICmdObj {
|
|
return self.cmd.New("git " + commandType + " --" + command)
|
|
}
|
|
|
|
func (self *RebaseCommands) ContinueRebase() error {
|
|
return self.GenericMergeOrRebaseAction("rebase", "continue")
|
|
}
|
|
|
|
func (self *RebaseCommands) AbortRebase() error {
|
|
return self.GenericMergeOrRebaseAction("rebase", "abort")
|
|
}
|
|
|
|
// GenericMerge takes a commandType of "merge" or "rebase" and a command of "abort", "skip" or "continue"
|
|
// By default we skip the editor in the case where a commit will be made
|
|
func (self *RebaseCommands) GenericMergeOrRebaseAction(commandType string, command string) error {
|
|
err := self.runSkipEditorCommand(self.GenericMergeOrRebaseActionCmdObj(commandType, command))
|
|
if err != nil {
|
|
if !strings.Contains(err.Error(), "no rebase in progress") {
|
|
return err
|
|
}
|
|
self.Log.Warn(err)
|
|
}
|
|
|
|
// sometimes we need to do a sequence of things in a rebase but the user needs to
|
|
// fix merge conflicts along the way. When this happens we queue up the next step
|
|
// so that after the next successful rebase continue we can continue from where we left off
|
|
if commandType == "rebase" && command == "continue" && self.onSuccessfulContinue != nil {
|
|
f := self.onSuccessfulContinue
|
|
self.onSuccessfulContinue = nil
|
|
return f()
|
|
}
|
|
if command == "abort" {
|
|
self.onSuccessfulContinue = nil
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func (self *RebaseCommands) runSkipEditorCommand(cmdObj oscommands.ICmdObj) error {
|
|
lazyGitPath := oscommands.GetLazygitPath()
|
|
return cmdObj.
|
|
AddEnvVars(
|
|
daemon.DaemonKindEnvKey+"="+string(daemon.ExitImmediately),
|
|
"GIT_EDITOR="+lazyGitPath,
|
|
"EDITOR="+lazyGitPath,
|
|
"VISUAL="+lazyGitPath,
|
|
).
|
|
Run()
|
|
}
|
|
|
|
// DiscardOldFileChanges discards changes to a file from an old commit
|
|
func (self *RebaseCommands) DiscardOldFileChanges(commits []*models.Commit, commitIndex int, fileName string) error {
|
|
if err := self.BeginInteractiveRebaseForCommit(commits, commitIndex); err != nil {
|
|
return err
|
|
}
|
|
|
|
// check if file exists in previous commit (this command returns an error if the file doesn't exist)
|
|
if err := self.cmd.New("git cat-file -e HEAD^:" + self.cmd.Quote(fileName)).Run(); err != nil {
|
|
if err := self.os.Remove(fileName); err != nil {
|
|
return err
|
|
}
|
|
if err := self.workingTree.StageFile(fileName); err != nil {
|
|
return err
|
|
}
|
|
} else if err := self.workingTree.CheckoutFile("HEAD^", fileName); err != nil {
|
|
return err
|
|
}
|
|
|
|
// amend the commit
|
|
err := self.commit.AmendHead()
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
// continue
|
|
return self.ContinueRebase()
|
|
}
|
|
|
|
// 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")
|
|
|
|
return self.PrepareInteractiveRebaseCommand("HEAD", todoLines, false).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 {
|
|
return self.Action + " " + self.Commit.Sha + " " + self.Commit.Name + "\n"
|
|
}
|