status.go 8.5 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322
  1. package git
  2. /*
  3. status.go
  4. Working tree status: the changed-file list that drives the GitApp sidebar,
  5. plus the ahead / behind counters shown on the "Push origin" button.
  6. */
  7. import (
  8. "errors"
  9. "os"
  10. "path/filepath"
  11. "sort"
  12. "strings"
  13. gogit "github.com/go-git/go-git/v5"
  14. "github.com/go-git/go-git/v5/plumbing"
  15. "github.com/go-git/go-git/v5/plumbing/object"
  16. )
  17. // maxAheadBehindWalk caps the history walk used for the ahead / behind counters
  18. // so a huge repository cannot stall a status refresh.
  19. const maxAheadBehindWalk = 5000
  20. // Status returns a full snapshot of the repository at realpath.
  21. func (m *Manager) Status(realpath string) (*RepoStatus, error) {
  22. repo, tree, err := m.worktree(realpath)
  23. if err != nil {
  24. return nil, err
  25. }
  26. status := &RepoStatus{
  27. Changes: []FileChange{},
  28. Remotes: []RemoteInfo{},
  29. }
  30. //Branch + HEAD. An unborn branch (fresh `git init`) is not an error: the
  31. //user should still see their untracked files so they can make a first commit.
  32. head, err := repo.Head()
  33. if err == nil {
  34. status.Detached = !head.Name().IsBranch()
  35. if head.Name().IsBranch() {
  36. status.Branch = head.Name().Short()
  37. }
  38. if commit, cerr := repo.CommitObject(head.Hash()); cerr == nil {
  39. status.Head = commitToInfo(commit)
  40. }
  41. } else if errors.Is(err, plumbing.ErrReferenceNotFound) {
  42. //Unborn branch: read the symbolic HEAD to learn the intended branch name
  43. if ref, rerr := repo.Reference(plumbing.HEAD, false); rerr == nil {
  44. status.Branch = ref.Target().Short()
  45. }
  46. } else {
  47. return nil, err
  48. }
  49. //Changed files
  50. worktreeStatus, err := tree.StatusWithOptions(gogit.StatusOptions{
  51. Strategy: gogit.Preload,
  52. })
  53. if err != nil {
  54. return nil, err
  55. }
  56. repoRoot := tree.Filesystem.Root()
  57. for path, fileStatus := range worktreeStatus {
  58. if fileStatus.Staging == gogit.Unmodified && fileStatus.Worktree == gogit.Unmodified {
  59. continue
  60. }
  61. status.Changes = append(status.Changes, buildFileChange(repoRoot, path, fileStatus))
  62. }
  63. sort.Slice(status.Changes, func(i, j int) bool {
  64. return status.Changes[i].Path < status.Changes[j].Path
  65. })
  66. status.Clean = len(status.Changes) == 0
  67. for _, change := range status.Changes {
  68. if change.Conflict {
  69. status.Conflicted = true
  70. break
  71. }
  72. }
  73. //Remotes
  74. if remotes, rerr := m.Remotes(realpath); rerr == nil {
  75. status.Remotes = remotes
  76. }
  77. //Upstream tracking + divergence. A missing upstream is normal (a branch that
  78. //was never pushed), so it is reported through Upstream being empty rather
  79. //than as an error.
  80. if status.Branch != "" {
  81. upstreamRef, upstreamName := resolveUpstream(repo, status.Branch)
  82. status.Upstream = upstreamName
  83. if upstreamRef != nil && status.Head != nil {
  84. ahead, behind, aerr := countAheadBehind(repo, plumbing.NewHash(status.Head.Hash), upstreamRef.Hash())
  85. if aerr == nil {
  86. status.Ahead = ahead
  87. status.Behind = behind
  88. }
  89. } else if upstreamName == "" && status.Head != nil {
  90. //Never pushed: every local commit counts as ahead so the UI can
  91. //offer "Publish branch" exactly like GitHub Desktop does.
  92. status.Ahead = countCommits(repo, plumbing.NewHash(status.Head.Hash))
  93. }
  94. }
  95. return status, nil
  96. }
  97. // buildFileChange converts one go-git status entry into the UI shape, adding
  98. // the on-disk facts (size, binary-ness) the front-end needs to decide whether a
  99. // diff can be rendered.
  100. func buildFileChange(repoRoot string, path string, fileStatus *gogit.FileStatus) FileChange {
  101. change := FileChange{
  102. Path: filepath.ToSlash(path),
  103. Staging: statusCodeToString(fileStatus.Staging),
  104. Worktree: statusCodeToString(fileStatus.Worktree),
  105. Staged: fileStatus.Staging != gogit.Unmodified && fileStatus.Staging != gogit.Untracked,
  106. Size: -1,
  107. Preview: PreviewKind(path),
  108. }
  109. if fileStatus.Extra != "" {
  110. change.OldPath = filepath.ToSlash(fileStatus.Extra)
  111. }
  112. //Summarised status: the working tree wins when it has something to say,
  113. //because that is the change the user is about to stage.
  114. change.Status = change.Worktree
  115. if change.Status == "unmodified" {
  116. change.Status = change.Staging
  117. }
  118. if fileStatus.Staging == gogit.UpdatedButUnmerged || fileStatus.Worktree == gogit.UpdatedButUnmerged {
  119. change.Conflict = true
  120. change.Status = "conflicted"
  121. }
  122. fullPath := filepath.Join(repoRoot, filepath.FromSlash(path))
  123. if info, err := os.Stat(fullPath); err == nil && !info.IsDir() {
  124. change.Size = info.Size()
  125. change.Binary = fileLooksBinary(fullPath)
  126. }
  127. return change
  128. }
  129. // statusCodeToString maps go-git's single-character status codes onto the
  130. // vocabulary used across the AGI API and the front-end.
  131. func statusCodeToString(code gogit.StatusCode) string {
  132. switch code {
  133. case gogit.Unmodified:
  134. return "unmodified"
  135. case gogit.Untracked:
  136. return "untracked"
  137. case gogit.Modified:
  138. return "modified"
  139. case gogit.Added:
  140. return "added"
  141. case gogit.Deleted:
  142. return "deleted"
  143. case gogit.Renamed:
  144. return "renamed"
  145. case gogit.Copied:
  146. return "copied"
  147. case gogit.UpdatedButUnmerged:
  148. return "conflicted"
  149. default:
  150. return "unknown"
  151. }
  152. }
  153. // resolveUpstream finds the remote tracking reference for a local branch,
  154. // returning the reference and its display name (e.g. "origin/master"). Both are
  155. // zero when the branch has no upstream configured.
  156. func resolveUpstream(repo *gogit.Repository, branch string) (*plumbing.Reference, string) {
  157. cfg, err := repo.Config()
  158. if err != nil {
  159. return nil, ""
  160. }
  161. branchCfg, ok := cfg.Branches[branch]
  162. remoteName := "origin"
  163. if ok && branchCfg.Remote != "" {
  164. remoteName = branchCfg.Remote
  165. }
  166. trackingName := plumbing.NewRemoteReferenceName(remoteName, branch)
  167. ref, err := repo.Reference(trackingName, true)
  168. if err != nil {
  169. return nil, ""
  170. }
  171. return ref, remoteName + "/" + branch
  172. }
  173. // countAheadBehind implements `git rev-list --left-right --count local...remote`
  174. // by diffing the two ancestor sets.
  175. func countAheadBehind(repo *gogit.Repository, local plumbing.Hash, remote plumbing.Hash) (int, int, error) {
  176. localSet, err := ancestorSet(repo, local)
  177. if err != nil {
  178. return 0, 0, err
  179. }
  180. remoteSet, err := ancestorSet(repo, remote)
  181. if err != nil {
  182. return 0, 0, err
  183. }
  184. ahead := 0
  185. for hash := range localSet {
  186. if _, shared := remoteSet[hash]; !shared {
  187. ahead++
  188. }
  189. }
  190. behind := 0
  191. for hash := range remoteSet {
  192. if _, shared := localSet[hash]; !shared {
  193. behind++
  194. }
  195. }
  196. return ahead, behind, nil
  197. }
  198. // ancestorSet collects the hashes reachable from start, bounded by
  199. // maxAheadBehindWalk.
  200. func ancestorSet(repo *gogit.Repository, start plumbing.Hash) (map[plumbing.Hash]struct{}, error) {
  201. seen := map[plumbing.Hash]struct{}{}
  202. if start.IsZero() {
  203. return seen, nil
  204. }
  205. commit, err := repo.CommitObject(start)
  206. if err != nil {
  207. return seen, err
  208. }
  209. iter := object.NewCommitPreorderIter(commit, nil, nil)
  210. defer iter.Close()
  211. count := 0
  212. err = iter.ForEach(func(c *object.Commit) error {
  213. seen[c.Hash] = struct{}{}
  214. count++
  215. if count >= maxAheadBehindWalk {
  216. return storerStop
  217. }
  218. return nil
  219. })
  220. if err != nil && !errors.Is(err, storerStop) {
  221. return seen, err
  222. }
  223. return seen, nil
  224. }
  225. // countCommits returns the number of commits reachable from start, capped by
  226. // maxAheadBehindWalk.
  227. func countCommits(repo *gogit.Repository, start plumbing.Hash) int {
  228. set, err := ancestorSet(repo, start)
  229. if err != nil {
  230. return 0
  231. }
  232. return len(set)
  233. }
  234. // storerStop terminates a commit walk early without being treated as a failure.
  235. var storerStop = errors.New("walk limit reached")
  236. // fileLooksBinary applies the heuristic git itself uses: a NUL byte inside the
  237. // first 8000 bytes means "binary".
  238. func fileLooksBinary(fullPath string) bool {
  239. file, err := os.Open(fullPath)
  240. if err != nil {
  241. return false
  242. }
  243. defer file.Close()
  244. buffer := make([]byte, 8000)
  245. read, err := file.Read(buffer)
  246. if err != nil && read == 0 {
  247. return false
  248. }
  249. return strings.IndexByte(string(buffer[:read]), 0) >= 0
  250. }
  251. // commitToInfo converts a go-git commit into the wire type.
  252. func commitToInfo(commit *object.Commit) *CommitInfo {
  253. if commit == nil {
  254. return nil
  255. }
  256. parents := []string{}
  257. for _, parent := range commit.ParentHashes {
  258. parents = append(parents, parent.String())
  259. }
  260. hash := commit.Hash.String()
  261. shortHash := hash
  262. if len(shortHash) > 7 {
  263. shortHash = shortHash[:7]
  264. }
  265. message := commit.Message
  266. subject := message
  267. if index := strings.IndexByte(subject, '\n'); index >= 0 {
  268. subject = subject[:index]
  269. }
  270. return &CommitInfo{
  271. Hash: hash,
  272. ShortHash: shortHash,
  273. Message: message,
  274. Subject: strings.TrimSpace(subject),
  275. AuthorName: commit.Author.Name,
  276. AuthorEmail: commit.Author.Email,
  277. Timestamp: commit.Author.When.Unix(),
  278. Parents: parents,
  279. }
  280. }