summaryrefslogtreecommitdiffstats
path: root/pkg/commands/git_commands
diff options
context:
space:
mode:
Diffstat (limited to 'pkg/commands/git_commands')
-rw-r--r--pkg/commands/git_commands/branch_loader.go204
-rw-r--r--pkg/commands/git_commands/branch_loader_test.go52
-rw-r--r--pkg/commands/git_commands/commit_file_loader.go56
-rw-r--r--pkg/commands/git_commands/commit_file_loader_test.go71
-rw-r--r--pkg/commands/git_commands/commit_loader.go459
-rw-r--r--pkg/commands/git_commands/commit_loader_test.go206
-rw-r--r--pkg/commands/git_commands/deps_test.go5
-rw-r--r--pkg/commands/git_commands/file_loader.go123
-rw-r--r--pkg/commands/git_commands/file_loader_test.go210
-rw-r--r--pkg/commands/git_commands/reflog_commit_loader.go77
-rw-r--r--pkg/commands/git_commands/reflog_commit_loader_test.go169
-rw-r--r--pkg/commands/git_commands/remote_loader.go77
-rw-r--r--pkg/commands/git_commands/stash.go7
-rw-r--r--pkg/commands/git_commands/stash_loader.go79
-rw-r--r--pkg/commands/git_commands/stash_loader_test.go60
-rw-r--r--pkg/commands/git_commands/tag_loader.go43
-rw-r--r--pkg/commands/git_commands/tag_loader_test.go68
-rw-r--r--pkg/commands/git_commands/working_tree.go7
18 files changed, 1962 insertions, 11 deletions
diff --git a/pkg/commands/git_commands/branch_loader.go b/pkg/commands/git_commands/branch_loader.go
new file mode 100644
index 000000000..08811374d
--- /dev/null
+++ b/pkg/commands/git_commands/branch_loader.go
@@ -0,0 +1,204 @@
+package git_commands
+
+import (
+ "regexp"
+ "strings"
+
+ "github.com/jesseduffield/generics/set"
+ "github.com/jesseduffield/generics/slices"
+ "github.com/jesseduffield/go-git/v5/config"
+ "github.com/jesseduffield/lazygit/pkg/commands/models"
+ "github.com/jesseduffield/lazygit/pkg/common"
+ "github.com/jesseduffield/lazygit/pkg/utils"
+)
+
+// context:
+// we want to only show 'safe' branches (ones that haven't e.g. been deleted)
+// which `git branch -a` gives us, but we also want the recency data that
+// git reflog gives us.
+// So we get the HEAD, then append get the reflog branches that intersect with
+// our safe branches, then add the remaining safe branches, ensuring uniqueness
+// along the way
+
+// if we find out we need to use one of these functions in the git.go file, we
+// can just pull them out of here and put them there and then call them from in here
+
+type BranchLoaderConfigCommands interface {
+ Branches() (map[string]*config.Branch, error)
+}
+
+// BranchLoader returns a list of Branch objects for the current repo
+type BranchLoader struct {
+ *common.Common
+ getRawBranches func() (string, error)
+ getCurrentBranchName func() (string, string, error)
+ config BranchLoaderConfigCommands
+}
+
+func NewBranchLoader(
+ cmn *common.Common,
+ getRawBranches func() (string, error),
+ getCurrentBranchName func() (string, string, error),
+ config BranchLoaderConfigCommands,
+) *BranchLoader {
+ return &BranchLoader{
+ Common: cmn,
+ getRawBranches: getRawBranches,
+ getCurrentBranchName: getCurrentBranchName,
+ config: config,
+ }
+}
+
+// Load the list of branches for the current repo
+func (self *BranchLoader) Load(reflogCommits []*models.Commit) ([]*models.Branch, error) {
+ branches := self.obtainBranches()
+
+ reflogBranches := self.obtainReflogBranches(reflogCommits)
+
+ // loop through reflog branches. If there is a match, merge them, then remove it from the branches and keep it in the reflog branches
+ branchesWithRecency := make([]*models.Branch, 0)
+outer:
+ for _, reflogBranch := range reflogBranches {
+ for j, branch := range branches {
+ if branch.Head {
+ continue
+ }
+ if strings.EqualFold(reflogBranch.Name, branch.Name) {
+ branch.Recency = reflogBranch.Recency
+ branchesWithRecency = append(branchesWithRecency, branch)
+ branches = slices.Remove(branches, j)
+ continue outer
+ }
+ }
+ }
+
+ branches = slices.Prepend(branches, branchesWithRecency...)
+
+ foundHead := false
+ for i, branch := range branches {
+ if branch.Head {
+ foundHead = true
+ branch.Recency = " *"
+ branches = slices.Move(branches, i, 0)
+ break
+ }
+ }
+ if !foundHead {
+ currentBranchName, currentBranchDisplayName, err := self.getCurrentBranchName()
+ if err != nil {
+ return nil, err
+ }
+ branches = slices.Prepend(branches, &models.Branch{Name: currentBranchName, DisplayName: currentBranchDisplayName, Head: true, Recency: " *"})
+ }
+
+ configBranches, err := self.config.Branches()
+ if err != nil {
+ return nil, err
+ }
+
+ for _, branch := range branches {
+ match := configBranches[branch.Name]
+ if match != nil {
+ branch.UpstreamRemote = match.Remote
+ branch.UpstreamBranch = match.Merge.Short()
+ }
+ }
+
+ return branches, nil
+}
+
+func (self *BranchLoader) obtainBranches() []*models.Branch {
+ output, err := self.getRawBranches()
+ if err != nil {
+ panic(err)
+ }
+
+ trimmedOutput := strings.TrimSpace(output)
+ outputLines := strings.Split(trimmedOutput, "\n")
+
+ return slices.FilterMap(outputLines, func(line string) (*models.Branch, bool) {
+ if line == "" {
+ return nil, false
+ }
+
+ split := strings.Split(line, "\x00")
+ if len(split) != 4 {
+ // Ignore line if it isn't separated into 4 parts
+ // This is probably a warning message, for more info see:
+ // https://github.com/jesseduffield/lazygit/issues/1385#issuecomment-885580439
+ return nil, false
+ }
+
+ return obtainBranch(split), true
+ })
+}
+
+// Obtain branch information from parsed line output of getRawBranches()
+// split contains the '|' separated tokens in the line of output
+func obtainBranch(split []string) *models.Branch {
+ name := strings.TrimPrefix(split[1], "heads/")
+ branch := &models.Branch{
+ Name: name,
+ Pullables: "?",
+ Pushables: "?",
+ Head: split[0] == "*",
+ }
+
+ upstreamName := split[2]
+ if upstreamName == "" {
+ // if we're here then it means we do not have a local version of the remote.
+ // The branch might still be tracking a remote though, we just don't know
+ // how many commits ahead/behind it is
+ return branch
+ }
+
+ track := split[3]
+ if track == "[gone]" {
+ branch.UpstreamGone = true
+ } else {
+ re := regexp.MustCompile(`ahead (\d+)`)
+ match := re.FindStringSubmatch(track)
+ if len(match) > 1 {
+ branch.Pushables = match[1]
+ } else {
+ branch.Pushables = "0"
+ }
+
+ re = regexp.MustCompile(`behind (\d+)`)
+ match = re.FindStringSubmatch(track)
+ if len(match) > 1 {
+ branch.Pullables = match[1]
+ } else {
+ branch.Pullables = "0"
+ }
+ }
+
+ return branch
+}
+
+// TODO: only look at the new reflog commits, and otherwise store the recencies in
+// int form against the branch to recalculate the time ago
+func (self *BranchLoader) obtainReflogBranches(reflogCommits []*models.Commit) []*models.Branch {
+ foundBranches := set.New[string]()
+ re := regexp.MustCompile(`checkout: moving from ([\S]+) to ([\S]+)`)
+ reflogBranches := make([]*models.Branch, 0, len(reflogCommits))
+
+ for _, commit := range reflogCommits {
+ match := re.FindStringSubmatch(commit.Name)
+ if len(match) != 3 {
+ continue
+ }
+
+ recency := utils.UnixToTimeAgo(commit.UnixTimestamp)
+ for _, branchName := range match[1:] {
+ if !foundBranches.Includes(branchName) {
+ foundBranches.Add(branchName)
+ reflogBranches = append(reflogBranches, &models.Branch{
+ Recency: recency,
+ Name: branchName,
+ })
+ }
+ }
+ }
+ return reflogBranches
+}
diff --git a/pkg/commands/git_commands/branch_loader_test.go b/pkg/commands/git_commands/branch_loader_test.go
new file mode 100644
index 000000000..c147c1484
--- /dev/null
+++ b/pkg/commands/git_commands/branch_loader_test.go
@@ -0,0 +1,52 @@
+package git_commands
+
+// "*|feat/detect-purge|origin/feat/detect-purge|[ahead 1]"
+import (
+ "testing"
+
+ "github.com/jesseduffield/lazygit/pkg/commands/models"
+ "github.com/stretchr/testify/assert"
+)
+
+func TestObtainBanch(t *testing.T) {
+ type scenario struct {
+ testName string
+ input []string
+ expectedBranch *models.Branch
+ }
+
+ scenarios := []scenario{
+ {
+ testName: "TrimHeads",
+ input: []string{"", "heads/a_branch", "", ""},
+ expectedBranch: &models.Branch{Name: "a_branch", Pushables: "?", Pullables: "?", Head: false},
+ },
+ {
+ testName: "NoUpstream",
+ input: []string{"", "a_branch", "", ""},
+ expectedBranch: &models.Branch{Name: "a_branch", Pushables: "?", Pullables: "?", Head: false},
+ },
+ {
+ testName: "IsHead",
+ input: []string{"*", "a_branch", "", ""},
+ expectedBranch: &models.Branch{Name: "a_branch", Pushables: "?", Pullables: "?", Head: true},
+ },
+ {
+ testName: "IsBehindAndAhead",
+ input: []string{"", "a_branch", "a_remote/a_branch", "[behind 2, ahead 3]"},
+ expectedBranch: &models.Branch{Name: "a_branch", Pushables: "3", Pullables: "2", Head: false},
+ },
+ {
+ testName: "RemoteBranchIsGone",
+ input: []string{"", "a_branch", "a_remote/a_branch", "[gone]"},
+ expectedBranch: &models.Branch{Name: "a_branch", UpstreamGone: true, Pushables: "?", Pullables: "?", Head: false},
+ },
+ }
+
+ for _, s := range scenarios {
+ t.Run(s.testName, func(t *testing.T) {
+ branch := obtainBranch(s.input)
+ assert.EqualValues(t, s.expectedBranch, branch)
+ })
+ }
+}
diff --git a/pkg/commands/git_commands/commit_file_loader.go b/pkg/commands/git_commands/commit_file_loader.go
new file mode 100644
index 000000000..0b606ae86
--- /dev/null
+++ b/pkg/commands/git_commands/commit_file_loader.go
@@ -0,0 +1,56 @@
+package git_commands
+
+import (
+ "fmt"
+ "strings"
+
+ "github.com/jesseduffield/generics/slices"
+ "github.com/jesseduffield/lazygit/pkg/commands/models"
+ "github.com/jesseduffield/lazygit/pkg/commands/oscommands"
+ "github.com/jesseduffield/lazygit/pkg/common"
+ "github.com/samber/lo"
+)
+
+type CommitFileLoader struct {
+ *common.Common
+ cmd oscommands.ICmdObjBuilder
+}
+
+func NewCommitFileLoader(common *common.Common, cmd oscommands.ICmdObjBuilder) *CommitFileLoader {
+ return &CommitFileLoader{
+ Common: common,
+ cmd: cmd,
+ }
+}
+
+// GetFilesInDiff get the specified commit files
+func (self *CommitFileLoader) GetFilesInDiff(from string, to string, reverse bool) ([]*models.CommitFile, error) {
+ reverseFlag := ""
+ if reverse {
+ reverseFlag = " -R "
+ }
+
+ filenames, err := self.cmd.New(fmt.Sprintf("git diff --submodule --no-ext-diff --name-status -z --no-renames %s %s %s", reverseFlag, from, to)).DontLog().RunWithOutput()
+ if err != nil {
+ return nil, err
+ }
+
+ return getCommitFilesFromFilenames(filenames), nil
+}
+
+// filenames string is something like "MM\x00file1\x00MU\x00file2\x00AA\x00file3\x00"
+// so we need to split it by the null character and then map each status-name pair to a commit file
+func getCommitFilesFromFilenames(filenames string) []*models.CommitFile {
+ lines := strings.Split(strings.TrimRight(filenames, "\x00"), "\x00")
+ if len(lines) == 1 {
+ return []*models.CommitFile{}
+ }
+
+ // typical result looks like 'A my_file' meaning my_file was added
+ return slices.Map(lo.Chunk(lines, 2), func(chunk []string) *models.CommitFile {
+ return &models.CommitFile{
+ ChangeStatus: chunk[0],
+ Name: chunk[1],
+ }
+ })
+}
diff --git a/pkg/commands/git_commands/commit_file_loader_test.go b/pkg/commands/git_commands/commit_file_loader_test.go
new file mode 100644
index 000000000..8928f5204
--- /dev/null
+++ b/pkg/commands/git_commands/commit_file_loader_test.go
@@ -0,0 +1,71 @@
+package git_commands
+
+import (
+ "testing"
+
+ "github.com/jesseduffield/lazygit/pkg/commands/models"
+ "github.com/stretchr/testify/assert"
+)
+
+func TestGetCommitFilesFromFilenames(t *testing.T) {
+ tests := []struct {
+ testName string
+ input string
+ output []*models.CommitFile
+ }{
+ {
+ testName: "no files",
+ input: "",
+ output: []*models.CommitFile{},
+ },
+ {
+ testName: "one file",
+ input: "MM\x00Myfile\x00",
+ output: []*models.CommitFile{
+ {
+ Name: "Myfile",
+ ChangeStatus: "MM",
+ },
+ },
+ },
+ {
+ testName: "two files",
+ input: "MM\x00Myfile\x00M \x00MyOtherFile\x00",
+ output: []*models.CommitFile{
+ {
+ Name: "Myfile",
+ ChangeStatus: "MM",
+ },
+ {
+ Name: "MyOtherFile",
+ ChangeStatus: "M ",
+ },
+ },
+ },
+ {
+ testName: "three files",
+ input: "MM\x00Myfile\x00M \x00MyOtherFile\x00 M\x00YetAnother\x00",
+ output: []*models.CommitFile{
+ {
+ Name: "Myfile",
+ ChangeStatus: "MM",
+ },
+ {
+ Name: "MyOtherFile",
+ ChangeStatus: "M ",
+ },
+ {
+ Name: "YetAnother",
+ ChangeStatus: " M",
+ },
+ },
+ },
+ }
+
+ for _, test := range tests {
+ t.Run(test.testName, func(t *testing.T) {
+ result := getCommitFilesFromFilenames(test.input)
+ assert.Equal(t, test.output, result)
+ })
+ }
+}
diff --git a/pkg/commands/git_commands/commit_loader.go b/pkg/commands/git_commands/commit_loader.go
new file mode 100644
index 000000000..21395fad4
--- /dev/null
+++ b/pkg/commands/git_commands/commit_loader.go
@@ -0,0 +1,459 @@
+package git_commands
+
+import (
+ "bytes"
+ "fmt"
+ "os"
+ "path/filepath"
+ "regexp"
+ "strconv"
+ "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/commands/oscommands"
+ "github.com/jesseduffield/lazygit/pkg/commands/types/enums"
+ "github.com/jesseduffield/lazygit/pkg/common"
+ "github.com/jesseduffield/lazygit/pkg/gui/style"
+)
+
+// 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
+
+// CommitLoader returns a list of Commit objects for the current repo
+type CommitLoader struct {
+ *common.Common
+ cmd oscommands.ICmdObjBuilder
+
+ getCurrentBranchName func() (string, string, error)
+ getRebaseMode func() (enums.RebaseMode, error)
+ readFile func(filename string) ([]byte, error)
+ walkFiles func(root string, fn filepath.WalkFunc) error
+ dotGitDir string
+}
+
+// making our dependencies explicit for the sake of easier testing
+func NewCommitLoader(
+ cmn *common.Common,
+ cmd oscommands.ICmdObjBuilder,
+ dotGitDir string,
+ getCurrentBranchName func() (string, string, error),
+ getRebaseMode func() (enums.RebaseMode, error),
+) *CommitLoader {
+ return &CommitLoader{
+ Common: cmn,
+ cmd: cmd,
+ getCurrentBranchName: getCurrentBranchName,
+ getRebaseMode: getRebaseMode,
+ readFile: os.ReadFile,
+ walkFiles: filepath.Walk,
+ dotGitDir: dotGitDir,
+ }
+}
+
+type GetCommitsOptions struct {
+ Limit bool
+ FilterPath string
+ IncludeRebaseCommits bool
+ RefName string // e.g. "HEAD" or "my_branch"
+ // determines if we show the whole git graph i.e. pass the '--all' flag
+ All bool
+}
+
+// GetCommits obtains the commits of the current branch
+func (self *CommitLoader) GetCommits(opts GetCommitsOptions) ([]*models.Commit, error) {
+ commits := []*models.Commit{}
+ var rebasingCommits []*models.Commit
+ rebaseMode, err := self.getRebaseMode()
+ if err != nil {
+ return nil, err
+ }
+
+ if opts.IncludeRebaseCommits && opts.FilterPath == "" {
+ var err error
+ rebasingCommits, err = self.MergeRebasingCommits(commits)
+ if err != nil {
+ return nil, err
+ }
+ commits = append(commits, rebasingCommits...)
+ }
+
+ passedFirstPushedCommit := false
+ firstPushedCommit, err := self.getFirstPushedCommit(opts.RefName)
+ if err != nil {
+ // must have no upstream branch so we'll consider everything as pushed
+ passedFirstPushedCommit = true
+ }
+
+ err = self.getLogCmd(opts).RunAndProcessLines(func(line string) (bool, error) {
+ commit := self.extractCommitFromLine(line)
+ if commit.Sha == firstPushedCommit {
+ passedFirstPushedCommit = true
+ }
+ commit.Status = map[bool]string{true: "unpushed", false: "pushed"}[!passedFirstPushedCommit]
+ commits = append(commits, commit)
+ return false, nil
+ })
+ if err != nil {
+ return nil, err
+ }
+
+ if len(commits) == 0 {
+ return commits, nil
+ }
+
+ if rebaseMode != enums.REBASE_MODE_NONE {
+ currentCommit := commits[len(rebasingCommits)]
+ youAreHere := style.FgYellow.Sprintf("<-- %s ---", self.Tr.YouAreHere)
+ currentCommit.Name = fmt.Sprintf("%s %s", youAreHere, currentCommit.Name)
+ }
+
+ commits, err = self.setCommitMergedStatuses(opts.RefName, commits)
+ if err != nil {
+ return nil, err
+ }
+
+ return commits, nil
+}
+
+func (self *CommitLoader) MergeRebasingCommits(commits []*models.Commit) ([]*models.Commit, error) {
+ // 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.Status != "rebasing" { // removing the existing rebase commits so we can add the refreshed ones
+ result = append(result, commits[i:]...)
+ break
+ }
+ }
+
+ rebaseMode, err := self.getRebaseMode()
+ if err != nil {
+ return nil, err
+ }
+
+ if rebaseMode == enums.REBASE_MODE_NONE {
+ // 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
+func (self *CommitLoader) extractCommitFromLine(line string) *models.Commit {
+ split := strings.SplitN(line, "\x00", 7)
+
+ sha := split[0]
+ unixTimestamp := split[1]
+ authorName := split[2]
+ authorEmail := split[3]
+ extraInfo := strings.TrimSpace(split[4])
+ parentHashes := split[5]
+ message := split[6]
+
+ 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)
+
+ parents := []string{}
+ if len(parentHashes) > 0 {
+ parents = strings.Split(parentHashes, " ")
+ }
+
+ return &models.Commit{
+ Sha: sha,
+ Name: message,
+ Tags: tags,
+ ExtraInfo: extraInfo,
+ UnixTimestamp: int64(unitTimestampInt),
+ AuthorName: authorName,
+ AuthorEmail: authorEmail,
+ Parents: parents,
+ }
+}
+
+func (self *CommitLoader) getHydratedRebasingCommits(rebaseMode enums.RebaseMode) ([]*models.Commit, error) {
+ commits, err := self.getRebasingCommits(rebaseMode)
+ if err != nil {
+ return nil, err
+ }
+
+ if len(commits) == 0 {
+ return nil, nil
+ }
+
+ commitShas := slices.Map(commits, func(commit *models.Commit) string {
+ return commit.Sha
+ })
+
+ // note that we're not filtering these as we do non-rebasing commits just because
+ // I suspect that will cause some damage
+ cmdObj := self.cmd.New(
+ fmt.Sprintf(
+ "git -c log.showSignature=false show %s --no-patch --oneline %s --abbrev=%d",
+ strings.Join(commitShas, " "),
+ prettyFormat,
+ 20,
+ ),
+ ).DontLog()
+
+ hydratedCommits := make([]*models.Commit, 0, len(commits))
+ i := 0
+ err = cmdObj.RunAndProcessLines(func(line string) (bool, error) {
+ commit := self.extractCommitFromLine(line)
+ matchingCommit := commits[i]
+ commit.Action = matchingCommit.Action
+ commit.Status = matchingCommit.Status
+ hydratedCommits = append(hydratedCommits, commit)
+ i++
+ return false, nil
+ })
+ if err != nil {
+ return nil, err
+ }
+ return hydratedCommits, nil
+}
+
+// getRebasingCommits obtains the commits that we're in the process of rebasing
+func (self *CommitLoader) getRebasingCommits(rebaseMode enums.RebaseMode) ([]*models.Commit, error) {
+ switch rebaseMode {
+ case enums.REBASE_MODE_MERGING:
+ return self.getNormalRebasingCommits()
+ case enums.REBASE_MODE_INTERACTIVE:
+ return self.getInteractiveRebasingCommits()
+ default:
+ return nil, nil
+ }
+}
+
+func (self *CommitLoader) getNormalRebasingCommits() ([]*models.Commit, error) {
+ rewrittenCount := 0
+ 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
+ commits := []*models.Commit{}
+ 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
+ }
+ bytesContent, err := self.readFile(path)
+ if err != nil {
+ return err
+ }
+ content := string(bytesContent)
+ commit := self.commitFromPatch(content)
+ 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:
+func (self *CommitLoader) getInteractiveRebasingCommits() ([]*models.Commit, error) {
+ bytesContent, err := self.readFile(filepath.Join(self.dotGitDir, "rebase-merge/git-rebase-todo"))
+ if err != nil {
+ 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
+ }
+
+ commits := []*models.Commit{}
+
+ todos, err := todo.Parse(bytes.NewBuffer(bytesContent))
+ if err != nil {
+ self.Log.Error(fmt.Sprintf("error occurred while parsing git-rebase-todo file: %s", err.Error()))
+ return nil, nil
+ }
+
+ for _, t := range todos {
+ if t.Commit == "" {
+ // Command does not have a commit associated, skip
+ continue
+ }
+ commits = slices.Prepend(commits, &models.Commit{
+ Sha: t.Commit,
+ Name: t.Msg,
+ Status: "rebasing",
+ Action: t.Command.String(),
+ })
+ }
+
+ return commits, nil
+}
+
+// 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
+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: ")
+ return &models.Commit{
+ Sha: sha,
+ Name: name,
+ Status: "rebasing",
+ }
+}
+
+func (self *CommitLoader) setCommitMergedStatuses(refName string, commits []*models.Commit) ([]*models.Commit, error) {
+ ancestor, err := self.getMergeBase(refName)
+ if err != nil {
+ return nil, err
+ }
+ if ancestor == "" {
+ return commits, nil
+ }
+ passedAncestor := false
+ for i, commit := range commits {
+ if strings.HasPrefix(ancestor, commit.Sha) {
+ passedAncestor = true
+ }
+ if commit.Status != "pushed" {
+ continue
+ }
+ if passedAncestor {
+ commits[i].Status = "merged"
+ }
+ }
+ return commits, nil
+}
+
+func (self *CommitLoader) getMergeBase(refName string) (string, error) {
+ currentBranch, _, err := self.getCurrentBranchName()
+ if err != nil {
+ return "", err
+ }
+
+ baseBranch := "master"
+ if strings.HasPrefix(currentBranch, "feature/") {
+ baseBranch = "develop"
+ }
+
+ // swallowing error because it's not a big deal; probably because there are no commits yet
+ output, _ := self.cmd.New(fmt.Sprintf("git merge-base %s %s", self.cmd.Quote(refName), self.cmd.Quote(baseBranch))).DontLog().RunWithOutput()
+ return ignoringWarnings(output), nil
+}
+
+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.
+func (self *CommitLoader) getFirstPushedCommit(refName string) (string, error) {
+ output, err := self.cmd.
+ New(
+ fmt.Sprintf("git merge-base %s %s@{u}", self.cmd.Quote(refName), self.cmd.Quote(refName)),
+ ).
+ DontLog().
+ RunWithOutput()
+ if err != nil {
+ return "", err
+ }
+
+ return ignoringWarnings(output), nil
+}
+
+// getLog gets the git log.
+func (self *CommitLoader) getLogCmd(opts GetCommitsOptions) oscommands.ICmdObj {
+ limitFlag := ""
+ if opts.Limit {
+ limitFlag = " -300"
+ }
+
+ filterFlag := ""
+ if opts.FilterPath != "" {
+ filterFlag = fmt.Sprintf(" --follow -- %s", self.cmd.Quote(opts.FilterPath))
+ }
+
+ config := self.UserConfig.Git.Log
+
+ orderFlag := "--" + config.Order
+ allFlag := ""
+ if opts.All {
+ allFlag = " --all"
+ }
+
+ return self.cmd.New(
+ fmt.Sprintf(
+ "git -c log.showSignature=false log %s %s %s --oneline %s%s --abbrev=%d%s",
+ self.cmd.Quote(opts.RefName),
+ orderFlag,
+ allFlag,
+ prettyFormat,
+ limitFlag,
+ 40,
+ filterFlag,
+ ),
+ ).DontLog()
+}
+
+var prettyFormat = fmt.Sprintf(
+ "--pretty=format:\"%%H%s%%at%s%%aN%s%%ae%s%%d%s%%p%s%%s\"",
+ NULL_CODE,
+ NULL_CODE,
+ NULL_CODE,
+ NULL_CODE,
+ NULL_CODE,
+ NULL_CODE,
+)
+
+const NULL_CODE = "%x00"
diff --git a/pkg/commands/git_commands/commit_loader_test.go b/pkg/commands/git_commands/commit_loader_test.go
new file mode 100644
index 000000000..7d45101ff
--- /dev/null
+++ b/pkg/commands/git_commands/commit_loader_test.go
@@ -0,0 +1,206 @@
+package git_commands
+
+import (
+ "path/filepath"
+ "strings"
+ "testing"
+
+ "github.com/jesseduffield/lazygit/pkg/commands/models"
+ "github.com/jesseduffield/lazygit/pkg/commands/oscommands"
+ "github.com/jesseduffield/lazygit/pkg/commands/types/enums"
+ "github.com/jesseduffield/lazygit/pkg/utils"
+ "github.com/stretchr/testify/assert"
+)
+
+var commitsOutput = strings.Replace(`0eea75e8c631fba6b58135697835d58ba4c18dbc|1640826609|Jesse Duffield|jessedduffield@gmail.com| (HEAD -> better-tests)|b21997d6b4cbdf84b149|better typing for rebase mode
+b21997d6b4cbdf84b149d8e6a2c4d06a8e9ec164|1640824515|Jesse Duffield|jessedduffield@gmail.com| (origin/better-tests)|e94e8fc5b6fab4cb755f|fix logging
+e94e8fc5b6fab4cb755f29f1bdb3ee5e001df35c|1640823749|Jesse Duffield|jessedduffield@gmail.com||d8084cd558925eb7c9c3|refactor
+d8084cd558925eb7c9c38afeed5725c21653ab90|1640821426|Jesse Duffield|jessedduffield@gmail.com||65f910ebd85283b5cce9|WIP
+65f910ebd85283b5cce9bf67d03d3f1a9ea3813a|1640821275|Jesse Duffield|jessedduffield@gmail.com||26c07b1ab33860a1a759|WIP
+26c07b1ab33860a1a7591a0638f9925ccf497ffa|1640750752|Jesse Duffield|jessedduffield@gmail.com||3d4470a6c072208722e5|WIP
+3d4470a6c072208722e5ae9a54bcb9634959a1c5|1640748818|Jesse Duffield|jessedduffield@gmail.com||053a66a7be3da43aacdc|WIP
+053a66a7be3da43aacdc7aa78e1fe757b82c4dd2|1640739815|Jesse Duffield|jessedduffield@gmail.com||985fe482e806b172aea4|refactoring the config struct`, "|", "\x00", -1)
+
+func TestGetCommits(t *testing.T) {
+ type scenario struct {
+ testName string
+ runner *oscommands.FakeCmdObjRunner
+ expectedCommits []*models.Commit
+ expectedError error
+ rebaseMode enums.RebaseMode
+ cur