| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322 |
- package git
- /*
- status.go
- Working tree status: the changed-file list that drives the GitApp sidebar,
- plus the ahead / behind counters shown on the "Push origin" button.
- */
- import (
- "errors"
- "os"
- "path/filepath"
- "sort"
- "strings"
- gogit "github.com/go-git/go-git/v5"
- "github.com/go-git/go-git/v5/plumbing"
- "github.com/go-git/go-git/v5/plumbing/object"
- )
- // maxAheadBehindWalk caps the history walk used for the ahead / behind counters
- // so a huge repository cannot stall a status refresh.
- const maxAheadBehindWalk = 5000
- // Status returns a full snapshot of the repository at realpath.
- func (m *Manager) Status(realpath string) (*RepoStatus, error) {
- repo, tree, err := m.worktree(realpath)
- if err != nil {
- return nil, err
- }
- status := &RepoStatus{
- Changes: []FileChange{},
- Remotes: []RemoteInfo{},
- }
- //Branch + HEAD. An unborn branch (fresh `git init`) is not an error: the
- //user should still see their untracked files so they can make a first commit.
- head, err := repo.Head()
- if err == nil {
- status.Detached = !head.Name().IsBranch()
- if head.Name().IsBranch() {
- status.Branch = head.Name().Short()
- }
- if commit, cerr := repo.CommitObject(head.Hash()); cerr == nil {
- status.Head = commitToInfo(commit)
- }
- } else if errors.Is(err, plumbing.ErrReferenceNotFound) {
- //Unborn branch: read the symbolic HEAD to learn the intended branch name
- if ref, rerr := repo.Reference(plumbing.HEAD, false); rerr == nil {
- status.Branch = ref.Target().Short()
- }
- } else {
- return nil, err
- }
- //Changed files
- worktreeStatus, err := tree.StatusWithOptions(gogit.StatusOptions{
- Strategy: gogit.Preload,
- })
- if err != nil {
- return nil, err
- }
- repoRoot := tree.Filesystem.Root()
- for path, fileStatus := range worktreeStatus {
- if fileStatus.Staging == gogit.Unmodified && fileStatus.Worktree == gogit.Unmodified {
- continue
- }
- status.Changes = append(status.Changes, buildFileChange(repoRoot, path, fileStatus))
- }
- sort.Slice(status.Changes, func(i, j int) bool {
- return status.Changes[i].Path < status.Changes[j].Path
- })
- status.Clean = len(status.Changes) == 0
- for _, change := range status.Changes {
- if change.Conflict {
- status.Conflicted = true
- break
- }
- }
- //Remotes
- if remotes, rerr := m.Remotes(realpath); rerr == nil {
- status.Remotes = remotes
- }
- //Upstream tracking + divergence. A missing upstream is normal (a branch that
- //was never pushed), so it is reported through Upstream being empty rather
- //than as an error.
- if status.Branch != "" {
- upstreamRef, upstreamName := resolveUpstream(repo, status.Branch)
- status.Upstream = upstreamName
- if upstreamRef != nil && status.Head != nil {
- ahead, behind, aerr := countAheadBehind(repo, plumbing.NewHash(status.Head.Hash), upstreamRef.Hash())
- if aerr == nil {
- status.Ahead = ahead
- status.Behind = behind
- }
- } else if upstreamName == "" && status.Head != nil {
- //Never pushed: every local commit counts as ahead so the UI can
- //offer "Publish branch" exactly like GitHub Desktop does.
- status.Ahead = countCommits(repo, plumbing.NewHash(status.Head.Hash))
- }
- }
- return status, nil
- }
- // buildFileChange converts one go-git status entry into the UI shape, adding
- // the on-disk facts (size, binary-ness) the front-end needs to decide whether a
- // diff can be rendered.
- func buildFileChange(repoRoot string, path string, fileStatus *gogit.FileStatus) FileChange {
- change := FileChange{
- Path: filepath.ToSlash(path),
- Staging: statusCodeToString(fileStatus.Staging),
- Worktree: statusCodeToString(fileStatus.Worktree),
- Staged: fileStatus.Staging != gogit.Unmodified && fileStatus.Staging != gogit.Untracked,
- Size: -1,
- Preview: PreviewKind(path),
- }
- if fileStatus.Extra != "" {
- change.OldPath = filepath.ToSlash(fileStatus.Extra)
- }
- //Summarised status: the working tree wins when it has something to say,
- //because that is the change the user is about to stage.
- change.Status = change.Worktree
- if change.Status == "unmodified" {
- change.Status = change.Staging
- }
- if fileStatus.Staging == gogit.UpdatedButUnmerged || fileStatus.Worktree == gogit.UpdatedButUnmerged {
- change.Conflict = true
- change.Status = "conflicted"
- }
- fullPath := filepath.Join(repoRoot, filepath.FromSlash(path))
- if info, err := os.Stat(fullPath); err == nil && !info.IsDir() {
- change.Size = info.Size()
- change.Binary = fileLooksBinary(fullPath)
- }
- return change
- }
- // statusCodeToString maps go-git's single-character status codes onto the
- // vocabulary used across the AGI API and the front-end.
- func statusCodeToString(code gogit.StatusCode) string {
- switch code {
- case gogit.Unmodified:
- return "unmodified"
- case gogit.Untracked:
- return "untracked"
- case gogit.Modified:
- return "modified"
- case gogit.Added:
- return "added"
- case gogit.Deleted:
- return "deleted"
- case gogit.Renamed:
- return "renamed"
- case gogit.Copied:
- return "copied"
- case gogit.UpdatedButUnmerged:
- return "conflicted"
- default:
- return "unknown"
- }
- }
- // resolveUpstream finds the remote tracking reference for a local branch,
- // returning the reference and its display name (e.g. "origin/master"). Both are
- // zero when the branch has no upstream configured.
- func resolveUpstream(repo *gogit.Repository, branch string) (*plumbing.Reference, string) {
- cfg, err := repo.Config()
- if err != nil {
- return nil, ""
- }
- branchCfg, ok := cfg.Branches[branch]
- remoteName := "origin"
- if ok && branchCfg.Remote != "" {
- remoteName = branchCfg.Remote
- }
- trackingName := plumbing.NewRemoteReferenceName(remoteName, branch)
- ref, err := repo.Reference(trackingName, true)
- if err != nil {
- return nil, ""
- }
- return ref, remoteName + "/" + branch
- }
- // countAheadBehind implements `git rev-list --left-right --count local...remote`
- // by diffing the two ancestor sets.
- func countAheadBehind(repo *gogit.Repository, local plumbing.Hash, remote plumbing.Hash) (int, int, error) {
- localSet, err := ancestorSet(repo, local)
- if err != nil {
- return 0, 0, err
- }
- remoteSet, err := ancestorSet(repo, remote)
- if err != nil {
- return 0, 0, err
- }
- ahead := 0
- for hash := range localSet {
- if _, shared := remoteSet[hash]; !shared {
- ahead++
- }
- }
- behind := 0
- for hash := range remoteSet {
- if _, shared := localSet[hash]; !shared {
- behind++
- }
- }
- return ahead, behind, nil
- }
- // ancestorSet collects the hashes reachable from start, bounded by
- // maxAheadBehindWalk.
- func ancestorSet(repo *gogit.Repository, start plumbing.Hash) (map[plumbing.Hash]struct{}, error) {
- seen := map[plumbing.Hash]struct{}{}
- if start.IsZero() {
- return seen, nil
- }
- commit, err := repo.CommitObject(start)
- if err != nil {
- return seen, err
- }
- iter := object.NewCommitPreorderIter(commit, nil, nil)
- defer iter.Close()
- count := 0
- err = iter.ForEach(func(c *object.Commit) error {
- seen[c.Hash] = struct{}{}
- count++
- if count >= maxAheadBehindWalk {
- return storerStop
- }
- return nil
- })
- if err != nil && !errors.Is(err, storerStop) {
- return seen, err
- }
- return seen, nil
- }
- // countCommits returns the number of commits reachable from start, capped by
- // maxAheadBehindWalk.
- func countCommits(repo *gogit.Repository, start plumbing.Hash) int {
- set, err := ancestorSet(repo, start)
- if err != nil {
- return 0
- }
- return len(set)
- }
- // storerStop terminates a commit walk early without being treated as a failure.
- var storerStop = errors.New("walk limit reached")
- // fileLooksBinary applies the heuristic git itself uses: a NUL byte inside the
- // first 8000 bytes means "binary".
- func fileLooksBinary(fullPath string) bool {
- file, err := os.Open(fullPath)
- if err != nil {
- return false
- }
- defer file.Close()
- buffer := make([]byte, 8000)
- read, err := file.Read(buffer)
- if err != nil && read == 0 {
- return false
- }
- return strings.IndexByte(string(buffer[:read]), 0) >= 0
- }
- // commitToInfo converts a go-git commit into the wire type.
- func commitToInfo(commit *object.Commit) *CommitInfo {
- if commit == nil {
- return nil
- }
- parents := []string{}
- for _, parent := range commit.ParentHashes {
- parents = append(parents, parent.String())
- }
- hash := commit.Hash.String()
- shortHash := hash
- if len(shortHash) > 7 {
- shortHash = shortHash[:7]
- }
- message := commit.Message
- subject := message
- if index := strings.IndexByte(subject, '\n'); index >= 0 {
- subject = subject[:index]
- }
- return &CommitInfo{
- Hash: hash,
- ShortHash: shortHash,
- Message: message,
- Subject: strings.TrimSpace(subject),
- AuthorName: commit.Author.Name,
- AuthorEmail: commit.Author.Email,
- Timestamp: commit.Author.When.Unix(),
- Parents: parents,
- }
- }
|