|
|
- // Copyright 2017 The Gitea Authors. All rights reserved.
- // Use of this source code is governed by a MIT-style
- // license that can be found in the LICENSE file.
-
- package git
-
- import (
- "bufio"
- "context"
- "fmt"
- "os/exec"
- "path"
- "runtime"
- "strconv"
- "strings"
- "sync"
- "time"
- )
-
- const (
- // parameters for searching for commit infos. If the untargeted search has
- // not found any entries in the past 5 commits, and 12 or fewer entries
- // remain, then we'll just let the targeted-searching threads finish off,
- // and stop the untargeted search to not interfere.
- deferToTargetedSearchColdStreak = 5
- deferToTargetedSearchNumRemainingEntries = 12
- )
-
- // getCommitsInfoState shared state while getting commit info for entries
- type getCommitsInfoState struct {
- lock sync.Mutex
- /* read-only fields, can be read without the mutex */
- // entries and entryPaths are read-only after initialization, so they can
- // safely be read without the mutex
- entries []*TreeEntry
- // set of filepaths to get info for
- entryPaths map[string]struct{}
- treePath string
- headCommit *Commit
-
- /* mutable fields, must hold mutex to read or write */
- // map from filepath to commit
- commits map[string]*Commit
- // set of filepaths that have been or are being searched for in a target search
- targetedPaths map[string]struct{}
- }
-
- func (state *getCommitsInfoState) numRemainingEntries() int {
- state.lock.Lock()
- defer state.lock.Unlock()
- return len(state.entries) - len(state.commits)
- }
-
- // getTargetEntryPath Returns the next path for a targeted-searching thread to
- // search for, or returns the empty string if nothing left to search for
- func (state *getCommitsInfoState) getTargetedEntryPath() string {
- var targetedEntryPath string
- state.lock.Lock()
- defer state.lock.Unlock()
- for _, entry := range state.entries {
- entryPath := path.Join(state.treePath, entry.Name())
- if _, ok := state.commits[entryPath]; ok {
- continue
- } else if _, ok = state.targetedPaths[entryPath]; ok {
- continue
- }
- targetedEntryPath = entryPath
- state.targetedPaths[entryPath] = struct{}{}
- break
- }
- return targetedEntryPath
- }
-
- // repeatedly perform targeted searches for unpopulated entries
- func targetedSearch(state *getCommitsInfoState, done chan error) {
- for {
- entryPath := state.getTargetedEntryPath()
- if len(entryPath) == 0 {
- done <- nil
- return
- }
- command := NewCommand("rev-list", "-1", state.headCommit.ID.String(), "--", entryPath)
- output, err := command.RunInDir(state.headCommit.repo.Path)
- if err != nil {
- done <- err
- return
- }
- id, err := NewIDFromString(strings.TrimSpace(output))
- if err != nil {
- done <- err
- return
- }
- commit, err := state.headCommit.repo.getCommit(id)
- if err != nil {
- done <- err
- return
- }
- state.update(entryPath, commit)
- }
- }
-
- func initGetCommitInfoState(entries Entries, headCommit *Commit, treePath string) *getCommitsInfoState {
- entryPaths := make(map[string]struct{}, len(entries))
- for _, entry := range entries {
- entryPaths[path.Join(treePath, entry.Name())] = struct{}{}
- }
- if treePath = path.Clean(treePath); treePath == "." {
- treePath = ""
- }
- return &getCommitsInfoState{
- entries: entries,
- entryPaths: entryPaths,
- commits: make(map[string]*Commit, len(entries)),
- targetedPaths: make(map[string]struct{}, len(entries)),
- treePath: treePath,
- headCommit: headCommit,
- }
- }
-
- // GetCommitsInfo gets information of all commits that are corresponding to these entries
- func (tes Entries) GetCommitsInfo(commit *Commit, treePath string) ([][]interface{}, error) {
- state := initGetCommitInfoState(tes, commit, treePath)
- if err := getCommitsInfo(state); err != nil {
- return nil, err
- }
- if len(state.commits) < len(state.entryPaths) {
- return nil, fmt.Errorf("could not find commits for all entries")
- }
-
- commitsInfo := make([][]interface{}, len(tes))
- for i, entry := range tes {
- commit, ok := state.commits[path.Join(treePath, entry.Name())]
- if !ok {
- return nil, fmt.Errorf("could not find commit for %s", entry.Name())
- }
- switch entry.Type {
- case ObjectCommit:
- subModuleURL := ""
- if subModule, err := state.headCommit.GetSubModule(entry.Name()); err != nil {
- return nil, err
- } else if subModule != nil {
- subModuleURL = subModule.URL
- }
- subModuleFile := NewSubModuleFile(commit, subModuleURL, entry.ID.String())
- commitsInfo[i] = []interface{}{entry, subModuleFile}
- default:
- commitsInfo[i] = []interface{}{entry, commit}
- }
- }
- return commitsInfo, nil
- }
-
- func (state *getCommitsInfoState) cleanEntryPath(rawEntryPath string) (string, error) {
- if rawEntryPath[0] == '"' {
- var err error
- rawEntryPath, err = strconv.Unquote(rawEntryPath)
- if err != nil {
- return rawEntryPath, err
- }
- }
- var entryNameStartIndex int
- if len(state.treePath) > 0 {
- entryNameStartIndex = len(state.treePath) + 1
- }
-
- if index := strings.IndexByte(rawEntryPath[entryNameStartIndex:], '/'); index >= 0 {
- return rawEntryPath[:entryNameStartIndex+index], nil
- }
- return rawEntryPath, nil
- }
-
- // update report that the given path was last modified by the given commit.
- // Returns whether state.commits was updated
- func (state *getCommitsInfoState) update(entryPath string, commit *Commit) bool {
- if _, ok := state.entryPaths[entryPath]; !ok {
- return false
- }
-
- var updated bool
- state.lock.Lock()
- defer state.lock.Unlock()
- if _, ok := state.commits[entryPath]; !ok {
- state.commits[entryPath] = commit
- updated = true
- }
- return updated
- }
-
- const getCommitsInfoPretty = "--pretty=format:%H %ct %s"
-
- func getCommitsInfo(state *getCommitsInfoState) error {
- ctx, cancel := context.WithTimeout(context.Background(), 1*time.Minute)
- defer cancel()
-
- args := []string{"log", state.headCommit.ID.String(), getCommitsInfoPretty, "--name-status", "-c"}
- if len(state.treePath) > 0 {
- args = append(args, "--", state.treePath)
- }
- cmd := exec.CommandContext(ctx, "git", args...)
- cmd.Dir = state.headCommit.repo.Path
-
- readCloser, err := cmd.StdoutPipe()
- if err != nil {
- return err
- }
-
- if err := cmd.Start(); err != nil {
- return err
- }
- // it's okay to ignore the error returned by cmd.Wait(); we expect the
- // subprocess to sometimes have a non-zero exit status, since we may
- // prematurely close stdout, resulting in a broken pipe.
- defer cmd.Wait()
-
- numThreads := runtime.NumCPU()
- done := make(chan error, numThreads)
- for i := 0; i < numThreads; i++ {
- go targetedSearch(state, done)
- }
-
- scanner := bufio.NewScanner(readCloser)
- err = state.processGitLogOutput(scanner)
-
- // it is important that we close stdout here; if we do not close
- // stdout, the subprocess will keep running, and the deffered call
- // cmd.Wait() may block for a long time.
- if closeErr := readCloser.Close(); closeErr != nil && err == nil {
- err = closeErr
- }
-
- for i := 0; i < numThreads; i++ {
- doneErr := <-done
- if doneErr != nil && err == nil {
- err = doneErr
- }
- }
- return err
- }
-
- func (state *getCommitsInfoState) processGitLogOutput(scanner *bufio.Scanner) error {
- // keep a local cache of seen paths to avoid acquiring a lock for paths
- // we've already seen
- seenPaths := make(map[string]struct{}, len(state.entryPaths))
- // number of consecutive commits without any finds
- coldStreak := 0
- var commit *Commit
- var err error
- for scanner.Scan() {
- line := scanner.Text()
- if len(line) == 0 { // in-between commits
- numRemainingEntries := state.numRemainingEntries()
- if numRemainingEntries == 0 {
- break
- }
- if coldStreak >= deferToTargetedSearchColdStreak &&
- numRemainingEntries <= deferToTargetedSearchNumRemainingEntries {
- // stop this untargeted search, and let the targeted-search threads
- // finish the work
- break
- }
- continue
- }
- if line[0] >= 'A' && line[0] <= 'X' { // a file was changed by the current commit
- // look for the last tab, since for copies (C) and renames (R) two
- // filenames are printed: src, then dest
- tabIndex := strings.LastIndexByte(line, '\t')
- if tabIndex < 1 {
- return fmt.Errorf("misformatted line: %s", line)
- }
- entryPath, err := state.cleanEntryPath(line[tabIndex+1:])
- if err != nil {
- return err
- }
- if _, ok := seenPaths[entryPath]; !ok {
- if state.update(entryPath, commit) {
- coldStreak = 0
- }
- seenPaths[entryPath] = struct{}{}
- }
- continue
- }
-
- // a new commit
- commit, err = parseCommitInfo(line)
- if err != nil {
- return err
- }
- coldStreak++
- }
- return scanner.Err()
- }
-
- // parseCommitInfo parse a commit from a line of `git log` output. Expects the
- // line to be formatted according to getCommitsInfoPretty.
- func parseCommitInfo(line string) (*Commit, error) {
- if len(line) < 43 {
- return nil, fmt.Errorf("invalid git output: %s", line)
- }
- ref, err := NewIDFromString(line[:40])
- if err != nil {
- return nil, err
- }
- spaceIndex := strings.IndexByte(line[41:], ' ')
- if spaceIndex < 0 {
- return nil, fmt.Errorf("invalid git output: %s", line)
- }
- unixSeconds, err := strconv.Atoi(line[41 : 41+spaceIndex])
- if err != nil {
- return nil, err
- }
- message := line[spaceIndex+42:]
- return &Commit{
- ID: ref,
- CommitMessage: message,
- Committer: &Signature{
- When: time.Unix(int64(unixSeconds), 0),
- },
- }, nil
- }
|