diff options
Diffstat (limited to 'storage/filesystem')
-rw-r--r-- | storage/filesystem/dotgit/dotgit.go | 97 | ||||
-rw-r--r-- | storage/filesystem/dotgit/dotgit_test.go | 162 | ||||
-rw-r--r-- | storage/filesystem/object.go | 13 | ||||
-rw-r--r-- | storage/filesystem/storage.go | 9 |
4 files changed, 227 insertions, 54 deletions
diff --git a/storage/filesystem/dotgit/dotgit.go b/storage/filesystem/dotgit/dotgit.go index e02e6dd..31c4694 100644 --- a/storage/filesystem/dotgit/dotgit.go +++ b/storage/filesystem/dotgit/dotgit.go @@ -8,18 +8,21 @@ import ( "fmt" "io" "os" + "path" "path/filepath" + "reflect" + "runtime" "sort" "strings" "time" - "github.com/go-git/go-billy/v5/osfs" "github.com/go-git/go-git/v5/plumbing" "github.com/go-git/go-git/v5/plumbing/hash" "github.com/go-git/go-git/v5/storage" "github.com/go-git/go-git/v5/utils/ioutil" "github.com/go-git/go-billy/v5" + "github.com/go-git/go-billy/v5/helper/chroot" ) const ( @@ -38,6 +41,7 @@ const ( remotesPath = "remotes" logsPath = "logs" worktreesPath = "worktrees" + alternatesPath = "alternates" tmpPackedRefsPrefix = "._packed-refs" @@ -78,6 +82,10 @@ type Options struct { // KeepDescriptors makes the file descriptors to be reused but they will // need to be manually closed calling Close(). KeepDescriptors bool + // AlternatesFS provides the billy filesystem to be used for Git Alternates. + // If none is provided, it falls back to using the underlying instance used for + // DotGit. + AlternatesFS billy.Filesystem } // The DotGit type represents a local git repository on disk. This @@ -1105,38 +1113,93 @@ func (d *DotGit) Module(name string) (billy.Filesystem, error) { return d.fs.Chroot(d.fs.Join(modulePath, name)) } +func (d *DotGit) AddAlternate(remote string) error { + altpath := d.fs.Join(objectsPath, infoPath, alternatesPath) + + f, err := d.fs.OpenFile(altpath, os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0640) + if err != nil { + return fmt.Errorf("cannot open file: %w", err) + } + defer f.Close() + + // locking in windows throws an error, based on comments + // https://github.com/go-git/go-git/pull/860#issuecomment-1751823044 + // do not lock on windows platform. + if runtime.GOOS != "windows" { + if err = f.Lock(); err != nil { + return fmt.Errorf("cannot lock file: %w", err) + } + defer f.Unlock() + } + + line := path.Join(remote, objectsPath) + "\n" + _, err = io.WriteString(f, line) + if err != nil { + return fmt.Errorf("error writing 'alternates' file: %w", err) + } + + return nil +} + // Alternates returns DotGit(s) based off paths in objects/info/alternates if // available. This can be used to checks if it's a shared repository. func (d *DotGit) Alternates() ([]*DotGit, error) { - altpath := d.fs.Join("objects", "info", "alternates") + altpath := d.fs.Join(objectsPath, infoPath, alternatesPath) f, err := d.fs.Open(altpath) if err != nil { return nil, err } defer f.Close() + fs := d.options.AlternatesFS + if fs == nil { + fs = d.fs + } + var alternates []*DotGit + seen := make(map[string]struct{}) // Read alternate paths line-by-line and create DotGit objects. scanner := bufio.NewScanner(f) for scanner.Scan() { path := scanner.Text() - if !filepath.IsAbs(path) { - // For relative paths, we can perform an internal conversion to - // slash so that they work cross-platform. - slashPath := filepath.ToSlash(path) - // If the path is not absolute, it must be relative to object - // database (.git/objects/info). - // https://www.kernel.org/pub/software/scm/git/docs/gitrepository-layout.html - // Hence, derive a path relative to DotGit's root. - // "../../../reponame/.git/" -> "../../reponame/.git" - // Remove the first ../ - relpath := filepath.Join(strings.Split(slashPath, "/")[1:]...) - normalPath := filepath.FromSlash(relpath) - path = filepath.Join(d.fs.Root(), normalPath) + + // Avoid creating multiple dotgits for the same alternative path. + if _, ok := seen[path]; ok { + continue + } + + seen[path] = struct{}{} + + if filepath.IsAbs(path) { + // Handling absolute paths should be straight-forward. However, the default osfs (Chroot) + // tries to concatenate an abs path with the root path in some operations (e.g. Stat), + // which leads to unexpected errors. Therefore, make the path relative to the current FS instead. + if reflect.TypeOf(fs) == reflect.TypeOf(&chroot.ChrootHelper{}) { + path, err = filepath.Rel(fs.Root(), path) + if err != nil { + return nil, fmt.Errorf("cannot make path %q relative: %w", path, err) + } + } + } else { + // By Git conventions, relative paths should be based on the object database (.git/objects/info) + // location as per: https://www.kernel.org/pub/software/scm/git/docs/gitrepository-layout.html + // However, due to the nature of go-git and its filesystem handling via Billy, paths cannot + // cross its "chroot boundaries". Therefore, ignore any "../" and treat the path from the + // fs root. If this is not correct based on the dotgit fs, set a different one via AlternatesFS. + abs := filepath.Join(string(filepath.Separator), filepath.ToSlash(path)) + path = filepath.FromSlash(abs) + } + + // Aligns with upstream behavior: exit if target path is not a valid directory. + if fi, err := fs.Stat(path); err != nil || !fi.IsDir() { + return nil, fmt.Errorf("invalid object directory %q: %w", path, err) + } + afs, err := fs.Chroot(filepath.Dir(path)) + if err != nil { + return nil, fmt.Errorf("cannot chroot %q: %w", path, err) } - fs := osfs.New(filepath.Dir(path)) - alternates = append(alternates, New(fs)) + alternates = append(alternates, New(afs)) } if err = scanner.Err(); err != nil { diff --git a/storage/filesystem/dotgit/dotgit_test.go b/storage/filesystem/dotgit/dotgit_test.go index 1b6c113..2cbdb0c 100644 --- a/storage/filesystem/dotgit/dotgit_test.go +++ b/storage/filesystem/dotgit/dotgit_test.go @@ -6,6 +6,7 @@ import ( "io" "os" "path/filepath" + "regexp" "runtime" "strings" "testing" @@ -15,6 +16,7 @@ import ( "github.com/go-git/go-billy/v5/util" fixtures "github.com/go-git/go-git-fixtures/v4" "github.com/go-git/go-git/v5/plumbing" + "github.com/stretchr/testify/assert" . "gopkg.in/check.v1" ) @@ -810,53 +812,139 @@ func (s *SuiteDotGit) TestPackRefs(c *C) { c.Assert(ref.Hash().String(), Equals, "b8d3ffab552895c19b9fcf7aa264d277cde33881") } -func (s *SuiteDotGit) TestAlternates(c *C) { - fs, clean := s.TemporalFilesystem() - defer clean() +func TestAlternatesDefault(t *testing.T) { + // Create a new dotgit object. + dotFS := osfs.New(t.TempDir()) - // Create a new dotgit object and initialize. - dir := New(fs) - err := dir.Initialize() - c.Assert(err, IsNil) + testAlternates(t, dotFS, dotFS) +} - // Create alternates file. - altpath := fs.Join("objects", "info", "alternates") - f, err := fs.Create(altpath) - c.Assert(err, IsNil) +func TestAlternatesWithFS(t *testing.T) { + // Create a new dotgit object with a specific FS for alternates. + altFS := osfs.New(t.TempDir()) + dotFS, _ := altFS.Chroot("repo2") - // Multiple alternates. - var strContent string - if runtime.GOOS == "windows" { - strContent = "C:\\Users\\username\\repo1\\.git\\objects\r\n..\\..\\..\\rep2\\.git\\objects" - } else { - strContent = "/Users/username/rep1//.git/objects\n../../../rep2//.git/objects" + testAlternates(t, dotFS, altFS) +} + +func TestAlternatesWithBoundOS(t *testing.T) { + // Create a new dotgit object with a specific FS for alternates. + altFS := osfs.New(t.TempDir(), osfs.WithBoundOS()) + dotFS, _ := altFS.Chroot("repo2") + + testAlternates(t, dotFS, altFS) +} + +func testAlternates(t *testing.T, dotFS, altFS billy.Filesystem) { + tests := []struct { + name string + in []string + inWindows []string + setup func() + wantErr bool + wantRoots []string + }{ + { + name: "no alternates", + }, + { + name: "abs path", + in: []string{filepath.Join(altFS.Root(), "./repo1/.git/objects")}, + inWindows: []string{filepath.Join(altFS.Root(), ".\\repo1\\.git\\objects")}, + setup: func() { + err := altFS.MkdirAll(filepath.Join("repo1", ".git", "objects"), 0o700) + assert.NoError(t, err) + }, + wantRoots: []string{filepath.Join("repo1", ".git")}, + }, + { + name: "rel path", + in: []string{"../../../repo3//.git/objects"}, + inWindows: []string{"..\\..\\..\\repo3\\.git\\objects"}, + setup: func() { + err := altFS.MkdirAll(filepath.Join("repo3", ".git", "objects"), 0o700) + assert.NoError(t, err) + }, + wantRoots: []string{filepath.Join("repo3", ".git")}, + }, + { + name: "invalid abs path", + in: []string{"/alt/target2"}, + inWindows: []string{"\\alt\\target2"}, + wantErr: true, + }, + { + name: "invalid rel path", + in: []string{"../../../alt/target3"}, + inWindows: []string{"..\\..\\..\\alt\\target3"}, + wantErr: true, + }, } - content := []byte(strContent) - f.Write(content) - f.Close() - dotgits, err := dir.Alternates() - c.Assert(err, IsNil) - if runtime.GOOS == "windows" { - c.Assert(dotgits[0].fs.Root(), Equals, "C:\\Users\\username\\repo1\\.git") - } else { - c.Assert(dotgits[0].fs.Root(), Equals, "/Users/username/rep1/.git") + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + dir := NewWithOptions(dotFS, Options{AlternatesFS: altFS}) + err := dir.Initialize() + assert.NoError(t, err) + + content := strings.Join(tc.in, "\n") + if runtime.GOOS == "windows" { + content = strings.Join(tc.inWindows, "\r\n") + } + + // Create alternates file. + altpath := dotFS.Join("objects", "info", "alternates") + f, err := dotFS.Create(altpath) + assert.NoError(t, err) + f.Write([]byte(content)) + f.Close() + + if tc.setup != nil { + tc.setup() + } + + dotgits, err := dir.Alternates() + if tc.wantErr { + assert.Error(t, err) + } else { + assert.NoError(t, err) + } + + for i, d := range dotgits { + assert.Regexp(t, "^"+regexp.QuoteMeta(altFS.Root()), d.fs.Root()) + assert.Regexp(t, regexp.QuoteMeta(tc.wantRoots[i])+"$", d.fs.Root()) + } + }) } +} - // For relative path: - // /some/absolute/path/to/dot-git -> /some/absolute/path - pathx := strings.Split(fs.Root(), string(filepath.Separator)) - pathx = pathx[:len(pathx)-2] - // Use string.Join() to avoid malformed absolutepath on windows - // C:Users\\User\\... instead of C:\\Users\\appveyor\\... . - resolvedPath := strings.Join(pathx, string(filepath.Separator)) - // Append the alternate path to the resolvedPath - expectedPath := fs.Join(string(filepath.Separator), resolvedPath, "rep2", ".git") +func TestAlternatesDupes(t *testing.T) { + dotFS := osfs.New(t.TempDir()) + dir := New(dotFS) + err := dir.Initialize() + assert.NoError(t, err) + + path := filepath.Join(dotFS.Root(), "target3") + dupes := []string{path, path, path, path, path} + + content := strings.Join(dupes, "\n") if runtime.GOOS == "windows" { - expectedPath = fs.Join(resolvedPath, "rep2", ".git") + content = strings.Join(dupes, "\r\n") } - c.Assert(dotgits[1].fs.Root(), Equals, expectedPath) + err = dotFS.MkdirAll("target3", 0o700) + assert.NoError(t, err) + + // Create alternates file. + altpath := dotFS.Join("objects", "info", "alternates") + f, err := dotFS.Create(altpath) + assert.NoError(t, err) + f.Write([]byte(content)) + f.Close() + + dotgits, err := dir.Alternates() + assert.NoError(t, err) + assert.Len(t, dotgits, 1) } type norwfs struct { diff --git a/storage/filesystem/object.go b/storage/filesystem/object.go index 846a7b8..e812fe9 100644 --- a/storage/filesystem/object.go +++ b/storage/filesystem/object.go @@ -146,6 +146,19 @@ func (s *ObjectStorage) SetEncodedObject(o plumbing.EncodedObject) (h plumbing.H return o.Hash(), err } +// LazyWriter returns a lazy ObjectWriter that is bound to a DotGit file. +// It first write the header passing on the object type and size, so +// that the object contents can be written later, without the need to +// create a MemoryObject and buffering its entire contents into memory. +func (s *ObjectStorage) LazyWriter() (w io.WriteCloser, wh func(typ plumbing.ObjectType, sz int64) error, err error) { + ow, err := s.dir.NewObject() + if err != nil { + return nil, nil, err + } + + return ow, ow.WriteHeader, nil +} + // HasEncodedObject returns nil if the object exists, without actually // reading the object data from storage. func (s *ObjectStorage) HasEncodedObject(h plumbing.Hash) (err error) { diff --git a/storage/filesystem/storage.go b/storage/filesystem/storage.go index 7e7a2c5..951ea00 100644 --- a/storage/filesystem/storage.go +++ b/storage/filesystem/storage.go @@ -37,6 +37,10 @@ type Options struct { // LargeObjectThreshold maximum object size (in bytes) that will be read in to memory. // If left unset or set to 0 there is no limit LargeObjectThreshold int64 + // AlternatesFS provides the billy filesystem to be used for Git Alternates. + // If none is provided, it falls back to using the underlying instance used for + // DotGit. + AlternatesFS billy.Filesystem } // NewStorage returns a new Storage backed by a given `fs.Filesystem` and cache. @@ -49,6 +53,7 @@ func NewStorage(fs billy.Filesystem, cache cache.Object) *Storage { func NewStorageWithOptions(fs billy.Filesystem, cache cache.Object, ops Options) *Storage { dirOps := dotgit.Options{ ExclusiveAccess: ops.ExclusiveAccess, + AlternatesFS: ops.AlternatesFS, } dir := dotgit.NewWithOptions(fs, dirOps) @@ -74,3 +79,7 @@ func (s *Storage) Filesystem() billy.Filesystem { func (s *Storage) Init() error { return s.dir.Initialize() } + +func (s *Storage) AddAlternate(remote string) error { + return s.dir.AddAlternate(remote) +} |