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