1package commands
2
3import (
4	"fmt"
5	"io/ioutil"
6	"os"
7	"os/exec"
8	"path/filepath"
9	"regexp"
10	"strconv"
11	"strings"
12
13	"github.com/jesseduffield/lazygit/pkg/commands/models"
14	"github.com/jesseduffield/lazygit/pkg/commands/oscommands"
15	"github.com/jesseduffield/lazygit/pkg/gui/style"
16	"github.com/jesseduffield/lazygit/pkg/i18n"
17	"github.com/sirupsen/logrus"
18)
19
20// context:
21// here we get the commits from git log but format them to show whether they're
22// unpushed/pushed/merged into the base branch or not, or if they're yet to
23// be processed as part of a rebase (these won't appear in git log but we
24// grab them from the rebase-related files in the .git directory to show them
25
26// if we find out we need to use one of these functions in the git.go file, we
27// can just pull them out of here and put them there and then call them from in here
28
29const SEPARATION_CHAR = "|"
30
31// CommitListBuilder returns a list of Branch objects for the current repo
32type CommitListBuilder struct {
33	Log        *logrus.Entry
34	GitCommand *GitCommand
35	OSCommand  *oscommands.OSCommand
36	Tr         *i18n.TranslationSet
37}
38
39// NewCommitListBuilder builds a new commit list builder
40func NewCommitListBuilder(
41	log *logrus.Entry,
42	gitCommand *GitCommand,
43	osCommand *oscommands.OSCommand,
44	tr *i18n.TranslationSet,
45) *CommitListBuilder {
46	return &CommitListBuilder{
47		Log:        log,
48		GitCommand: gitCommand,
49		OSCommand:  osCommand,
50		Tr:         tr,
51	}
52}
53
54// extractCommitFromLine takes a line from a git log and extracts the sha, message, date, and tag if present
55// then puts them into a commit object
56// example input:
57// 8ad01fe32fcc20f07bc6693f87aa4977c327f1e1|10 hours ago|Jesse Duffield| (HEAD -> master, tag: v0.15.2)|refresh commits when adding a tag
58func (c *CommitListBuilder) extractCommitFromLine(line string) *models.Commit {
59	split := strings.Split(line, SEPARATION_CHAR)
60
61	sha := split[0]
62	unixTimestamp := split[1]
63	author := split[2]
64	extraInfo := strings.TrimSpace(split[3])
65	parentHashes := split[4]
66
67	message := strings.Join(split[5:], SEPARATION_CHAR)
68	tags := []string{}
69
70	if extraInfo != "" {
71		re := regexp.MustCompile(`tag: ([^,\)]+)`)
72		tagMatch := re.FindStringSubmatch(extraInfo)
73		if len(tagMatch) > 1 {
74			tags = append(tags, tagMatch[1])
75		}
76	}
77
78	unitTimestampInt, _ := strconv.Atoi(unixTimestamp)
79
80	return &models.Commit{
81		Sha:           sha,
82		Name:          message,
83		Tags:          tags,
84		ExtraInfo:     extraInfo,
85		UnixTimestamp: int64(unitTimestampInt),
86		Author:        author,
87		Parents:       strings.Split(parentHashes, " "),
88	}
89}
90
91type GetCommitsOptions struct {
92	Limit                bool
93	FilterPath           string
94	IncludeRebaseCommits bool
95	RefName              string // e.g. "HEAD" or "my_branch"
96	// determines if we show the whole git graph i.e. pass the '--all' flag
97	All bool
98}
99
100func (c *CommitListBuilder) MergeRebasingCommits(commits []*models.Commit) ([]*models.Commit, error) {
101	// chances are we have as many commits as last time so we'll set the capacity to be the old length
102	result := make([]*models.Commit, 0, len(commits))
103	for i, commit := range commits {
104		if commit.Status != "rebasing" { // removing the existing rebase commits so we can add the refreshed ones
105			result = append(result, commits[i:]...)
106			break
107		}
108	}
109
110	rebaseMode, err := c.GitCommand.RebaseMode()
111	if err != nil {
112		return nil, err
113	}
114
115	if rebaseMode == "" {
116		// not in rebase mode so return original commits
117		return result, nil
118	}
119
120	rebasingCommits, err := c.getHydratedRebasingCommits(rebaseMode)
121	if err != nil {
122		return nil, err
123	}
124	if len(rebasingCommits) > 0 {
125		result = append(rebasingCommits, result...)
126	}
127
128	return result, nil
129}
130
131// GetCommits obtains the commits of the current branch
132func (c *CommitListBuilder) GetCommits(opts GetCommitsOptions) ([]*models.Commit, error) {
133	commits := []*models.Commit{}
134	var rebasingCommits []*models.Commit
135	rebaseMode, err := c.GitCommand.RebaseMode()
136	if err != nil {
137		return nil, err
138	}
139
140	if opts.IncludeRebaseCommits && opts.FilterPath == "" {
141		var err error
142		rebasingCommits, err = c.MergeRebasingCommits(commits)
143		if err != nil {
144			return nil, err
145		}
146		commits = append(commits, rebasingCommits...)
147	}
148
149	passedFirstPushedCommit := false
150	firstPushedCommit, err := c.getFirstPushedCommit(opts.RefName)
151	if err != nil {
152		// must have no upstream branch so we'll consider everything as pushed
153		passedFirstPushedCommit = true
154	}
155
156	cmd := c.getLogCmd(opts)
157
158	err = oscommands.RunLineOutputCmd(cmd, func(line string) (bool, error) {
159		if canExtractCommit(line) {
160			commit := c.extractCommitFromLine(line)
161			if commit.Sha == firstPushedCommit {
162				passedFirstPushedCommit = true
163			}
164			commit.Status = map[bool]string{true: "unpushed", false: "pushed"}[!passedFirstPushedCommit]
165			commits = append(commits, commit)
166		}
167		return false, nil
168	})
169	if err != nil {
170		return nil, err
171	}
172
173	if rebaseMode != "" {
174		currentCommit := commits[len(rebasingCommits)]
175		youAreHere := style.FgYellow.Sprintf("<-- %s ---", c.Tr.YouAreHere)
176		currentCommit.Name = fmt.Sprintf("%s %s", youAreHere, currentCommit.Name)
177	}
178
179	commits, err = c.setCommitMergedStatuses(opts.RefName, commits)
180	if err != nil {
181		return nil, err
182	}
183
184	return commits, nil
185}
186
187func (c *CommitListBuilder) getHydratedRebasingCommits(rebaseMode string) ([]*models.Commit, error) {
188	commits, err := c.getRebasingCommits(rebaseMode)
189	if err != nil {
190		return nil, err
191	}
192
193	if len(commits) == 0 {
194		return nil, nil
195	}
196
197	commitShas := make([]string, len(commits))
198	for i, commit := range commits {
199		commitShas[i] = commit.Sha
200	}
201
202	// note that we're not filtering these as we do non-rebasing commits just because
203	// I suspect that will cause some damage
204	cmd := c.OSCommand.ExecutableFromString(
205		fmt.Sprintf(
206			"git show %s --no-patch --oneline %s --abbrev=%d",
207			strings.Join(commitShas, " "),
208			prettyFormat,
209			20,
210		),
211	)
212
213	hydratedCommits := make([]*models.Commit, 0, len(commits))
214	i := 0
215	err = oscommands.RunLineOutputCmd(cmd, func(line string) (bool, error) {
216		if canExtractCommit(line) {
217			commit := c.extractCommitFromLine(line)
218			matchingCommit := commits[i]
219			commit.Action = matchingCommit.Action
220			commit.Status = matchingCommit.Status
221			hydratedCommits = append(hydratedCommits, commit)
222			i++
223		}
224		return false, nil
225	})
226	if err != nil {
227		return nil, err
228	}
229	return hydratedCommits, nil
230}
231
232// getRebasingCommits obtains the commits that we're in the process of rebasing
233func (c *CommitListBuilder) getRebasingCommits(rebaseMode string) ([]*models.Commit, error) {
234	switch rebaseMode {
235	case REBASE_MODE_MERGING:
236		return c.getNormalRebasingCommits()
237	case REBASE_MODE_INTERACTIVE:
238		return c.getInteractiveRebasingCommits()
239	default:
240		return nil, nil
241	}
242}
243
244func (c *CommitListBuilder) getNormalRebasingCommits() ([]*models.Commit, error) {
245	rewrittenCount := 0
246	bytesContent, err := ioutil.ReadFile(filepath.Join(c.GitCommand.DotGitDir, "rebase-apply/rewritten"))
247	if err == nil {
248		content := string(bytesContent)
249		rewrittenCount = len(strings.Split(content, "\n"))
250	}
251
252	// we know we're rebasing, so lets get all the files whose names have numbers
253	commits := []*models.Commit{}
254	err = filepath.Walk(filepath.Join(c.GitCommand.DotGitDir, "rebase-apply"), func(path string, f os.FileInfo, err error) error {
255		if rewrittenCount > 0 {
256			rewrittenCount--
257			return nil
258		}
259		if err != nil {
260			return err
261		}
262		re := regexp.MustCompile(`^\d+$`)
263		if !re.MatchString(f.Name()) {
264			return nil
265		}
266		bytesContent, err := ioutil.ReadFile(path)
267		if err != nil {
268			return err
269		}
270		content := string(bytesContent)
271		commit, err := c.commitFromPatch(content)
272		if err != nil {
273			return err
274		}
275		commits = append([]*models.Commit{commit}, commits...)
276		return nil
277	})
278	if err != nil {
279		return nil, err
280	}
281
282	return commits, nil
283}
284
285// git-rebase-todo example:
286// pick ac446ae94ee560bdb8d1d057278657b251aaef17 ac446ae
287// pick afb893148791a2fbd8091aeb81deba4930c73031 afb8931
288
289// git-rebase-todo.backup example:
290// pick 49cbba374296938ea86bbd4bf4fee2f6ba5cccf6 third commit on master
291// pick ac446ae94ee560bdb8d1d057278657b251aaef17 blah  commit on master
292// pick afb893148791a2fbd8091aeb81deba4930c73031 fourth commit on master
293
294// getInteractiveRebasingCommits takes our git-rebase-todo and our git-rebase-todo.backup files
295// and extracts out the sha and names of commits that we still have to go
296// in the rebase:
297func (c *CommitListBuilder) getInteractiveRebasingCommits() ([]*models.Commit, error) {
298	bytesContent, err := ioutil.ReadFile(filepath.Join(c.GitCommand.DotGitDir, "rebase-merge/git-rebase-todo"))
299	if err != nil {
300		c.Log.Error(fmt.Sprintf("error occurred reading git-rebase-todo: %s", err.Error()))
301		// we assume an error means the file doesn't exist so we just return
302		return nil, nil
303	}
304
305	commits := []*models.Commit{}
306	lines := strings.Split(string(bytesContent), "\n")
307	for _, line := range lines {
308		if line == "" || line == "noop" {
309			return commits, nil
310		}
311		if strings.HasPrefix(line, "#") {
312			continue
313		}
314		splitLine := strings.Split(line, " ")
315		commits = append([]*models.Commit{{
316			Sha:    splitLine[1],
317			Name:   strings.Join(splitLine[2:], " "),
318			Status: "rebasing",
319			Action: splitLine[0],
320		}}, commits...)
321	}
322
323	return commits, nil
324}
325
326// assuming the file starts like this:
327// From e93d4193e6dd45ca9cf3a5a273d7ba6cd8b8fb20 Mon Sep 17 00:00:00 2001
328// From: Lazygit Tester <test@example.com>
329// Date: Wed, 5 Dec 2018 21:03:23 +1100
330// Subject: second commit on master
331func (c *CommitListBuilder) commitFromPatch(content string) (*models.Commit, error) {
332	lines := strings.Split(content, "\n")
333	sha := strings.Split(lines[0], " ")[1]
334	name := strings.TrimPrefix(lines[3], "Subject: ")
335	return &models.Commit{
336		Sha:    sha,
337		Name:   name,
338		Status: "rebasing",
339	}, nil
340}
341
342func (c *CommitListBuilder) setCommitMergedStatuses(refName string, commits []*models.Commit) ([]*models.Commit, error) {
343	ancestor, err := c.getMergeBase(refName)
344	if err != nil {
345		return nil, err
346	}
347	if ancestor == "" {
348		return commits, nil
349	}
350	passedAncestor := false
351	for i, commit := range commits {
352		if strings.HasPrefix(ancestor, commit.Sha) {
353			passedAncestor = true
354		}
355		if commit.Status != "pushed" {
356			continue
357		}
358		if passedAncestor {
359			commits[i].Status = "merged"
360		}
361	}
362	return commits, nil
363}
364
365func (c *CommitListBuilder) getMergeBase(refName string) (string, error) {
366	currentBranch, _, err := c.GitCommand.CurrentBranchName()
367	if err != nil {
368		return "", err
369	}
370
371	baseBranch := "master"
372	if strings.HasPrefix(currentBranch, "feature/") {
373		baseBranch = "develop"
374	}
375
376	// swallowing error because it's not a big deal; probably because there are no commits yet
377	output, _ := c.OSCommand.RunCommandWithOutput("git merge-base %s %s", c.OSCommand.Quote(refName), c.OSCommand.Quote(baseBranch))
378	return ignoringWarnings(output), nil
379}
380
381func ignoringWarnings(commandOutput string) string {
382	trimmedOutput := strings.TrimSpace(commandOutput)
383	split := strings.Split(trimmedOutput, "\n")
384	// need to get last line in case the first line is a warning about how the error is ambiguous.
385	// At some point we should find a way to make it unambiguous
386	lastLine := split[len(split)-1]
387
388	return lastLine
389}
390
391// getFirstPushedCommit returns the first commit SHA which has been pushed to the ref's upstream.
392// all commits above this are deemed unpushed and marked as such.
393func (c *CommitListBuilder) getFirstPushedCommit(refName string) (string, error) {
394	output, err := c.OSCommand.RunCommandWithOutput("git merge-base %s %s@{u}", c.OSCommand.Quote(refName), c.OSCommand.Quote(refName))
395	if err != nil {
396		return "", err
397	}
398
399	return ignoringWarnings(output), nil
400}
401
402// getLog gets the git log.
403func (c *CommitListBuilder) getLogCmd(opts GetCommitsOptions) *exec.Cmd {
404	limitFlag := ""
405	if opts.Limit {
406		limitFlag = "-300"
407	}
408
409	filterFlag := ""
410	if opts.FilterPath != "" {
411		filterFlag = fmt.Sprintf(" --follow -- %s", c.OSCommand.Quote(opts.FilterPath))
412	}
413
414	config := c.GitCommand.Config.GetUserConfig().Git.Log
415
416	orderFlag := "--" + config.Order
417	allFlag := ""
418	if opts.All {
419		allFlag = " --all"
420	}
421
422	return c.OSCommand.ExecutableFromString(
423		fmt.Sprintf(
424			"git log %s %s %s --oneline %s %s --abbrev=%d %s",
425			c.OSCommand.Quote(opts.RefName),
426			orderFlag,
427			allFlag,
428			prettyFormat,
429			limitFlag,
430			20,
431			filterFlag,
432		),
433	)
434}
435
436var prettyFormat = fmt.Sprintf(
437	"--pretty=format:\"%%H%s%%at%s%%aN%s%%d%s%%p%s%%s\"",
438	SEPARATION_CHAR,
439	SEPARATION_CHAR,
440	SEPARATION_CHAR,
441	SEPARATION_CHAR,
442	SEPARATION_CHAR,
443)
444
445func canExtractCommit(line string) bool {
446	return strings.Split(line, " ")[0] != "gpg:"
447}
448