GitRoot

Craft your forge, Build your project, Grow your community freely
  1// SPDX-FileCopyrightText: 2025 Romain Maneschi <romain@gitroot.dev>
  2//
  3// SPDX-License-Identifier: EUPL-1.2
  4
  5package repository
  6
  7import (
  8	"os"
  9	"path/filepath"
 10
 11	"github.com/go-git/go-billy/v6/osfs"
 12	git "github.com/go-git/go-git/v6"
 13	"github.com/go-git/go-git/v6/plumbing"
 14	"github.com/go-git/go-git/v6/plumbing/cache"
 15	"github.com/go-git/go-git/v6/plumbing/object"
 16	"github.com/go-git/go-git/v6/storage"
 17	"github.com/go-git/go-git/v6/storage/filesystem"
 18	"github.com/samber/oops"
 19	"gitroot.dev/server/logger"
 20)
 21
 22type GitRootRepository struct {
 23	name           string
 24	logger         *logger.Logger
 25	errorHandler   oops.OopsErrorBuilder
 26	repo           *git.Repository
 27	storer         storage.Storer
 28	storerCache    cache.Object
 29	manager        *Manager
 30	hasBecomeWrite *GitRootRepositoryWrite
 31	configLoaded   bool
 32	config         RepoConfiguration
 33}
 34
 35func (m *Manager) NewGitRootRepository(name string, withCache *GitRootRepository) (*GitRootRepository, error) {
 36	errorHandler := oops.Code("NewGitRootRepository").With("name", name)
 37
 38	unlock := m.repoLocks.Read(name)
 39	defer unlock()
 40
 41	if withCache == nil {
 42		storerCache := cache.NewObjectLRUDefault()
 43		bfs := filesystem.NewStorageWithOptions(osfs.New(m.conf.GetDirPathForRepo(name), osfs.WithBoundOS()), storerCache, filesystem.Options{
 44			HighMemoryMode: true,
 45			UseInMemoryIdx: true,
 46		})
 47		repo, err := git.Open(bfs, nil)
 48		if err != nil {
 49			return nil, errorHandler.Wrapf(err, "can't open")
 50		}
 51		return &GitRootRepository{
 52			name:           name,
 53			logger:         m.logger.NewSubLogger(name),
 54			errorHandler:   errorHandler,
 55			repo:           repo,
 56			storer:         bfs,
 57			storerCache:    storerCache,
 58			manager:        m,
 59			hasBecomeWrite: nil,
 60		}, nil
 61	} else {
 62		return &GitRootRepository{
 63			name:           name,
 64			logger:         m.logger.NewSubLogger(name),
 65			errorHandler:   errorHandler,
 66			repo:           withCache.repo,
 67			storer:         withCache.storer,
 68			storerCache:    withCache.storerCache,
 69			manager:        m,
 70			hasBecomeWrite: nil,
 71		}, nil
 72	}
 73}
 74
 75func (repo *GitRootRepository) Name() string {
 76	return repo.name
 77}
 78
 79func (repo *GitRootRepository) WillWrite(branch plumbing.ReferenceName) (*GitRootRepositoryWrite, error) {
 80	lock := repo.manager.repoLocks.WillWrite(repo.name)
 81	if repo.hasBecomeWrite != nil {
 82		return nil, repo.errorHandler.Errorf("WillWrite called multiple times before close")
 83	}
 84	hasBecomeWrite, err := NewGitRootRepositoryWrite(repo, branch, lock)
 85	repo.hasBecomeWrite = hasBecomeWrite
 86	return repo.hasBecomeWrite, err
 87}
 88
 89func (repo *GitRootRepository) StopWrite() {
 90	if repo.hasBecomeWrite != nil {
 91		repo.hasBecomeWrite.close()
 92	}
 93	repo.hasBecomeWrite = nil
 94}
 95
 96func (repo *GitRootRepository) Commit(hash plumbing.Hash) (*object.Commit, error) {
 97	return repo.repo.CommitObject(hash)
 98}
 99
