worktree.go 6.6 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257
  1. package git
  2. /*
  3. worktree.go
  4. Index and working tree mutations: staging, unstaging, discarding and
  5. committing.
  6. The GitApp UI follows the GitHub Desktop model where the user ticks the files
  7. that belong in the next commit rather than maintaining a long lived staging
  8. area, so Commit accepts an explicit file list and stages it immediately
  9. before writing the commit object.
  10. */
  11. import (
  12. "errors"
  13. "os"
  14. "path/filepath"
  15. "strings"
  16. "time"
  17. gogit "github.com/go-git/go-git/v5"
  18. "github.com/go-git/go-git/v5/plumbing/object"
  19. )
  20. // Add stages the given repo-relative paths. Deleted paths are removed from the
  21. // index, which is what go-git's Add already does for a missing file.
  22. func (m *Manager) Add(realpath string, files []string) error {
  23. _, tree, err := m.worktree(realpath)
  24. if err != nil {
  25. return err
  26. }
  27. for _, file := range files {
  28. cleaned, err := cleanRepoPath(file)
  29. if err != nil {
  30. return err
  31. }
  32. if _, err := tree.Add(cleaned); err != nil {
  33. return errors.New("cannot stage " + cleaned + ": " + err.Error())
  34. }
  35. }
  36. return nil
  37. }
  38. // AddAll stages every change in the working tree, equivalent to `git add -A`.
  39. func (m *Manager) AddAll(realpath string) error {
  40. _, tree, err := m.worktree(realpath)
  41. if err != nil {
  42. return err
  43. }
  44. return tree.AddWithOptions(&gogit.AddOptions{All: true})
  45. }
  46. // Unstage removes the given paths from the index while leaving the working tree
  47. // untouched, equivalent to `git restore --staged`.
  48. func (m *Manager) Unstage(realpath string, files []string) error {
  49. _, tree, err := m.worktree(realpath)
  50. if err != nil {
  51. return err
  52. }
  53. cleanedFiles, err := cleanRepoPaths(files)
  54. if err != nil {
  55. return err
  56. }
  57. if len(cleanedFiles) == 0 {
  58. return errors.New("no files given to unstage")
  59. }
  60. return tree.Restore(&gogit.RestoreOptions{
  61. Staged: true,
  62. Files: cleanedFiles,
  63. })
  64. }
  65. // Discard throws away the working tree changes of the given paths, restoring
  66. // them from the index (`git restore`). Untracked files have nothing to restore
  67. // from, so they are deleted instead — the same thing GitHub Desktop's "Discard
  68. // changes" does.
  69. func (m *Manager) Discard(realpath string, files []string) error {
  70. _, tree, err := m.worktree(realpath)
  71. if err != nil {
  72. return err
  73. }
  74. cleanedFiles, err := cleanRepoPaths(files)
  75. if err != nil {
  76. return err
  77. }
  78. if len(cleanedFiles) == 0 {
  79. return errors.New("no files given to discard")
  80. }
  81. status, err := tree.Status()
  82. if err != nil {
  83. return err
  84. }
  85. restorable := []string{}
  86. repoRoot := tree.Filesystem.Root()
  87. for _, file := range cleanedFiles {
  88. if fileStatus, ok := status[file]; ok && fileStatus.Worktree == gogit.Untracked {
  89. //Never tracked: removing the file is the only possible "discard"
  90. if err := os.Remove(filepath.Join(repoRoot, filepath.FromSlash(file))); err != nil && !os.IsNotExist(err) {
  91. return err
  92. }
  93. continue
  94. }
  95. restorable = append(restorable, file)
  96. }
  97. if len(restorable) == 0 {
  98. return nil
  99. }
  100. return tree.Restore(&gogit.RestoreOptions{
  101. Staged: true,
  102. Worktree: true,
  103. Files: restorable,
  104. })
  105. }
  106. // Commit stages req.Files (or every tracked change when req.All is set) and
  107. // writes a commit. The new commit hash is returned.
  108. func (m *Manager) Commit(realpath string, req *CommitRequest) (string, error) {
  109. if req == nil || strings.TrimSpace(req.Message) == "" {
  110. return "", errors.New("commit message cannot be empty")
  111. }
  112. repo, tree, err := m.worktree(realpath)
  113. if err != nil {
  114. return "", err
  115. }
  116. if req.All {
  117. if err := tree.AddWithOptions(&gogit.AddOptions{All: true}); err != nil {
  118. return "", err
  119. }
  120. } else if len(req.Files) > 0 {
  121. if err := m.Add(realpath, req.Files); err != nil {
  122. return "", err
  123. }
  124. }
  125. signature, err := resolveSignature(repo, req.Name, req.Email)
  126. if err != nil {
  127. return "", err
  128. }
  129. hash, err := tree.Commit(req.Message, &gogit.CommitOptions{
  130. Author: signature,
  131. Committer: signature,
  132. })
  133. if err != nil {
  134. if errors.Is(err, gogit.ErrEmptyCommit) {
  135. return "", errors.New("nothing to commit — select at least one changed file")
  136. }
  137. return "", err
  138. }
  139. return hash.String(), nil
  140. }
  141. // resolveSignature builds the author signature, preferring the values passed by
  142. // the caller and falling back to the repository's own git config. go-git
  143. // refuses to commit without an author, so an explicit error beats its generic
  144. // validation message.
  145. func resolveSignature(repo *gogit.Repository, name string, email string) (*object.Signature, error) {
  146. name = strings.TrimSpace(name)
  147. email = strings.TrimSpace(email)
  148. if name == "" || email == "" {
  149. //Only the repository's own config is consulted. Reading the host's
  150. //global gitconfig would leak the identity of whoever runs the ArozOS
  151. //process into every user's commits.
  152. if cfg, err := repo.Config(); err == nil {
  153. if name == "" {
  154. name = cfg.User.Name
  155. }
  156. if email == "" {
  157. email = cfg.User.Email
  158. }
  159. }
  160. }
  161. if name == "" {
  162. return nil, errors.New("commit author name is required — set it in GitApp settings")
  163. }
  164. if email == "" {
  165. //An address is mandatory in the commit object format; synthesise a
  166. //local one rather than blocking the commit.
  167. email = sanitiseLocalEmail(name)
  168. }
  169. return &object.Signature{
  170. Name: name,
  171. Email: email,
  172. When: time.Now(),
  173. }, nil
  174. }
  175. // sanitiseLocalEmail turns an author name into a usable placeholder address for
  176. // users who never configured one.
  177. func sanitiseLocalEmail(name string) string {
  178. local := strings.Map(func(r rune) rune {
  179. switch {
  180. case r >= 'a' && r <= 'z', r >= '0' && r <= '9', r == '.', r == '-', r == '_':
  181. return r
  182. case r >= 'A' && r <= 'Z':
  183. return r + 32
  184. default:
  185. return -1
  186. }
  187. }, name)
  188. if local == "" {
  189. local = "user"
  190. }
  191. return local + "@arozos.local"
  192. }
  193. // cleanRepoPaths normalises a list of repo-relative paths, dropping empties.
  194. func cleanRepoPaths(files []string) ([]string, error) {
  195. results := []string{}
  196. for _, file := range files {
  197. if strings.TrimSpace(file) == "" {
  198. continue
  199. }
  200. cleaned, err := cleanRepoPath(file)
  201. if err != nil {
  202. return nil, err
  203. }
  204. results = append(results, cleaned)
  205. }
  206. return results, nil
  207. }
  208. // cleanRepoPath validates that a path stays inside the repository. Paths come
  209. // from the browser, so a "../" escape must never reach the filesystem.
  210. func cleanRepoPath(file string) (string, error) {
  211. cleaned := filepath.ToSlash(filepath.Clean(strings.TrimSpace(file)))
  212. cleaned = strings.TrimPrefix(cleaned, "./")
  213. if cleaned == "" || cleaned == "." {
  214. return "", errors.New("empty file path")
  215. }
  216. if strings.HasPrefix(cleaned, "../") || cleaned == ".." || strings.HasPrefix(cleaned, "/") {
  217. return "", errors.New("path escapes the repository: " + file)
  218. }
  219. if filepath.IsAbs(cleaned) {
  220. return "", errors.New("absolute paths are not accepted: " + file)
  221. }
  222. return cleaned, nil
  223. }