github.com/gitbundle/modules@v0.0.0-20231025071548-85b91c5c3b01/git/commit.go (about) 1 // Copyright 2023 The GitBundle Inc. All rights reserved. 2 // Copyright 2017 The Gitea Authors. All rights reserved. 3 // Use of this source code is governed by a MIT-style 4 // license that can be found in the LICENSE file. 5 6 package git 7 8 import ( 9 "bufio" 10 "bytes" 11 "context" 12 "errors" 13 "fmt" 14 "io" 15 "os/exec" 16 "strconv" 17 "strings" 18 19 "github.com/gitbundle/modules/log" 20 "github.com/gitbundle/modules/util" 21 ) 22 23 // Commit represents a git commit. 24 type Commit struct { 25 Branch string // Branch this commit belongs to 26 Tree 27 ID SHA1 // The ID of this commit object 28 Author *Signature 29 Committer *Signature 30 CommitMessage string 31 Signature *CommitGPGSignature 32 33 Parents []SHA1 // SHA1 strings 34 submoduleCache *ObjectCache 35 } 36 37 // CommitGPGSignature represents a git commit signature part. 38 type CommitGPGSignature struct { 39 Signature string 40 Payload string // TODO check if can be reconstruct from the rest of commit information to not have duplicate data 41 } 42 43 // Message returns the commit message. Same as retrieving CommitMessage directly. 44 func (c *Commit) Message() string { 45 return c.CommitMessage 46 } 47 48 // Summary returns first line of commit message. 49 func (c *Commit) Summary() string { 50 return strings.Split(strings.TrimSpace(c.CommitMessage), "\n")[0] 51 } 52 53 // ParentID returns oid of n-th parent (0-based index). 54 // It returns nil if no such parent exists. 55 func (c *Commit) ParentID(n int) (SHA1, error) { 56 if n >= len(c.Parents) { 57 return SHA1{}, ErrNotExist{"", ""} 58 } 59 return c.Parents[n], nil 60 } 61 62 // Parent returns n-th parent (0-based index) of the commit. 63 func (c *Commit) Parent(n int) (*Commit, error) { 64 id, err := c.ParentID(n) 65 if err != nil { 66 return nil, err 67 } 68 parent, err := c.repo.getCommit(id) 69 if err != nil { 70 return nil, err 71 } 72 return parent, nil 73 } 74 75 // ParentCount returns number of parents of the commit. 76 // 0 if this is the root commit, otherwise 1,2, etc. 77 func (c *Commit) ParentCount() int { 78 return len(c.Parents) 79 } 80 81 // GetCommitByPath return the commit of relative path object. 82 func (c *Commit) GetCommitByPath(relpath string) (*Commit, error) { 83 return c.repo.getCommitByPathWithID(c.ID, relpath) 84 } 85 86 // AddChanges marks local changes to be ready for commit. 87 func AddChanges(repoPath string, all bool, files ...string) error { 88 return AddChangesWithArgs(repoPath, globalCommandArgs, all, files...) 89 } 90 91 // AddChangesWithArgs marks local changes to be ready for commit. 92 func AddChangesWithArgs(repoPath string, globalArgs []string, all bool, files ...string) error { 93 cmd := NewCommandNoGlobals(append(globalArgs, "add")...) 94 if all { 95 cmd.AddArguments("--all") 96 } 97 cmd.AddArguments("--") 98 _, _, err := cmd.AddArguments(files...).RunStdString(&RunOpts{Dir: repoPath}) 99 return err 100 } 101 102 // CommitChangesOptions the options when a commit created 103 type CommitChangesOptions struct { 104 Committer *Signature 105 Author *Signature 106 Message string 107 } 108 109 // CommitChanges commits local changes with given committer, author and message. 110 // If author is nil, it will be the same as committer. 111 func CommitChanges(repoPath string, opts CommitChangesOptions) error { 112 cargs := make([]string, len(globalCommandArgs)) 113 copy(cargs, globalCommandArgs) 114 return CommitChangesWithArgs(repoPath, cargs, opts) 115 } 116 117 // CommitChangesWithArgs commits local changes with given committer, author and message. 118 // If author is nil, it will be the same as committer. 119 func CommitChangesWithArgs(repoPath string, args []string, opts CommitChangesOptions) error { 120 cmd := NewCommandNoGlobals(args...) 121 if opts.Committer != nil { 122 cmd.AddArguments("-c", "user.name="+opts.Committer.Name, "-c", "user.email="+opts.Committer.Email) 123 } 124 cmd.AddArguments("commit") 125 126 if opts.Author == nil { 127 opts.Author = opts.Committer 128 } 129 if opts.Author != nil { 130 cmd.AddArguments(fmt.Sprintf("--author='%s <%s>'", opts.Author.Name, opts.Author.Email)) 131 } 132 cmd.AddArguments("-m", opts.Message) 133 134 _, _, err := cmd.RunStdString(&RunOpts{Dir: repoPath}) 135 // No stderr but exit status 1 means nothing to commit. 136 if err != nil && err.Error() == "exit status 1" { 137 return nil 138 } 139 return err 140 } 141 142 // AllCommitsCount returns count of all commits in repository 143 func AllCommitsCount(ctx context.Context, repoPath string, hidePRRefs bool, files ...string) (int64, error) { 144 args := []string{"--all", "--count"} 145 if hidePRRefs { 146 args = append([]string{"--exclude=" + PullPrefix + "*"}, args...) 147 } 148 cmd := NewCommand(ctx, "rev-list") 149 cmd.AddArguments(args...) 150 if len(files) > 0 { 151 cmd.AddArguments("--") 152 cmd.AddArguments(files...) 153 } 154 155 stdout, _, err := cmd.RunStdString(&RunOpts{Dir: repoPath}) 156 if err != nil { 157 return 0, err 158 } 159 160 return strconv.ParseInt(strings.TrimSpace(stdout), 10, 64) 161 } 162 163 // CommitsCountFiles returns number of total commits of until given revision. 164 func CommitsCountFiles(ctx context.Context, repoPath string, revision, relpath []string) (int64, error) { 165 cmd := NewCommand(ctx, "rev-list", "--count") 166 cmd.AddArguments(revision...) 167 if len(relpath) > 0 { 168 cmd.AddArguments("--") 169 cmd.AddArguments(relpath...) 170 } 171 172 stdout, _, err := cmd.RunStdString(&RunOpts{Dir: repoPath}) 173 if err != nil { 174 return 0, err 175 } 176 177 return strconv.ParseInt(strings.TrimSpace(stdout), 10, 64) 178 } 179 180 // CommitsCount returns number of total commits of until given revision. 181 func CommitsCount(ctx context.Context, repoPath string, revision ...string) (int64, error) { 182 return CommitsCountFiles(ctx, repoPath, revision, []string{}) 183 } 184 185 // CommitsCount returns number of total commits of until current revision. 186 func (c *Commit) CommitsCount() (int64, error) { 187 return CommitsCount(c.repo.Ctx, c.repo.Path, c.ID.String()) 188 } 189 190 // CommitsByRange returns the specific page commits before current revision, every page's number default by CommitsRangeSize 191 func (c *Commit) CommitsByRange(page, pageSize int) ([]*Commit, error) { 192 return c.repo.commitsByRange(c.ID, page, pageSize) 193 } 194 195 // CommitsBefore returns all the commits before current revision 196 func (c *Commit) CommitsBefore() ([]*Commit, error) { 197 return c.repo.getCommitsBefore(c.ID) 198 } 199 200 // HasPreviousCommit returns true if a given commitHash is contained in commit's parents 201 func (c *Commit) HasPreviousCommit(commitHash SHA1) (bool, error) { 202 this := c.ID.String() 203 that := commitHash.String() 204 205 if this == that { 206 return false, nil 207 } 208 209 _, _, err := NewCommand(c.repo.Ctx, "merge-base", "--is-ancestor", that, this).RunStdString(&RunOpts{Dir: c.repo.Path}) 210 if err == nil { 211 return true, nil 212 } 213 var exitError *exec.ExitError 214 if errors.As(err, &exitError) { 215 if exitError.ProcessState.ExitCode() == 1 && len(exitError.Stderr) == 0 { 216 return false, nil 217 } 218 } 219 return false, err 220 } 221 222 // CommitsBeforeLimit returns num commits before current revision 223 func (c *Commit) CommitsBeforeLimit(num int) ([]*Commit, error) { 224 return c.repo.getCommitsBeforeLimit(c.ID, num) 225 } 226 227 // CommitsBeforeUntil returns the commits between commitID to current revision 228 func (c *Commit) CommitsBeforeUntil(commitID string) ([]*Commit, error) { 229 endCommit, err := c.repo.GetCommit(commitID) 230 if err != nil { 231 return nil, err 232 } 233 return c.repo.CommitsBetween(c, endCommit) 234 } 235 236 // SearchCommitsOptions specify the parameters for SearchCommits 237 type SearchCommitsOptions struct { 238 Keywords []string 239 Authors, Committers []string 240 After, Before string 241 All bool 242 } 243 244 // NewSearchCommitsOptions construct a SearchCommitsOption from a space-delimited search string 245 func NewSearchCommitsOptions(searchString string, forAllRefs bool) SearchCommitsOptions { 246 var keywords, authors, committers []string 247 var after, before string 248 249 fields := strings.Fields(searchString) 250 for _, k := range fields { 251 switch { 252 case strings.HasPrefix(k, "author:"): 253 authors = append(authors, strings.TrimPrefix(k, "author:")) 254 case strings.HasPrefix(k, "committer:"): 255 committers = append(committers, strings.TrimPrefix(k, "committer:")) 256 case strings.HasPrefix(k, "after:"): 257 after = strings.TrimPrefix(k, "after:") 258 case strings.HasPrefix(k, "before:"): 259 before = strings.TrimPrefix(k, "before:") 260 default: 261 keywords = append(keywords, k) 262 } 263 } 264 265 return SearchCommitsOptions{ 266 Keywords: keywords, 267 Authors: authors, 268 Committers: committers, 269 After: after, 270 Before: before, 271 All: forAllRefs, 272 } 273 } 274 275 // SearchCommits returns the commits match the keyword before current revision 276 func (c *Commit) SearchCommits(opts SearchCommitsOptions) ([]*Commit, error) { 277 return c.repo.searchCommits(c.ID, opts) 278 } 279 280 // GetFilesChangedSinceCommit get all changed file names between pastCommit to current revision 281 func (c *Commit) GetFilesChangedSinceCommit(pastCommit string) ([]string, error) { 282 return c.repo.getFilesChanged(pastCommit, c.ID.String()) 283 } 284 285 // FileChangedSinceCommit Returns true if the file given has changed since the the past commit 286 // YOU MUST ENSURE THAT pastCommit is a valid commit ID. 287 func (c *Commit) FileChangedSinceCommit(filename, pastCommit string) (bool, error) { 288 return c.repo.FileChangedBetweenCommits(filename, pastCommit, c.ID.String()) 289 } 290 291 // HasFile returns true if the file given exists on this commit 292 // This does only mean it's there - it does not mean the file was changed during the commit. 293 func (c *Commit) HasFile(filename string) (bool, error) { 294 _, err := c.GetBlobByPath(filename) 295 if err != nil { 296 return false, err 297 } 298 return true, nil 299 } 300 301 // GetFileContent reads a file content as a string or returns false if this was not possible 302 func (c *Commit) GetFileContent(filename string, limit int) (string, error) { 303 entry, err := c.GetTreeEntryByPath(filename) 304 if err != nil { 305 return "", err 306 } 307 308 r, err := entry.Blob().DataAsync() 309 if err != nil { 310 return "", err 311 } 312 defer r.Close() 313 314 if limit > 0 { 315 bs := make([]byte, limit) 316 n, err := util.ReadAtMost(r, bs) 317 if err != nil { 318 return "", err 319 } 320 return string(bs[:n]), nil 321 } 322 323 bytes, err := io.ReadAll(r) 324 if err != nil { 325 return "", err 326 } 327 return string(bytes), nil 328 } 329 330 // GetSubModules get all the sub modules of current revision git tree 331 func (c *Commit) GetSubModules() (*ObjectCache, error) { 332 if c.submoduleCache != nil { 333 return c.submoduleCache, nil 334 } 335 336 entry, err := c.GetTreeEntryByPath(".gitmodules") 337 if err != nil { 338 if _, ok := err.(ErrNotExist); ok { 339 return nil, nil 340 } 341 return nil, err 342 } 343 344 rd, err := entry.Blob().DataAsync() 345 if err != nil { 346 return nil, err 347 } 348 349 defer rd.Close() 350 scanner := bufio.NewScanner(rd) 351 c.submoduleCache = newObjectCache() 352 var ismodule bool 353 var path string 354 for scanner.Scan() { 355 if strings.HasPrefix(scanner.Text(), "[submodule") { 356 ismodule = true 357 continue 358 } 359 if ismodule { 360 fields := strings.Split(scanner.Text(), "=") 361 k := strings.TrimSpace(fields[0]) 362 if k == "path" { 363 path = strings.TrimSpace(fields[1]) 364 } else if k == "url" { 365 c.submoduleCache.Set(path, &SubModule{path, strings.TrimSpace(fields[1])}) 366 ismodule = false 367 } 368 } 369 } 370 371 return c.submoduleCache, nil 372 } 373 374 // GetSubModule get the sub module according entryname 375 func (c *Commit) GetSubModule(entryname string) (*SubModule, error) { 376 modules, err := c.GetSubModules() 377 if err != nil { 378 return nil, err 379 } 380 381 if modules != nil { 382 module, has := modules.Get(entryname) 383 if has { 384 return module.(*SubModule), nil 385 } 386 } 387 return nil, nil 388 } 389 390 // GetBranchName gets the closest branch name (as returned by 'git name-rev --name-only') 391 func (c *Commit) GetBranchName() (string, error) { 392 args := []string{ 393 "name-rev", 394 } 395 if CheckGitVersionAtLeast("2.13.0") == nil { 396 args = append(args, "--exclude", "refs/tags/*") 397 } 398 args = append(args, "--name-only", "--no-undefined", c.ID.String()) 399 400 data, _, err := NewCommand(c.repo.Ctx, args...).RunStdString(&RunOpts{Dir: c.repo.Path}) 401 if err != nil { 402 // handle special case where git can not describe commit 403 if strings.Contains(err.Error(), "cannot describe") { 404 return "", nil 405 } 406 407 return "", err 408 } 409 410 // name-rev commitID output will be "master" or "master~12" 411 return strings.SplitN(strings.TrimSpace(data), "~", 2)[0], nil 412 } 413 414 // LoadBranchName load branch name for commit 415 func (c *Commit) LoadBranchName() (err error) { 416 if len(c.Branch) != 0 { 417 return 418 } 419 420 c.Branch, err = c.GetBranchName() 421 return 422 } 423 424 // GetTagName gets the current tag name for given commit 425 func (c *Commit) GetTagName() (string, error) { 426 data, _, err := NewCommand(c.repo.Ctx, "describe", "--exact-match", "--tags", "--always", c.ID.String()).RunStdString(&RunOpts{Dir: c.repo.Path}) 427 if err != nil { 428 // handle special case where there is no tag for this commit 429 if strings.Contains(err.Error(), "no tag exactly matches") { 430 return "", nil 431 } 432 433 return "", err 434 } 435 436 return strings.TrimSpace(data), nil 437 } 438 439 // CommitFileStatus represents status of files in a commit. 440 type CommitFileStatus struct { 441 Added []string 442 Removed []string 443 Modified []string 444 } 445 446 // NewCommitFileStatus creates a CommitFileStatus 447 func NewCommitFileStatus() *CommitFileStatus { 448 return &CommitFileStatus{ 449 []string{}, []string{}, []string{}, 450 } 451 } 452 453 func parseCommitFileStatus(fileStatus *CommitFileStatus, stdout io.Reader) { 454 rd := bufio.NewReader(stdout) 455 peek, err := rd.Peek(1) 456 if err != nil { 457 if err != io.EOF { 458 log.Error("Unexpected error whilst reading from git log --name-status. Error: %v", err) 459 } 460 return 461 } 462 if peek[0] == '\n' || peek[0] == '\x00' { 463 _, _ = rd.Discard(1) 464 } 465 for { 466 modifier, err := rd.ReadSlice('\x00') 467 if err != nil { 468 if err != io.EOF { 469 log.Error("Unexpected error whilst reading from git log --name-status. Error: %v", err) 470 } 471 return 472 } 473 file, err := rd.ReadString('\x00') 474 if err != nil { 475 if err != io.EOF { 476 log.Error("Unexpected error whilst reading from git log --name-status. Error: %v", err) 477 } 478 return 479 } 480 file = file[:len(file)-1] 481 switch modifier[0] { 482 case 'A': 483 fileStatus.Added = append(fileStatus.Added, file) 484 case 'D': 485 fileStatus.Removed = append(fileStatus.Removed, file) 486 case 'M': 487 fileStatus.Modified = append(fileStatus.Modified, file) 488 } 489 } 490 } 491 492 // GetCommitFileStatus returns file status of commit in given repository. 493 func GetCommitFileStatus(ctx context.Context, repoPath, commitID string) (*CommitFileStatus, error) { 494 stdout, w := io.Pipe() 495 done := make(chan struct{}) 496 fileStatus := NewCommitFileStatus() 497 go func() { 498 parseCommitFileStatus(fileStatus, stdout) 499 close(done) 500 }() 501 502 stderr := new(bytes.Buffer) 503 args := []string{"log", "--name-status", "-c", "--pretty=format:", "--parents", "--no-renames", "-z", "-1", commitID} 504 505 err := NewCommand(ctx, args...).Run(&RunOpts{ 506 Dir: repoPath, 507 Stdout: w, 508 Stderr: stderr, 509 }) 510 w.Close() // Close writer to exit parsing goroutine 511 if err != nil { 512 return nil, ConcatenateError(err, stderr.String()) 513 } 514 515 <-done 516 return fileStatus, nil 517 } 518 519 // GetFullCommitID returns full length (40) of commit ID by given short SHA in a repository. 520 func GetFullCommitID(ctx context.Context, repoPath, shortID string) (string, error) { 521 commitID, _, err := NewCommand(ctx, "rev-parse", shortID).RunStdString(&RunOpts{Dir: repoPath}) 522 if err != nil { 523 if strings.Contains(err.Error(), "exit status 128") { 524 return "", ErrNotExist{shortID, ""} 525 } 526 return "", err 527 } 528 return strings.TrimSpace(commitID), nil 529 } 530 531 // GetRepositoryDefaultPublicGPGKey returns the default public key for this commit 532 func (c *Commit) GetRepositoryDefaultPublicGPGKey(forceUpdate bool) (*GPGSettings, error) { 533 if c.repo == nil { 534 return nil, nil 535 } 536 return c.repo.GetDefaultPublicGPGKey(forceUpdate) 537 }