100func (repo *GitRootRepository) Configuration() (RepoConfiguration, error) {
101	if !repo.configLoaded {
102		hash, err := repo.DefaultBranchCurrentHash()
103		if err != nil {
104			return RepoConfiguration{}, repo.errorHandler.Wrapf(err, "can't get default hash")
105		}
106		content, err := repo.ContentAtHash(repo.manager.conf.PathFileRepoConfigurationName(), hash)
107		if err != nil {
108			return RepoConfiguration{}, repo.errorHandler.Wrapf(err, "can't read conf file")
109		}
110		conf, err := readRepoConfiguration(content)
111		if err != nil {
112			return RepoConfiguration{}, repo.errorHandler.Wrapf(err, "can't read repo conf")
113		}
114		repo.configLoaded = true
115		repo.config = conf
116	}
117	return repo.config, nil
118}
119
120func (repo *GitRootRepository) Branches() ([]plumbing.ReferenceName, error) {
121	unlock := repo.manager.repoLocks.Read(repo.name)
122	defer unlock()
123	branches, err := repo.repo.Branches()
124	if err != nil {
125		return nil, repo.errorHandler.Wrapf(err, "can't load branches")
126	}
127	b := []plumbing.ReferenceName{}
128	branches.ForEach(func(r *plumbing.Reference) error {
129		b = append(b, r.Name())
130		return nil
131	})
132	return b, nil
133}
134
135func (repo *GitRootRepository) DefaultBranch() (plumbing.ReferenceName, error) {
136	unlock := repo.manager.repoLocks.Read(repo.name)
137	defer unlock()
138	conf, err := repo.repo.Config()
139	if err != nil {
140		return plumbing.NewBranchReferenceName(""), repo.errorHandler.Wrapf(err, "can't read conf git")
141	}
142	return plumbing.NewBranchReferenceName(conf.Init.DefaultBranch), nil
143}
144
145func (repo *GitRootRepository) DefaultBranchCurrentHash() (plumbing.Hash, error) {
146	unlock := repo.manager.repoLocks.Read(repo.name)
147	defer unlock()
148	conf, err := repo.repo.Config()
149	if err != nil {
150		return plumbing.ZeroHash, repo.errorHandler.Wrapf(err, "can't read conf git")
151	}
152	ref, err := repo.repo.Reference(plumbing.NewBranchReferenceName(conf.Init.DefaultBranch), true)
153	if err != nil {
154		return plumbing.ZeroHash, repo.errorHandler.Wrapf(err, "can't read conf git")
155	}
156	return ref.Hash(), nil
157}
158
159func (repo *GitRootRepository) ContentRepositoriesConf() ([]byte, error) {
160	hash, err := repo.DefaultBranchCurrentHash()
161	if err != nil {
162		return nil, oops.With("repo", repo.Name()).Wrapf(err, "can't get conf")
163	}
164	return repo.ContentAtHash(repo.manager.conf.PathFileRepositories(), hash)
165}
166
167func (repo *GitRootRepository) ContentPluginsConf() ([]byte, error) {
168	hash, err := repo.DefaultBranchCurrentHash()
169	if err != nil {
170		return nil, oops.With("repo", repo.Name()).Wrapf(err, "can't get conf")
171	}
172	return repo.ContentAtHash(repo.manager.conf.PathFilePlugins(), hash)
173}
174
175func (repo *GitRootRepository) ContentPluginsConfAtHash(hash plumbing.Hash) ([]byte, error) {
176	return repo.ContentAtHash(repo.manager.conf.PathFilePlugins(), hash)
177}
178
179func (repo *GitRootRepository) ContentPluginsConfAtRef(name plumbing.ReferenceName) ([]byte, error) {
180	return repo.ContentAtRef(repo.manager.conf.PathFilePlugins(), name)
181}
182
183func (repo *GitRootRepository) ContentUserAtDefaultBranch() ([]byte, error) {
184	hash, err := repo.DefaultBranchCurrentHash()
185	if err != nil {
186		return nil, oops.With("repo", repo.Name()).Wrapf(err, "can't get conf")
187	}
188	return repo.ContentAtHash(repo.manager.conf.PathFileUsers(), hash)
189}
190
191func (repo *GitRootRepository) PathDataWeb(path string) string {
192	return filepath.Join(repo.manager.conf.PathDataWeb(repo.name), path)
193}
194
195func (repo *GitRootRepository) Content(filepath string) ([]byte, error) {
196	hash, err := repo.DefaultBranchCurrentHash()
197	if err != nil {
198		return nil, oops.Wrapf(err, "Head")
199	}
200	repo.logger.Debug("head", logger.NewLoggerPair("hash", hash.String()))
201	return repo.ContentAtHash(filepath, hash)
202}
203
204func (repo *GitRootRepository) ContentAtRef(filepath string, name plumbing.ReferenceName) ([]byte, error) {
205	unlock := repo.manager.repoLocks.Read(repo.name)
206	ref, err := repo.repo.Reference(name, false)
207	if err != nil {
208		unlock()
209		return nil, oops.With("ref", name).Wrapf(err, "ContentPluginsConfAtRef")
210	}
211	unlock()
212	return repo.ContentAtHash(filepath, ref.Hash())
213}
214
215func (repo *GitRootRepository) ContentAtHash(filepath string, hash plumbing.Hash) ([]byte, error) {
216	unlock := repo.manager.repoLocks.Read(repo.name)
217	defer unlock()
218	c, err := repo.repo.CommitObject(hash)
219	if err != nil {
220		return nil, err
221	}
222	tree, err := c.Tree()
223	if err != nil {
224		return nil, err
225	}
226	file, err := tree.File(filepath)
227	if err != nil {
228		return nil, err
229	}
230	fileContent, err := file.Contents()
231	if err != nil {
232		return nil, repo.errorHandler.With("filepath", filepath).Wrapf(err, "can't read file")
233	}
234	return []byte(fileContent), nil
235}
236
237func (repo *GitRootRepository) Exists(filepath string) bool {
238	hash, err := repo.DefaultBranchCurrentHash()
239	if err != nil {
240		return false
241	}
242	return repo.ExistsAtHash(filepath, hash)
243}
244
245func (repo *GitRootRepository) ExistsAtHash(filepath string, hash plumbing.Hash) bool {
246	unlock := repo.manager.repoLocks.Read(repo.name)
247	defer unlock()
248	c, err := repo.repo.CommitObject(hash)
249	if err != nil {
250		return false
251	}
252	tree, err := c.Tree()
253	if err != nil {
254		return false
255	}
256	f, _ := tree.FindEntry(filepath)
257	return f != nil
258}
259
260func (repo *GitRootRepository) Worktree(path string, branch string) error {
261	hash, err := repo.DefaultBranchCurrentHash()
262	if err != nil {
263		return repo.errorHandler.Wrapf(err, "can't get repoConf for worktree on osfs")
264	}
265	unlock := repo.manager.repoLocks.Read(repo.name)
266	defer unlock()
267	ref, err := repo.repo.Reference(plumbing.NewBranchReferenceName(branch), false)
268	if err != nil {
269		return oops.With("ref", branch).Wrapf(err, "Worktree")
270	}
271	commit, err := repo.repo.CommitObject(ref.Hash())
272	if err != nil {
273		return repo.errorHandler.With("hash", hash).Wrapf(err, "can't get CommitObject for worktree on osfs")
274	}
275	tree, err := repo.repo.TreeObject(commit.TreeHash)
276	if err != nil {
277		return repo.errorHandler.With("hash", hash).Wrapf(err, "can't get TreeObject for worktree on osfs")
278	}
279	files := tree.Files()
280	files.ForEach(func(file *object.File) error {
281		content, err := file.Contents()
282		if err != nil {
283			repo.logger.Error("can't get content", err, logger.NewLoggerPair("file", file.Name))
284			return nil
285		}
286		dir, _ := filepath.Split(file.Name)
287		err = os.MkdirAll(filepath.Join(path, dir), os.ModePerm)
288		if err != nil {
289			repo.logger.Error("can't mkdirall", err, logger.NewLoggerPair("dir", dir))
290		}
291		if err := os.WriteFile(filepath.Join(path, file.Name), []byte(content), os.ModePerm); err != nil {
292			repo.logger.Error("can't WriteFile", err, logger.NewLoggerPair("file", file.Name))
293		}
294		return nil
295	})
296	return nil
297}
298
299func (repo *GitRootRepository) Close() error {
300	repo.logger.Debug("Close", logger.NewLoggerPair("hasBecomeWrite", repo.hasBecomeWrite != nil))
301	if repo.hasBecomeWrite != nil {
302		repo.hasBecomeWrite.close()
303		repo.hasBecomeWrite = nil
304	}
305	if nb := repo.manager.cacheRepos.Remove(repo.name); nb == 0 {
306		return repo.manager.cacheRepos.Delete(repo.name, func() error {
307			repoLock := repo.manager.repoLocks.WillWrite(repo.name)
308			defer repoLock.Close()
309			unwrite := repoLock.Write()
310			defer unwrite()
311
312			// if err := repo.repo.Prune(git.PruneOptions{Handler: repo.repo.DeleteObject}); err != nil && !errors.Is(err, git.ErrLooseObjectsNotSupported) {
313			// 	repo.logger.Error("close prune", oops.Wrapf(err, "Prune"), logger.NewLoggerPair("type storer", fmt.Sprintf("%T", repo.repo.Storer)))
314			// } else {
315			// 	repo.logger.Debug("close prune ok", logger.NewLoggerPair("type storer", fmt.Sprintf("%T", repo.repo.Storer)))
316			// }
317
318			// if err := repo.repo.RepackObjects(&git.RepackConfig{UseRefDeltas: true}); err != nil && !errors.Is(err, git.ErrPackedObjectsNotSupported) {
319			// 	repo.logger.Error("close RepackObjects", oops.Wrapf(err, "RepackObjects"))
320			// } else {
321			// 	repo.logger.Debug("close RepackObjects ok", logger.NewLoggerPair("type storer", fmt.Sprintf("%T", repo.repo.Storer)))
322			// }
323
324			if sto, ok := repo.storer.(*filesystem.Storage); ok {
325				sto.Close()
326			}
327			repo.storerCache.Clear()
328			return nil
329		})
330	}
331
332	return nil
333}