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