1
0
mirror of https://github.com/jesseduffield/lazygit.git synced 2024-12-14 11:23:09 +02:00
lazygit/pkg/commands/git_commands/commit_loader.go

589 lines
18 KiB
Go
Raw Normal View History

package git_commands
import (
"bytes"
"fmt"
"os"
"path/filepath"
"regexp"
"strconv"
"strings"
"github.com/fsmiamoto/git-todo-parser/todo"
2022-03-19 07:34:46 +02:00
"github.com/jesseduffield/generics/slices"
"github.com/jesseduffield/lazygit/pkg/commands/models"
"github.com/jesseduffield/lazygit/pkg/commands/oscommands"
2021-12-30 04:35:10 +02:00
"github.com/jesseduffield/lazygit/pkg/commands/types/enums"
2021-12-29 05:33:38 +02:00
"github.com/jesseduffield/lazygit/pkg/common"
"github.com/samber/lo"
)
// context:
// here we get the commits from git log but format them to show whether they're
// unpushed/pushed/merged into the base branch or not, or if they're yet to
// be processed as part of a rebase (these won't appear in git log but we
// grab them from the rebase-related files in the .git directory to show them
2021-12-30 04:35:10 +02:00
// CommitLoader returns a list of Commit objects for the current repo
type CommitLoader struct {
2021-12-29 05:33:38 +02:00
*common.Common
cmd oscommands.ICmdObjBuilder
getRebaseMode func() (enums.RebaseMode, error)
readFile func(filename string) ([]byte, error)
walkFiles func(root string, fn filepath.WalkFunc) error
dotGitDir string
// List of main branches that exist in the repo.
// We use these to obtain the merge base of the branch.
// When nil, we're yet to obtain the list of existing main branches.
// When an empty slice, we've obtained the list and it's empty.
mainBranches []string
*GitCommon
}
2021-12-30 04:44:41 +02:00
// making our dependencies explicit for the sake of easier testing
2021-12-30 04:35:10 +02:00
func NewCommitLoader(
2021-12-30 04:44:41 +02:00
cmn *common.Common,
2022-01-02 01:34:33 +02:00
cmd oscommands.ICmdObjBuilder,
dotGitDir string,
getRebaseMode func() (enums.RebaseMode, error),
gitCommon *GitCommon,
2021-12-30 04:35:10 +02:00
) *CommitLoader {
return &CommitLoader{
Common: cmn,
cmd: cmd,
getRebaseMode: getRebaseMode,
readFile: os.ReadFile,
walkFiles: filepath.Walk,
dotGitDir: dotGitDir,
mainBranches: nil,
GitCommon: gitCommon,
2020-08-22 00:49:02 +02:00
}
}
type GetCommitsOptions struct {
2020-08-22 00:49:02 +02:00
Limit bool
FilterPath string
IncludeRebaseCommits bool
RefName string // e.g. "HEAD" or "my_branch"
2021-11-02 12:16:00 +02:00
// determines if we show the whole git graph i.e. pass the '--all' flag
All bool
}
// GetCommits obtains the commits of the current branch
2021-12-30 04:35:10 +02:00
func (self *CommitLoader) GetCommits(opts GetCommitsOptions) ([]*models.Commit, error) {
2020-09-29 10:36:54 +02:00
commits := []*models.Commit{}
var rebasingCommits []*models.Commit
2020-08-22 00:49:02 +02:00
if opts.IncludeRebaseCommits && opts.FilterPath == "" {
2020-08-22 00:49:02 +02:00
var err error
2021-12-30 02:22:29 +02:00
rebasingCommits, err = self.MergeRebasingCommits(commits)
if err != nil {
return nil, err
}
commits = append(commits, rebasingCommits...)
}
2020-08-27 09:05:07 +02:00
passedFirstPushedCommit := false
2021-12-30 02:22:29 +02:00
firstPushedCommit, err := self.getFirstPushedCommit(opts.RefName)
if err != nil {
2020-08-27 09:05:07 +02:00
// must have no upstream branch so we'll consider everything as pushed
passedFirstPushedCommit = true
}
2021-12-30 02:22:29 +02:00
err = self.getLogCmd(opts).RunAndProcessLines(func(line string) (bool, error) {
commit := self.extractCommitFromLine(line)
if commit.Sha == firstPushedCommit {
passedFirstPushedCommit = true
}
commit.Status = map[bool]models.CommitStatus{true: models.StatusUnpushed, false: models.StatusPushed}[!passedFirstPushedCommit]
commits = append(commits, commit)
return false, nil
})
if err != nil {
return nil, err
}
2021-12-30 04:11:58 +02:00
if len(commits) == 0 {
return commits, nil
}
commits = self.setCommitMergedStatuses(opts.RefName, commits)
2019-02-24 04:51:52 +02:00
return commits, nil
}
2021-12-30 04:35:10 +02:00
func (self *CommitLoader) MergeRebasingCommits(commits []*models.Commit) ([]*models.Commit, error) {
2021-12-30 04:11:58 +02:00
// chances are we have as many commits as last time so we'll set the capacity to be the old length
result := make([]*models.Commit, 0, len(commits))
for i, commit := range commits {
if !commit.IsTODO() { // removing the existing rebase commits so we can add the refreshed ones
2021-12-30 04:11:58 +02:00
result = append(result, commits[i:]...)
break
}
}
rebaseMode, err := self.getRebaseMode()
if err != nil {
return nil, err
}
2021-12-30 04:35:10 +02:00
if rebaseMode == enums.REBASE_MODE_NONE {
2021-12-30 04:11:58 +02:00
// not in rebase mode so return original commits
return result, nil
}
rebasingCommits, err := self.getHydratedRebasingCommits(rebaseMode)
if err != nil {
return nil, err
}
if len(rebasingCommits) > 0 {
result = append(rebasingCommits, result...)
}
return result, nil
}
// extractCommitFromLine takes a line from a git log and extracts the sha, message, date, and tag if present
// then puts them into a commit object
// example input:
// 8ad01fe32fcc20f07bc6693f87aa4977c327f1e1|10 hours ago|Jesse Duffield| (HEAD -> master, tag: v0.15.2)|refresh commits when adding a tag
2021-12-30 04:35:10 +02:00
func (self *CommitLoader) extractCommitFromLine(line string) *models.Commit {
2022-05-08 06:23:29 +02:00
split := strings.SplitN(line, "\x00", 7)
2021-12-30 04:11:58 +02:00
sha := split[0]
unixTimestamp := split[1]
2022-05-08 06:23:29 +02:00
authorName := split[2]
authorEmail := split[3]
extraInfo := strings.TrimSpace(split[4])
parentHashes := split[5]
message := split[6]
2021-12-30 04:11:58 +02:00
tags := []string{}
if extraInfo != "" {
re := regexp.MustCompile(`tag: ([^,\)]+)`)
tagMatch := re.FindStringSubmatch(extraInfo)
if len(tagMatch) > 1 {
tags = append(tags, tagMatch[1])
}
}
unitTimestampInt, _ := strconv.Atoi(unixTimestamp)
2022-03-26 15:03:32 +02:00
parents := []string{}
if len(parentHashes) > 0 {
parents = strings.Split(parentHashes, " ")
}
2021-12-30 04:11:58 +02:00
return &models.Commit{
Sha: sha,
Name: message,
Tags: tags,
ExtraInfo: extraInfo,
UnixTimestamp: int64(unitTimestampInt),
2022-05-08 06:23:29 +02:00
AuthorName: authorName,
AuthorEmail: authorEmail,
2022-03-26 15:03:32 +02:00
Parents: parents,
2021-12-30 04:11:58 +02:00
}
}
2021-12-30 04:35:10 +02:00
func (self *CommitLoader) getHydratedRebasingCommits(rebaseMode enums.RebaseMode) ([]*models.Commit, error) {
2021-12-30 02:22:29 +02:00
commits, err := self.getRebasingCommits(rebaseMode)
2021-10-30 08:42:52 +02:00
if err != nil {
return nil, err
}
2021-10-30 09:06:39 +02:00
if len(commits) == 0 {
return nil, nil
}
commitShas := slices.FilterMap(commits, func(commit *models.Commit) (string, bool) {
return commit.Sha, commit.Sha != ""
2022-03-19 07:34:46 +02:00
})
2021-10-30 08:42:52 +02:00
// note that we're not filtering these as we do non-rebasing commits just because
// I suspect that will cause some damage
2021-12-30 02:22:29 +02:00
cmdObj := self.cmd.New(
NewGitCmd("show").
Config("log.showSignature=false").
Arg("--no-patch", "--oneline", "--abbrev=20", prettyFormat).
Arg(commitShas...).
ToArgv(),
2022-01-05 02:57:32 +02:00
).DontLog()
2021-10-30 08:42:52 +02:00
fullCommits := map[string]*models.Commit{}
2021-12-30 02:22:29 +02:00
err = cmdObj.RunAndProcessLines(func(line string) (bool, error) {
commit := self.extractCommitFromLine(line)
fullCommits[commit.Sha] = commit
2021-10-30 08:42:52 +02:00
return false, nil
})
if err != nil {
return nil, err
}
findFullCommit := lo.Ternary(self.version.IsOlderThan(2, 25, 2),
func(sha string) *models.Commit {
for s, c := range fullCommits {
if strings.HasPrefix(s, sha) {
return c
}
}
return nil
},
func(sha string) *models.Commit {
return fullCommits[sha]
})
hydratedCommits := make([]*models.Commit, 0, len(commits))
for _, rebasingCommit := range commits {
if rebasingCommit.Sha == "" {
hydratedCommits = append(hydratedCommits, rebasingCommit)
} else if commit := findFullCommit(rebasingCommit.Sha); commit != nil {
commit.Action = rebasingCommit.Action
commit.Status = rebasingCommit.Status
hydratedCommits = append(hydratedCommits, commit)
}
}
2021-10-30 08:42:52 +02:00
return hydratedCommits, nil
}
// getRebasingCommits obtains the commits that we're in the process of rebasing
2021-12-30 04:35:10 +02:00
func (self *CommitLoader) getRebasingCommits(rebaseMode enums.RebaseMode) ([]*models.Commit, error) {
switch rebaseMode {
2021-12-30 04:35:10 +02:00
case enums.REBASE_MODE_MERGING:
2021-12-30 02:22:29 +02:00
return self.getNormalRebasingCommits()
2021-12-30 04:35:10 +02:00
case enums.REBASE_MODE_INTERACTIVE:
2021-12-30 02:22:29 +02:00
return self.getInteractiveRebasingCommits()
default:
return nil, nil
}
}
2021-12-30 04:35:10 +02:00
func (self *CommitLoader) getNormalRebasingCommits() ([]*models.Commit, error) {
rewrittenCount := 0
2021-12-30 02:22:29 +02:00
bytesContent, err := self.readFile(filepath.Join(self.dotGitDir, "rebase-apply/rewritten"))
if err == nil {
content := string(bytesContent)
rewrittenCount = len(strings.Split(content, "\n"))
}
// we know we're rebasing, so lets get all the files whose names have numbers
2020-09-29 10:36:54 +02:00
commits := []*models.Commit{}
2021-12-30 02:22:29 +02:00
err = self.walkFiles(filepath.Join(self.dotGitDir, "rebase-apply"), func(path string, f os.FileInfo, err error) error {
if rewrittenCount > 0 {
rewrittenCount--
return nil
}
if err != nil {
return err
}
re := regexp.MustCompile(`^\d+$`)
if !re.MatchString(f.Name()) {
return nil
}
2021-12-30 02:22:29 +02:00
bytesContent, err := self.readFile(path)
if err != nil {
return err
}
content := string(bytesContent)
2022-01-08 06:46:35 +02:00
commit := self.commitFromPatch(content)
2020-09-29 10:36:54 +02:00
commits = append([]*models.Commit{commit}, commits...)
return nil
})
if err != nil {
return nil, err
}
return commits, nil
}
// git-rebase-todo example:
// pick ac446ae94ee560bdb8d1d057278657b251aaef17 ac446ae
// pick afb893148791a2fbd8091aeb81deba4930c73031 afb8931
// git-rebase-todo.backup example:
// pick 49cbba374296938ea86bbd4bf4fee2f6ba5cccf6 third commit on master
// pick ac446ae94ee560bdb8d1d057278657b251aaef17 blah commit on master
// pick afb893148791a2fbd8091aeb81deba4930c73031 fourth commit on master
// getInteractiveRebasingCommits takes our git-rebase-todo and our git-rebase-todo.backup files
// and extracts out the sha and names of commits that we still have to go
// in the rebase:
2021-12-30 04:35:10 +02:00
func (self *CommitLoader) getInteractiveRebasingCommits() ([]*models.Commit, error) {
2021-12-30 02:22:29 +02:00
bytesContent, err := self.readFile(filepath.Join(self.dotGitDir, "rebase-merge/git-rebase-todo"))
if err != nil {
2021-12-30 02:22:29 +02:00
self.Log.Error(fmt.Sprintf("error occurred reading git-rebase-todo: %s", err.Error()))
// we assume an error means the file doesn't exist so we just return
return nil, nil
}
2020-09-29 10:36:54 +02:00
commits := []*models.Commit{}
todos, err := todo.Parse(bytes.NewBuffer(bytesContent), self.config.GetCoreCommentChar())
if err != nil {
self.Log.Error(fmt.Sprintf("error occurred while parsing git-rebase-todo file: %s", err.Error()))
return nil, nil
}
// See if the current commit couldn't be applied because it conflicted; if
// so, add a fake entry for it
if conflictedCommitSha := self.getConflictedCommit(todos); conflictedCommitSha != "" {
commits = append(commits, &models.Commit{
Sha: conflictedCommitSha,
Name: "",
Status: models.StatusRebasing,
Action: models.ActionConflict,
})
}
for _, t := range todos {
if t.Command == todo.UpdateRef {
t.Msg = strings.TrimPrefix(t.Ref, "refs/heads/")
} else if t.Commit == "" {
// Command does not have a commit associated, skip
2020-04-22 03:15:41 +02:00
continue
}
commits = slices.Prepend(commits, &models.Commit{
Sha: t.Commit,
Name: t.Msg,
Status: models.StatusRebasing,
Action: t.Command,
})
}
2020-04-22 03:21:20 +02:00
return commits, nil
}
func (self *CommitLoader) getConflictedCommit(todos []todo.Todo) string {
bytesContent, err := self.readFile(filepath.Join(self.dotGitDir, "rebase-merge/done"))
if err != nil {
self.Log.Error(fmt.Sprintf("error occurred reading rebase-merge/done: %s", err.Error()))
return ""
}
doneTodos, err := todo.Parse(bytes.NewBuffer(bytesContent), self.config.GetCoreCommentChar())
if err != nil {
self.Log.Error(fmt.Sprintf("error occurred while parsing rebase-merge/done file: %s", err.Error()))
return ""
}
amendFileExists := false
if _, err := os.Stat(filepath.Join(self.dotGitDir, "rebase-merge/amend")); err == nil {
amendFileExists = true
}
return self.getConflictedCommitImpl(todos, doneTodos, amendFileExists)
}
func (self *CommitLoader) getConflictedCommitImpl(todos []todo.Todo, doneTodos []todo.Todo, amendFileExists bool) string {
// Should never be possible, but just to be safe:
if len(doneTodos) == 0 {
self.Log.Error("no done entries in rebase-merge/done file")
return ""
}
lastTodo := doneTodos[len(doneTodos)-1]
if lastTodo.Command == todo.Break || lastTodo.Command == todo.Exec || lastTodo.Command == todo.Reword {
return ""
}
// In certain cases, git reschedules commands that failed. One example is if
// a patch would overwrite an untracked file (another one is an "exec" that
// failed, but we don't care about that here because we dealt with exec
// already above). To detect this, compare the last command of the "done"
// file against the first command of "git-rebase-todo"; if they are the
// same, the command was rescheduled.
if len(doneTodos) > 0 && len(todos) > 0 && doneTodos[len(doneTodos)-1] == todos[0] {
// Command was rescheduled, no need to display it
return ""
}
// Older versions of git have a bug whereby, if a command is rescheduled,
// the last successful command is appended to the "done" file again. To
// detect this, we need to compare the second-to-last done entry against the
// first todo entry, and also compare the last done entry against the
// last-but-two done entry; this latter check is needed for the following
// case:
// pick A
// exec make test
// pick B
// exec make test
// If pick B fails with conflicts, then the "done" file contains
// pick A
// exec make test
// pick B
// and git-rebase-todo contains
// exec make test
// Without the last condition we would erroneously treat this as the exec
// command being rescheduled, so we wouldn't display our fake entry for
// "pick B".
if len(doneTodos) >= 3 && len(todos) > 0 && doneTodos[len(doneTodos)-2] == todos[0] &&
doneTodos[len(doneTodos)-1] == doneTodos[len(doneTodos)-3] {
// Command was rescheduled, no need to display it
return ""
}
if lastTodo.Command == todo.Edit {
if amendFileExists {
// Special case for "edit": if the "amend" file exists, the "edit"
// command was successful, otherwise it wasn't
return ""
}
}
// I don't think this is ever possible, but again, just to be safe:
if lastTodo.Commit == "" {
self.Log.Error("last command in rebase-merge/done file doesn't have a commit")
return ""
}
// Any other todo that has a commit associated with it must have failed with
// a conflict, otherwise we wouldn't have stopped the rebase:
return lastTodo.Commit
}
// assuming the file starts like this:
// From e93d4193e6dd45ca9cf3a5a273d7ba6cd8b8fb20 Mon Sep 17 00:00:00 2001
// From: Lazygit Tester <test@example.com>
// Date: Wed, 5 Dec 2018 21:03:23 +1100
// Subject: second commit on master
2022-01-08 06:46:35 +02:00
func (self *CommitLoader) commitFromPatch(content string) *models.Commit {
lines := strings.Split(content, "\n")
sha := strings.Split(lines[0], " ")[1]
name := strings.TrimPrefix(lines[3], "Subject: ")
2020-09-29 10:36:54 +02:00
return &models.Commit{
Sha: sha,
Name: name,
Status: models.StatusRebasing,
2022-01-08 06:46:35 +02:00
}
}
func (self *CommitLoader) setCommitMergedStatuses(refName string, commits []*models.Commit) []*models.Commit {
ancestor := self.getMergeBase(refName)
if ancestor == "" {
return commits
}
passedAncestor := false
for i, commit := range commits {
if strings.HasPrefix(ancestor, commit.Sha) {
passedAncestor = true
}
if commit.Status != models.StatusPushed && commit.Status != models.StatusUnpushed {
continue
}
if passedAncestor {
commits[i].Status = models.StatusMerged
}
}
return commits
}
func (self *CommitLoader) getMergeBase(refName string) string {
if self.mainBranches == nil {
self.mainBranches = self.getExistingMainBranches()
}
if len(self.mainBranches) == 0 {
return ""
}
// We pass all configured main branches to the merge-base call; git will
// return the base commit for the closest one.
output, err := self.cmd.New(
NewGitCmd("merge-base").Arg(refName).Arg(self.mainBranches...).
ToArgv(),
).DontLog().RunWithOutput()
if err != nil {
// If there's an error, it must be because one of the main branches that
// used to exist when we called getExistingMainBranches() was deleted
// meanwhile. To fix this for next time, throw away our cache.
self.mainBranches = nil
}
return ignoringWarnings(output)
}
func (self *CommitLoader) getExistingMainBranches() []string {
return lo.FilterMap(self.UserConfig.Git.MainBranches,
func(branchName string, _ int) (string, bool) {
// Try to determine upstream of local main branch
if ref, err := self.cmd.New(
NewGitCmd("rev-parse").Arg("--symbolic-full-name", branchName+"@{u}").ToArgv(),
).DontLog().RunWithOutput(); err == nil {
return strings.TrimSpace(ref), true
}
// If this failed, a local branch for this main branch doesn't exist or it
// has no upstream configured. Try looking for one in the "origin" remote.
ref := "refs/remotes/origin/" + branchName
if err := self.cmd.New(
NewGitCmd("rev-parse").Arg("--verify", "--quiet", ref).ToArgv(),
).DontLog().Run(); err == nil {
return ref, true
}
// If this failed as well, try if we have the main branch as a local
// branch. This covers the case where somebody is using git locally
// for something, but never pushing anywhere.
ref = "refs/heads/" + branchName
if err := self.cmd.New(
NewGitCmd("rev-parse").Arg("--verify", "--quiet", ref).ToArgv(),
).DontLog().Run(); err == nil {
return ref, true
}
return "", false
})
}
func ignoringWarnings(commandOutput string) string {
trimmedOutput := strings.TrimSpace(commandOutput)
split := strings.Split(trimmedOutput, "\n")
// need to get last line in case the first line is a warning about how the error is ambiguous.
// At some point we should find a way to make it unambiguous
lastLine := split[len(split)-1]
return lastLine
}
// getFirstPushedCommit returns the first commit SHA which has been pushed to the ref's upstream.
// all commits above this are deemed unpushed and marked as such.
2021-12-30 04:35:10 +02:00
func (self *CommitLoader) getFirstPushedCommit(refName string) (string, error) {
output, err := self.cmd.New(
NewGitCmd("merge-base").
Arg(refName).
Arg(strings.TrimPrefix(refName, "refs/heads/") + "@{u}").
ToArgv(),
).
2022-01-05 02:57:32 +02:00
DontLog().
2021-12-30 02:22:29 +02:00
RunWithOutput()
if err != nil {
return "", err
}
return ignoringWarnings(output), nil
}
2020-01-11 09:23:35 +02:00
// getLog gets the git log.
2021-12-30 04:35:10 +02:00
func (self *CommitLoader) getLogCmd(opts GetCommitsOptions) oscommands.ICmdObj {
2021-12-30 02:22:29 +02:00
config := self.UserConfig.Git.Log
2021-11-02 11:05:23 +02:00
cmdArgs := NewGitCmd("log").
Arg(opts.RefName).
ArgIf(config.Order != "default", "--"+config.Order).
ArgIf(opts.All, "--all").
Arg("--oneline").
Arg(prettyFormat).
Arg("--abbrev=40").
ArgIf(opts.Limit, "-300").
ArgIf(opts.FilterPath != "", "--follow").
Arg("--no-show-signature").
Arg("--").
ArgIf(opts.FilterPath != "", opts.FilterPath).
ToArgv()
return self.cmd.New(cmdArgs).DontLog()
}
2021-10-30 08:42:52 +02:00
const prettyFormat = `--pretty=format:%H%x00%at%x00%aN%x00%ae%x00%d%x00%p%x00%s`