manager.go 7.2 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282
  1. package git
  2. /*
  3. manager.go
  4. Core Manager type plus repository discovery / creation.
  5. Everything in this package is built on go-git (pure Go, already a direct
  6. dependency of the project — see mod/modules/installer.go). No `git` binary is
  7. ever invoked, so the feature cross-compiles to every target in the Makefile
  8. and needs no host package installed.
  9. */
  10. import (
  11. "errors"
  12. "os"
  13. "path/filepath"
  14. "strings"
  15. gogit "github.com/go-git/go-git/v5"
  16. "github.com/go-git/go-git/v5/config"
  17. "github.com/go-git/go-git/v5/plumbing"
  18. "imuslab.com/arozos/mod/info/logger"
  19. )
  20. // Options configures a Manager.
  21. type Options struct {
  22. //Database is the ArozOS system database used to persist credentials.
  23. //A nil Database disables the credential store (operations still work with
  24. //credentials passed per call).
  25. Database CredentialDatabase
  26. //KeyStorePath is the folder holding the AES key that encrypts stored
  27. //credentials, e.g. "./system/git". Created on demand.
  28. KeyStorePath string
  29. //Logger is the system-wide logger. Optional.
  30. Logger *logger.Logger
  31. }
  32. // Manager is the entry point for all git operations. It is stateless with
  33. // respect to repositories: every call resolves the repository from the real
  34. // path it is given, so concurrent operations on different repos never contend.
  35. type Manager struct {
  36. options *Options
  37. credentials *CredentialStore
  38. }
  39. // NewManager builds a Manager. It only fails when the credential store cannot
  40. // be initialised; git operations themselves have no host prerequisites.
  41. func NewManager(options Options) (*Manager, error) {
  42. manager := &Manager{options: &options}
  43. if options.Database != nil {
  44. store, err := newCredentialStore(options.Database, options.KeyStorePath)
  45. if err != nil {
  46. return nil, err
  47. }
  48. manager.credentials = store
  49. }
  50. return manager, nil
  51. }
  52. // Credentials exposes the credential store. Returns nil when no database was
  53. // wired in.
  54. func (m *Manager) Credentials() *CredentialStore {
  55. return m.credentials
  56. }
  57. // IsRepo reports whether realpath is inside a git working tree. A bare .git
  58. // folder counts, a plain folder does not.
  59. func (m *Manager) IsRepo(realpath string) bool {
  60. _, err := m.open(realpath)
  61. return err == nil
  62. }
  63. // RepoRoot returns the working tree root containing realpath. Used so the UI can
  64. // accept any folder inside a repository and still operate on the whole repo.
  65. func (m *Manager) RepoRoot(realpath string) (string, error) {
  66. current, err := filepath.Abs(realpath)
  67. if err != nil {
  68. return "", err
  69. }
  70. for {
  71. if isDir(filepath.Join(current, ".git")) || isFile(filepath.Join(current, ".git")) {
  72. return filepath.ToSlash(current), nil
  73. }
  74. parent := filepath.Dir(current)
  75. if parent == current {
  76. return "", ErrNotARepo
  77. }
  78. current = parent
  79. }
  80. }
  81. // Init creates an empty repository at realpath, creating the folder when it
  82. // does not exist yet.
  83. func (m *Manager) Init(realpath string) error {
  84. if err := os.MkdirAll(realpath, 0775); err != nil {
  85. return err
  86. }
  87. if m.IsRepo(realpath) {
  88. return errors.New("a git repository already exists at this location")
  89. }
  90. _, err := gogit.PlainInit(realpath, false)
  91. return err
  92. }
  93. // Clone clones req.URL into req.Dest. The destination must not exist or must be
  94. // an empty folder — go-git refuses to clone into a populated directory, and we
  95. // check up front so the caller gets a readable message instead of a low level
  96. // one.
  97. func (m *Manager) Clone(req *CloneRequest) error {
  98. if strings.TrimSpace(req.URL) == "" {
  99. return errors.New("remote URL cannot be empty")
  100. }
  101. if strings.TrimSpace(req.Dest) == "" {
  102. return errors.New("clone destination cannot be empty")
  103. }
  104. empty, err := dirIsEmpty(req.Dest)
  105. if err != nil {
  106. return err
  107. }
  108. if !empty {
  109. return errors.New("clone destination is not empty")
  110. }
  111. //Remember whether the folder was already there: a failed clone should clean
  112. //up after itself without deleting a folder the user created.
  113. destExisted := isDir(req.Dest)
  114. if err := os.MkdirAll(req.Dest, 0775); err != nil {
  115. return err
  116. }
  117. cloneOptions := &gogit.CloneOptions{
  118. URL: req.URL,
  119. Depth: req.Depth,
  120. Auth: buildAuth(req.Username, req.Token),
  121. }
  122. if req.Branch != "" {
  123. cloneOptions.ReferenceName = plumbing.NewBranchReferenceName(req.Branch)
  124. cloneOptions.SingleBranch = true
  125. }
  126. if _, err := gogit.PlainClone(req.Dest, false, cloneOptions); err != nil {
  127. //A failed clone leaves a half written folder behind. Clear it so the
  128. //user can retry into the same path (e.g. after fixing credentials).
  129. if destExisted {
  130. emptyDir(req.Dest)
  131. } else {
  132. os.RemoveAll(req.Dest)
  133. }
  134. return classifyError(err)
  135. }
  136. return nil
  137. }
  138. // Remotes lists the configured remotes of the repository at realpath.
  139. func (m *Manager) Remotes(realpath string) ([]RemoteInfo, error) {
  140. repo, err := m.open(realpath)
  141. if err != nil {
  142. return nil, err
  143. }
  144. remotes, err := repo.Remotes()
  145. if err != nil {
  146. return nil, err
  147. }
  148. results := []RemoteInfo{}
  149. for _, remote := range remotes {
  150. cfg := remote.Config()
  151. results = append(results, RemoteInfo{
  152. Name: cfg.Name,
  153. URLs: cfg.URLs,
  154. })
  155. }
  156. return results, nil
  157. }
  158. // AddRemote registers a new remote. Passing an existing name replaces its URLs.
  159. func (m *Manager) AddRemote(realpath string, name string, url string) error {
  160. if strings.TrimSpace(name) == "" || strings.TrimSpace(url) == "" {
  161. return errors.New("remote name and URL are both required")
  162. }
  163. repo, err := m.open(realpath)
  164. if err != nil {
  165. return err
  166. }
  167. if _, err := repo.Remote(name); err == nil {
  168. if err := repo.DeleteRemote(name); err != nil {
  169. return err
  170. }
  171. }
  172. _, err = repo.CreateRemote(&config.RemoteConfig{
  173. Name: name,
  174. URLs: []string{url},
  175. })
  176. return err
  177. }
  178. // RemoveRemote deletes a remote by name.
  179. func (m *Manager) RemoveRemote(realpath string, name string) error {
  180. repo, err := m.open(realpath)
  181. if err != nil {
  182. return err
  183. }
  184. return repo.DeleteRemote(name)
  185. }
  186. // open resolves realpath to a repository, walking up to the working tree root
  187. // so callers may pass any path inside the repo.
  188. func (m *Manager) open(realpath string) (*gogit.Repository, error) {
  189. repo, err := gogit.PlainOpenWithOptions(realpath, &gogit.PlainOpenOptions{
  190. DetectDotGit: true,
  191. })
  192. if err != nil {
  193. if errors.Is(err, gogit.ErrRepositoryNotExists) {
  194. return nil, ErrNotARepo
  195. }
  196. return nil, err
  197. }
  198. return repo, nil
  199. }
  200. // worktree resolves realpath to a repository and its working tree in one go.
  201. func (m *Manager) worktree(realpath string) (*gogit.Repository, *gogit.Worktree, error) {
  202. repo, err := m.open(realpath)
  203. if err != nil {
  204. return nil, nil, err
  205. }
  206. tree, err := repo.Worktree()
  207. if err != nil {
  208. return nil, nil, err
  209. }
  210. return repo, tree, nil
  211. }
  212. // dirIsEmpty reports whether path is absent or an empty directory.
  213. func dirIsEmpty(path string) (bool, error) {
  214. entries, err := os.ReadDir(path)
  215. if err != nil {
  216. if os.IsNotExist(err) {
  217. return true, nil
  218. }
  219. return false, err
  220. }
  221. return len(entries) == 0, nil
  222. }
  223. // emptyDir removes everything inside path but keeps the folder itself.
  224. func emptyDir(path string) {
  225. entries, err := os.ReadDir(path)
  226. if err != nil {
  227. return
  228. }
  229. for _, entry := range entries {
  230. os.RemoveAll(filepath.Join(path, entry.Name()))
  231. }
  232. }
  233. func isDir(path string) bool {
  234. info, err := os.Stat(path)
  235. return err == nil && info.IsDir()
  236. }
  237. func isFile(path string) bool {
  238. info, err := os.Stat(path)
  239. return err == nil && !info.IsDir()
  240. }