history_actions.go 16 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585
  1. package git
  2. /*
  3. history_actions.go
  4. The operations behind the History tab's commit context menu, matching what
  5. GitHub Desktop offers: checkout, reset, branch/tag creation, revert,
  6. cherry-pick and message amend.
  7. Revert and cherry-pick deserve a note. go-git has no merge engine, so instead
  8. of attempting a three-way merge that could silently produce a wrong result,
  9. this package uses a "clean or refuse" strategy: a change is applied only when
  10. the files it touches still hold the exact content it expects, and otherwise
  11. the whole operation is refused with a readable message. That safely covers
  12. the common cases (reverting the latest commit, or an older commit whose files
  13. were not touched since) and never fabricates a bad tree.
  14. */
  15. import (
  16. "bytes"
  17. "errors"
  18. "fmt"
  19. "io"
  20. "os"
  21. "path/filepath"
  22. "strings"
  23. gogit "github.com/go-git/go-git/v5"
  24. "github.com/go-git/go-git/v5/plumbing"
  25. "github.com/go-git/go-git/v5/plumbing/object"
  26. )
  27. // maxApplyBytes caps the size of a single blob revert/cherry-pick will rewrite,
  28. // so one enormous asset cannot exhaust memory.
  29. const maxApplyBytes = 50 * 1024 * 1024
  30. // CheckoutCommit checks a commit out in detached HEAD state. The working tree
  31. // must be clean, so no uncommitted work is silently discarded.
  32. func (m *Manager) CheckoutCommit(realpath string, hash string) error {
  33. repo, tree, err := m.worktree(realpath)
  34. if err != nil {
  35. return err
  36. }
  37. commit, err := resolveCommit(repo, hash)
  38. if err != nil {
  39. return err
  40. }
  41. if err := requireCleanWorktree(tree); err != nil {
  42. return err
  43. }
  44. return tree.Checkout(&gogit.CheckoutOptions{Hash: commit.Hash})
  45. }
  46. // ResetToCommit moves the current branch to a commit. mode is "soft" (move HEAD
  47. // only), "mixed" (also reset the index) or "hard" (also reset the working tree).
  48. func (m *Manager) ResetToCommit(realpath string, hash string, mode string) error {
  49. repo, tree, err := m.worktree(realpath)
  50. if err != nil {
  51. return err
  52. }
  53. commit, err := resolveCommit(repo, hash)
  54. if err != nil {
  55. return err
  56. }
  57. resetMode, err := parseResetMode(mode)
  58. if err != nil {
  59. return err
  60. }
  61. //A hard reset throws away working tree changes; guard so it cannot happen
  62. //by surprise when the user has uncommitted work they forgot about.
  63. if resetMode == gogit.HardReset {
  64. if err := requireCleanWorktree(tree); err != nil {
  65. return errors.New("hard reset would discard your uncommitted changes — commit or discard them first")
  66. }
  67. }
  68. return tree.Reset(&gogit.ResetOptions{
  69. Commit: commit.Hash,
  70. Mode: resetMode,
  71. })
  72. }
  73. // CreateBranchAt creates a branch pointing at a commit and checks it out.
  74. func (m *Manager) CreateBranchAt(realpath string, branch string, hash string) error {
  75. branch = strings.TrimSpace(branch)
  76. if err := validateBranchName(branch); err != nil {
  77. return err
  78. }
  79. repo, tree, err := m.worktree(realpath)
  80. if err != nil {
  81. return err
  82. }
  83. commit, err := resolveCommit(repo, hash)
  84. if err != nil {
  85. return err
  86. }
  87. referenceName := plumbing.NewBranchReferenceName(branch)
  88. if _, rerr := repo.Reference(referenceName, false); rerr == nil {
  89. return errors.New("a branch named " + branch + " already exists")
  90. }
  91. if err := repo.Storer.SetReference(plumbing.NewHashReference(referenceName, commit.Hash)); err != nil {
  92. return err
  93. }
  94. return tree.Checkout(&gogit.CheckoutOptions{
  95. Branch: referenceName,
  96. Keep: true,
  97. })
  98. }
  99. // CreateTag creates a tag at a commit. A non-empty message produces an
  100. // annotated tag (signed by name / email, falling back to the repository's git
  101. // config), otherwise a lightweight one.
  102. func (m *Manager) CreateTag(realpath string, tag string, hash string, message string, name string, email string) error {
  103. tag = strings.TrimSpace(tag)
  104. if err := validateTagName(tag); err != nil {
  105. return err
  106. }
  107. repo, err := m.open(realpath)
  108. if err != nil {
  109. return err
  110. }
  111. commit, err := resolveCommit(repo, hash)
  112. if err != nil {
  113. return err
  114. }
  115. var options *gogit.CreateTagOptions
  116. if strings.TrimSpace(message) != "" {
  117. signature, serr := resolveSignature(repo, name, email)
  118. if serr != nil {
  119. return serr
  120. }
  121. options = &gogit.CreateTagOptions{
  122. Tagger: signature,
  123. Message: message,
  124. }
  125. }
  126. _, err = repo.CreateTag(tag, commit.Hash, options)
  127. if errors.Is(err, gogit.ErrTagExists) {
  128. return errors.New("a tag named " + tag + " already exists")
  129. }
  130. return err
  131. }
  132. // TagsForCommit returns the names of tags pointing at a commit, resolving
  133. // annotated tag objects to the commit they wrap.
  134. func (m *Manager) TagsForCommit(realpath string, hash string) ([]string, error) {
  135. repo, err := m.open(realpath)
  136. if err != nil {
  137. return nil, err
  138. }
  139. commit, err := resolveCommit(repo, hash)
  140. if err != nil {
  141. return nil, err
  142. }
  143. tagMap, err := buildTagMap(repo)
  144. if err != nil {
  145. return nil, err
  146. }
  147. return tagMap[commit.Hash], nil
  148. }
  149. // RevertCommit creates a new commit that undoes a commit's changes. It is the
  150. // reverse transition: from the commit's tree back to its parent's tree.
  151. func (m *Manager) RevertCommit(realpath string, hash string, req *CommitRequest) (string, error) {
  152. repo, tree, err := m.worktree(realpath)
  153. if err != nil {
  154. return "", err
  155. }
  156. commit, err := resolveCommit(repo, hash)
  157. if err != nil {
  158. return "", err
  159. }
  160. if err := requireCleanWorktree(tree); err != nil {
  161. return "", errors.New("commit or discard your changes before reverting")
  162. }
  163. commitTree, err := commit.Tree()
  164. if err != nil {
  165. return "", err
  166. }
  167. //Reverting the changes means going from the commit back to its parent. The
  168. //root commit has no parent, so its parent state is the empty tree.
  169. var parentTree *object.Tree
  170. if commit.NumParents() > 0 {
  171. parent, perr := commit.Parent(0)
  172. if perr != nil {
  173. return "", perr
  174. }
  175. if parentTree, err = parent.Tree(); err != nil {
  176. return "", err
  177. }
  178. }
  179. if err := m.applyTransition(tree, commitTree, parentTree); err != nil {
  180. return "", err
  181. }
  182. message := req.Message
  183. if strings.TrimSpace(message) == "" {
  184. message = "Revert \"" + commitSubject(commit) + "\"\n\nThis reverts commit " + commit.Hash.String() + "."
  185. }
  186. return m.commitStaged(repo, tree, message, req, nil)
  187. }
  188. // CherryPickCommit applies a commit's changes onto the current HEAD. It is the
  189. // forward transition: from the commit's parent tree to the commit's tree.
  190. func (m *Manager) CherryPickCommit(realpath string, hash string, req *CommitRequest) (string, error) {
  191. repo, tree, err := m.worktree(realpath)
  192. if err != nil {
  193. return "", err
  194. }
  195. commit, err := resolveCommit(repo, hash)
  196. if err != nil {
  197. return "", err
  198. }
  199. if err := requireCleanWorktree(tree); err != nil {
  200. return "", errors.New("commit or discard your changes before cherry-picking")
  201. }
  202. commitTree, err := commit.Tree()
  203. if err != nil {
  204. return "", err
  205. }
  206. var parentTree *object.Tree
  207. if commit.NumParents() > 0 {
  208. parent, perr := commit.Parent(0)
  209. if perr != nil {
  210. return "", perr
  211. }
  212. if parentTree, err = parent.Tree(); err != nil {
  213. return "", err
  214. }
  215. }
  216. if err := m.applyTransition(tree, parentTree, commitTree); err != nil {
  217. return "", err
  218. }
  219. message := req.Message
  220. if strings.TrimSpace(message) == "" {
  221. message = commit.Message
  222. }
  223. //A cherry-pick keeps the original author but records the current user as the
  224. //committer, exactly like git does.
  225. originalAuthor := commit.Author
  226. return m.commitStaged(repo, tree, message, req, &originalAuthor)
  227. }
  228. // AmendCommitMessage rewrites the message of the HEAD commit, keeping its tree,
  229. // parents and author. Only a branch tip can be amended.
  230. func (m *Manager) AmendCommitMessage(realpath string, message string, req *CommitRequest) (string, error) {
  231. if strings.TrimSpace(message) == "" {
  232. return "", errors.New("commit message cannot be empty")
  233. }
  234. repo, err := m.open(realpath)
  235. if err != nil {
  236. return "", err
  237. }
  238. head, err := repo.Head()
  239. if err != nil {
  240. if errors.Is(err, plumbing.ErrReferenceNotFound) {
  241. return "", ErrUnbornBranch
  242. }
  243. return "", err
  244. }
  245. if !head.Name().IsBranch() {
  246. return "", errors.New("HEAD is detached — check out a branch before amending")
  247. }
  248. headCommit, err := repo.CommitObject(head.Hash())
  249. if err != nil {
  250. return "", err
  251. }
  252. committer, err := resolveSignature(repo, req.Name, req.Email)
  253. if err != nil {
  254. return "", err
  255. }
  256. amended := &object.Commit{
  257. Author: headCommit.Author,
  258. Committer: *committer,
  259. Message: message,
  260. TreeHash: headCommit.TreeHash,
  261. ParentHashes: headCommit.ParentHashes,
  262. }
  263. encoded := repo.Storer.NewEncodedObject()
  264. if err := amended.Encode(encoded); err != nil {
  265. return "", err
  266. }
  267. newHash, err := repo.Storer.SetEncodedObject(encoded)
  268. if err != nil {
  269. return "", err
  270. }
  271. if err := repo.Storer.SetReference(plumbing.NewHashReference(head.Name(), newHash)); err != nil {
  272. return "", err
  273. }
  274. return newHash.String(), nil
  275. }
  276. /*
  277. applyTransition rewrites the working tree so that, for every path whose content
  278. differs between fromTree and toTree, the file becomes its toTree version.
  279. Before touching a file it checks the current (clean) working tree still matches
  280. the fromTree version. If any file has diverged the whole operation is refused,
  281. so a revert or cherry-pick either applies cleanly in full or not at all.
  282. */
  283. func (m *Manager) applyTransition(tree *gogit.Worktree, fromTree *object.Tree, toTree *object.Tree) error {
  284. paths, err := unionTreePaths(fromTree, toTree)
  285. if err != nil {
  286. return err
  287. }
  288. repoRoot := tree.Filesystem.Root()
  289. applied := 0
  290. for _, path := range paths {
  291. fromContent, fromExists, ferr := treeBytes(fromTree, path)
  292. if ferr != nil {
  293. return ferr
  294. }
  295. toContent, toExists, terr := treeBytes(toTree, path)
  296. if terr != nil {
  297. return terr
  298. }
  299. //Unchanged by this transition — nothing to do
  300. if fromExists == toExists && bytes.Equal(fromContent, toContent) {
  301. continue
  302. }
  303. //The clean working tree must still hold the "from" version, otherwise
  304. //applying the change would need a real merge.
  305. currentContent, currentExists, cerr := worktreeBytes(repoRoot, path)
  306. if cerr != nil {
  307. return cerr
  308. }
  309. if currentExists != fromExists || !bytes.Equal(currentContent, fromContent) {
  310. return errors.New("cannot apply cleanly — " + path + " has changed since that commit; a manual merge is needed")
  311. }
  312. fullPath := filepath.Join(repoRoot, filepath.FromSlash(path))
  313. if toExists {
  314. if err := os.MkdirAll(filepath.Dir(fullPath), 0775); err != nil {
  315. return err
  316. }
  317. if err := os.WriteFile(fullPath, toContent, 0664); err != nil {
  318. return err
  319. }
  320. } else if err := os.Remove(fullPath); err != nil && !os.IsNotExist(err) {
  321. return err
  322. }
  323. if _, err := tree.Add(path); err != nil {
  324. return errors.New("cannot stage " + path + ": " + err.Error())
  325. }
  326. applied++
  327. }
  328. if applied == 0 {
  329. return errors.New("this commit introduces no change to apply")
  330. }
  331. return nil
  332. }
  333. // commitStaged writes a commit from the current index. author is used when the
  334. // original author should be preserved (cherry-pick), otherwise the caller's or
  335. // the repository's identity is used for both roles.
  336. func (m *Manager) commitStaged(repo *gogit.Repository, tree *gogit.Worktree, message string, req *CommitRequest, author *object.Signature) (string, error) {
  337. if req == nil {
  338. req = &CommitRequest{}
  339. }
  340. committer, err := resolveSignature(repo, req.Name, req.Email)
  341. if err != nil {
  342. return "", err
  343. }
  344. commitAuthor := committer
  345. if author != nil {
  346. //Preserve the original author identity and date
  347. authorCopy := *author
  348. commitAuthor = &authorCopy
  349. }
  350. hash, err := tree.Commit(message, &gogit.CommitOptions{
  351. Author: commitAuthor,
  352. Committer: committer,
  353. })
  354. if err != nil {
  355. if errors.Is(err, gogit.ErrEmptyCommit) {
  356. return "", errors.New("nothing to commit — the change is already present")
  357. }
  358. return "", err
  359. }
  360. return hash.String(), nil
  361. }
  362. // resolveCommit validates a hash and loads its commit object.
  363. func resolveCommit(repo *gogit.Repository, hash string) (*object.Commit, error) {
  364. hash = strings.TrimSpace(hash)
  365. if !commitHashPattern.MatchString(hash) {
  366. return nil, errors.New("not a commit hash: " + hash)
  367. }
  368. commit, err := repo.CommitObject(plumbing.NewHash(hash))
  369. if err != nil {
  370. return nil, errors.New("no such commit: " + hash)
  371. }
  372. return commit, nil
  373. }
  374. // requireCleanWorktree refuses when there are uncommitted changes.
  375. func requireCleanWorktree(tree *gogit.Worktree) error {
  376. status, err := tree.Status()
  377. if err != nil {
  378. return err
  379. }
  380. if !status.IsClean() {
  381. return errors.New("the working tree has uncommitted changes")
  382. }
  383. return nil
  384. }
  385. // parseResetMode maps the front-end vocabulary onto go-git's reset modes.
  386. func parseResetMode(mode string) (gogit.ResetMode, error) {
  387. switch strings.ToLower(strings.TrimSpace(mode)) {
  388. case "", "mixed":
  389. return gogit.MixedReset, nil
  390. case "soft":
  391. return gogit.SoftReset, nil
  392. case "hard":
  393. return gogit.HardReset, nil
  394. default:
  395. return gogit.MixedReset, errors.New("unknown reset mode: " + mode)
  396. }
  397. }
  398. // validateTagName rejects the ref-name characters git forbids in a tag.
  399. func validateTagName(tag string) error {
  400. if tag == "" {
  401. return errors.New("tag name cannot be empty")
  402. }
  403. if err := plumbing.NewTagReferenceName(tag).Validate(); err != nil {
  404. return errors.New("invalid tag name: " + tag)
  405. }
  406. return nil
  407. }
  408. // commitSubject returns the first line of a commit message.
  409. func commitSubject(commit *object.Commit) string {
  410. subject := commit.Message
  411. if index := strings.IndexByte(subject, '\n'); index >= 0 {
  412. subject = subject[:index]
  413. }
  414. return strings.TrimSpace(subject)
  415. }
  416. // buildTagMap indexes every tag by the commit hash it ultimately points at,
  417. // following annotated tag objects to their target.
  418. func buildTagMap(repo *gogit.Repository) (map[plumbing.Hash][]string, error) {
  419. tagMap := map[plumbing.Hash][]string{}
  420. iter, err := repo.Tags()
  421. if err != nil {
  422. return tagMap, err
  423. }
  424. defer iter.Close()
  425. err = iter.ForEach(func(ref *plumbing.Reference) error {
  426. name := ref.Name().Short()
  427. //Annotated tags are their own object wrapping the commit; lightweight
  428. //tags point straight at it.
  429. if tagObject, terr := repo.TagObject(ref.Hash()); terr == nil {
  430. if commit, cerr := tagObject.Commit(); cerr == nil {
  431. tagMap[commit.Hash] = append(tagMap[commit.Hash], name)
  432. return nil
  433. }
  434. }
  435. tagMap[ref.Hash()] = append(tagMap[ref.Hash()], name)
  436. return nil
  437. })
  438. return tagMap, err
  439. }
  440. // unionTreePaths lists every file path present in either tree.
  441. func unionTreePaths(a *object.Tree, b *object.Tree) ([]string, error) {
  442. seen := map[string]struct{}{}
  443. for _, tree := range []*object.Tree{a, b} {
  444. if tree == nil {
  445. continue
  446. }
  447. iter := tree.Files()
  448. err := iter.ForEach(func(file *object.File) error {
  449. seen[file.Name] = struct{}{}
  450. return nil
  451. })
  452. iter.Close()
  453. if err != nil {
  454. return nil, err
  455. }
  456. }
  457. paths := make([]string, 0, len(seen))
  458. for path := range seen {
  459. paths = append(paths, path)
  460. }
  461. return paths, nil
  462. }
  463. // treeBytes reads a path from a tree, reporting whether it exists.
  464. func treeBytes(tree *object.Tree, path string) ([]byte, bool, error) {
  465. if tree == nil {
  466. return nil, false, nil
  467. }
  468. file, err := tree.File(path)
  469. if err != nil {
  470. return nil, false, nil
  471. }
  472. if file.Size > maxApplyBytes {
  473. return nil, true, fmt.Errorf("%s is too large to apply", path)
  474. }
  475. reader, err := file.Reader()
  476. if err != nil {
  477. return nil, true, err
  478. }
  479. defer reader.Close()
  480. content, err := io.ReadAll(reader)
  481. if err != nil {
  482. return nil, true, err
  483. }
  484. return content, true, nil
  485. }
  486. // worktreeBytes reads a path from the working tree, reporting whether it exists.
  487. func worktreeBytes(repoRoot string, path string) ([]byte, bool, error) {
  488. fullPath := filepath.Join(repoRoot, filepath.FromSlash(path))
  489. info, err := os.Stat(fullPath)
  490. if err != nil {
  491. if os.IsNotExist(err) {
  492. return nil, false, nil
  493. }
  494. return nil, false, err
  495. }
  496. if info.IsDir() {
  497. return nil, false, nil
  498. }
  499. content, err := os.ReadFile(fullPath)
  500. if err != nil {
  501. return nil, false, err
  502. }
  503. return content, true, nil
  504. }