branch_actions.go 9.5 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326
  1. package git
  2. /*
  3. branch_actions.go
  4. Deleting and renaming branches, both local and on a remote.
  5. Local operations are pure ref manipulation. Remote ones are network pushes:
  6. a delete is a push with an empty source refspec, and a rename is a push of
  7. the new name followed by a delete of the old, because git has no notion of
  8. renaming a ref on a server. That two-step is done in the safe order — the new
  9. name is created first — so a failure never leaves the branch missing.
  10. */
  11. import (
  12. "errors"
  13. "strings"
  14. gogit "github.com/go-git/go-git/v5"
  15. "github.com/go-git/go-git/v5/config"
  16. "github.com/go-git/go-git/v5/plumbing"
  17. )
  18. const (
  19. //branchRefPrefix is the ref namespace holding local branches.
  20. branchRefPrefix = "refs/heads/"
  21. //remoteRefPrefix is the ref namespace holding remote-tracking branches.
  22. remoteRefPrefix = "refs/remotes/"
  23. )
  24. // DeleteBranch removes a local branch.
  25. //
  26. // The checked-out branch is never deleted. A branch holding commits that are not
  27. // reachable from HEAD returns ErrUnmergedBranch unless force is set, so the
  28. // caller can confirm before losing work — the same protection `git branch -d`
  29. // gives over `-D`.
  30. func (m *Manager) DeleteBranch(realpath string, branch string, force bool) error {
  31. branch = strings.TrimSpace(branch)
  32. if err := validateBranchName(branch); err != nil {
  33. return err
  34. }
  35. repo, err := m.open(realpath)
  36. if err != nil {
  37. return err
  38. }
  39. referenceName := plumbing.NewBranchReferenceName(branch)
  40. branchRef, err := repo.Reference(referenceName, false)
  41. if err != nil {
  42. return errors.New("no such local branch: " + branch)
  43. }
  44. if head, herr := repo.Head(); herr == nil && head.Name() == referenceName {
  45. return errors.New("cannot delete " + branch + " because it is the current branch — switch to another branch first")
  46. }
  47. if !force {
  48. merged, cerr := branchIsMerged(repo, branchRef.Hash())
  49. //A failed reachability check is not a reason to block the delete; only a
  50. //definite "not merged" answer is.
  51. if cerr == nil && !merged {
  52. return ErrUnmergedBranch
  53. }
  54. }
  55. if err := repo.Storer.RemoveReference(referenceName); err != nil {
  56. return err
  57. }
  58. //Drop the tracking configuration so a future branch reusing the name does
  59. //not silently inherit this one's upstream.
  60. removeBranchConfig(repo, branch)
  61. return nil
  62. }
  63. // RenameBranch renames a local branch, carrying its upstream configuration over
  64. // and following HEAD when the renamed branch is the checked-out one.
  65. func (m *Manager) RenameBranch(realpath string, oldName string, newName string) error {
  66. oldName = strings.TrimSpace(oldName)
  67. newName = strings.TrimSpace(newName)
  68. if err := validateBranchName(oldName); err != nil {
  69. return err
  70. }
  71. if err := validateBranchName(newName); err != nil {
  72. return err
  73. }
  74. if oldName == newName {
  75. return errors.New("the new name is the same as the current one")
  76. }
  77. repo, err := m.open(realpath)
  78. if err != nil {
  79. return err
  80. }
  81. oldReferenceName := plumbing.NewBranchReferenceName(oldName)
  82. newReferenceName := plumbing.NewBranchReferenceName(newName)
  83. oldRef, err := repo.Reference(oldReferenceName, false)
  84. if err != nil {
  85. return errors.New("no such local branch: " + oldName)
  86. }
  87. if _, err := repo.Reference(newReferenceName, false); err == nil {
  88. return errors.New("a branch named " + newName + " already exists")
  89. }
  90. //Whether this is the current branch has to be settled before the old ref
  91. //disappears, otherwise HEAD can no longer be resolved.
  92. isCurrent := false
  93. if head, herr := repo.Head(); herr == nil && head.Name() == oldReferenceName {
  94. isCurrent = true
  95. }
  96. if err := repo.Storer.SetReference(plumbing.NewHashReference(newReferenceName, oldRef.Hash())); err != nil {
  97. return err
  98. }
  99. moveBranchConfig(repo, oldName, newName)
  100. if isCurrent {
  101. if err := repo.Storer.SetReference(plumbing.NewSymbolicReference(plumbing.HEAD, newReferenceName)); err != nil {
  102. return err
  103. }
  104. }
  105. return repo.Storer.RemoveReference(oldReferenceName)
  106. }
  107. // DeleteRemoteBranch deletes a branch on the remote and prunes the local
  108. // remote-tracking ref so it stops showing in the branch list.
  109. func (m *Manager) DeleteRemoteBranch(realpath string, remote string, branch string, req *TransportRequest) error {
  110. branch = strings.TrimSpace(branch)
  111. if err := validateBranchName(branch); err != nil {
  112. return err
  113. }
  114. if req == nil {
  115. req = &TransportRequest{}
  116. }
  117. remote = remoteOrName(remote)
  118. repo, err := m.open(realpath)
  119. if err != nil {
  120. return err
  121. }
  122. if _, rerr := repo.Remote(remote); rerr != nil {
  123. return ErrNoRemote
  124. }
  125. //An empty source is git's "delete this ref on the remote" refspec
  126. err = repo.Push(&gogit.PushOptions{
  127. RemoteName: remote,
  128. RefSpecs: []config.RefSpec{config.RefSpec(":" + branchRefPrefix + branch)},
  129. Auth: buildAuth(req.Username, req.Token),
  130. })
  131. if err != nil && !errors.Is(err, gogit.NoErrAlreadyUpToDate) {
  132. return classifyError(err)
  133. }
  134. //Pruning is best effort: the branch is already gone on the server, so a
  135. //stale local pointer must not turn a success into a failure.
  136. repo.Storer.RemoveReference(plumbing.NewRemoteReferenceName(remote, branch))
  137. return nil
  138. }
  139. /*
  140. RenameRemoteBranch renames a branch on the remote.
  141. Git cannot rename a remote ref, so this pushes the branch under its new name and
  142. then deletes the old one. The order matters: if the second push fails the branch
  143. still exists under both names, which is recoverable, whereas deleting first could
  144. lose it entirely.
  145. */
  146. func (m *Manager) RenameRemoteBranch(realpath string, remote string, oldName string, newName string, req *TransportRequest) error {
  147. oldName = strings.TrimSpace(oldName)
  148. newName = strings.TrimSpace(newName)
  149. if err := validateBranchName(oldName); err != nil {
  150. return err
  151. }
  152. if err := validateBranchName(newName); err != nil {
  153. return err
  154. }
  155. if oldName == newName {
  156. return errors.New("the new name is the same as the current one")
  157. }
  158. if req == nil {
  159. req = &TransportRequest{}
  160. }
  161. remote = remoteOrName(remote)
  162. repo, err := m.open(realpath)
  163. if err != nil {
  164. return err
  165. }
  166. if _, rerr := repo.Remote(remote); rerr != nil {
  167. return ErrNoRemote
  168. }
  169. //The remote-tracking ref is the local record of what the remote holds, and
  170. //is a valid push source.
  171. sourceName := plumbing.NewRemoteReferenceName(remote, oldName)
  172. if _, rerr := repo.Reference(sourceName, false); rerr != nil {
  173. return errors.New("no such branch on " + remote + ": " + oldName)
  174. }
  175. auth := buildAuth(req.Username, req.Token)
  176. err = repo.Push(&gogit.PushOptions{
  177. RemoteName: remote,
  178. RefSpecs: []config.RefSpec{config.RefSpec(sourceName.String() + ":" + branchRefPrefix + newName)},
  179. Auth: auth,
  180. })
  181. if err != nil && !errors.Is(err, gogit.NoErrAlreadyUpToDate) {
  182. return classifyError(err)
  183. }
  184. //The new name now exists on the server; remove the old one.
  185. err = repo.Push(&gogit.PushOptions{
  186. RemoteName: remote,
  187. RefSpecs: []config.RefSpec{config.RefSpec(":" + branchRefPrefix + oldName)},
  188. Auth: auth,
  189. })
  190. if err != nil && !errors.Is(err, gogit.NoErrAlreadyUpToDate) {
  191. return errors.New(newName + " was created on " + remote +
  192. ", but the old branch could not be removed: " + err.Error())
  193. }
  194. //Mirror the change locally so the branch list is correct without a fetch
  195. if oldRef, rerr := repo.Reference(sourceName, false); rerr == nil {
  196. repo.Storer.SetReference(plumbing.NewHashReference(
  197. plumbing.NewRemoteReferenceName(remote, newName), oldRef.Hash()))
  198. repo.Storer.RemoveReference(sourceName)
  199. }
  200. return nil
  201. }
  202. // branchIsMerged reports whether a branch tip is reachable from HEAD, i.e. its
  203. // commits are already contained in the current branch.
  204. func branchIsMerged(repo *gogit.Repository, branchHash plumbing.Hash) (bool, error) {
  205. head, err := repo.Head()
  206. if err != nil {
  207. //Nothing checked out to compare against
  208. return false, err
  209. }
  210. if head.Hash() == branchHash {
  211. return true, nil
  212. }
  213. //The walk is bounded (see maxAheadBehindWalk), so on a very large history a
  214. //long-since-merged branch may be reported as unmerged. That only costs the
  215. //user a confirmation prompt, never data.
  216. reachable, err := ancestorSet(repo, head.Hash())
  217. if err != nil {
  218. return false, err
  219. }
  220. _, merged := reachable[branchHash]
  221. return merged, nil
  222. }
  223. // removeBranchConfig deletes a branch's tracking configuration.
  224. func removeBranchConfig(repo *gogit.Repository, branch string) {
  225. cfg, err := repo.Config()
  226. if err != nil || cfg.Branches == nil {
  227. return
  228. }
  229. if _, ok := cfg.Branches[branch]; !ok {
  230. return
  231. }
  232. delete(cfg.Branches, branch)
  233. repo.SetConfig(cfg)
  234. }
  235. // moveBranchConfig re-keys a branch's tracking configuration under a new name.
  236. func moveBranchConfig(repo *gogit.Repository, oldName string, newName string) {
  237. cfg, err := repo.Config()
  238. if err != nil || cfg.Branches == nil {
  239. return
  240. }
  241. branchCfg, ok := cfg.Branches[oldName]
  242. if !ok {
  243. return
  244. }
  245. renamed := *branchCfg
  246. renamed.Name = newName
  247. cfg.Branches[newName] = &renamed
  248. delete(cfg.Branches, oldName)
  249. if err := cfg.Validate(); err != nil {
  250. //A config that will not validate must not be written back
  251. return
  252. }
  253. repo.SetConfig(cfg)
  254. }
  255. // splitRemoteRef breaks a remote-tracking ref into its remote and branch parts.
  256. // "refs/remotes/origin/feature/login" yields ("origin", "feature/login").
  257. func splitRemoteRef(fullRef string) (string, string) {
  258. trimmed := strings.TrimPrefix(fullRef, remoteRefPrefix)
  259. if trimmed == fullRef {
  260. //Not a remote-tracking ref
  261. return "", strings.TrimPrefix(fullRef, branchRefPrefix)
  262. }
  263. parts := strings.SplitN(trimmed, "/", 2)
  264. if len(parts) != 2 {
  265. return "", trimmed
  266. }
  267. return parts[0], parts[1]
  268. }
  269. // remoteOrName defaults a remote name to "origin".
  270. func remoteOrName(remote string) string {
  271. remote = strings.TrimSpace(remote)
  272. if remote == "" {
  273. return defaultRemote
  274. }
  275. return remote
  276. }