Skip to content

Commit cec7da6

Browse files
authored
Merge pull request #953 from pjbgf/alternates
storage: filesystem, Add option to set a specific FS for alternates
2 parents 4f61489 + 8b47ceb commit cec7da6

File tree

6 files changed

+203
-64
lines changed

6 files changed

+203
-64
lines changed

go.mod

+4
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@ require (
2020
github.com/pjbgf/sha1cd v0.3.0
2121
github.com/sergi/go-diff v1.1.0
2222
github.com/skeema/knownhosts v1.2.1
23+
github.com/stretchr/testify v1.8.4
2324
github.com/xanzy/ssh-agent v0.3.3
2425
golang.org/x/crypto v0.16.0
2526
golang.org/x/net v0.19.0
@@ -33,10 +34,13 @@ require (
3334
github.com/anmitsu/go-shlex v0.0.0-20200514113438-38f4b401e2be // indirect
3435
github.com/cloudflare/circl v1.3.3 // indirect
3536
github.com/cyphar/filepath-securejoin v0.2.4 // indirect
37+
github.com/davecgh/go-spew v1.1.1 // indirect
3638
github.com/kr/pretty v0.3.1 // indirect
3739
github.com/kr/text v0.2.0 // indirect
40+
github.com/pmezard/go-difflib v1.0.0 // indirect
3841
github.com/rogpeppe/go-internal v1.11.0 // indirect
3942
golang.org/x/mod v0.12.0 // indirect
4043
golang.org/x/tools v0.13.0 // indirect
4144
gopkg.in/warnings.v0 v0.1.2 // indirect
45+
gopkg.in/yaml.v3 v3.0.1 // indirect
4246
)

go.sum

+1
Original file line numberDiff line numberDiff line change
@@ -69,6 +69,7 @@ github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+
6969
github.com/stretchr/testify v1.2.2/go.mod h1:a8OnRcib4nhh0OaRAV+Yts87kKdq0PP7pXfy6kDkUVs=
7070
github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4=
7171
github.com/stretchr/testify v1.8.4 h1:CcVxjf3Q8PM0mHUKJCdn+eZZtm5yQwehR5yeSVQQcUk=
72+
github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo=
7273
github.com/xanzy/ssh-agent v0.3.3 h1:+/15pJfg/RsTxqYcX6fHqOXZwwMP+2VyYWJeWM2qQFM=
7374
github.com/xanzy/ssh-agent v0.3.3/go.mod h1:6dzNDKs0J9rVPHPhaGCukekBHKqfl+L3KghI1Bc68Uw=
7475
github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY=

storage/filesystem/dotgit/dotgit.go

+48-16
Original file line numberDiff line numberDiff line change
@@ -10,18 +10,19 @@ import (
1010
"os"
1111
"path"
1212
"path/filepath"
13+
"reflect"
1314
"runtime"
1415
"sort"
1516
"strings"
1617
"time"
1718

18-
"github.com/go-git/go-billy/v5/osfs"
1919
"github.com/go-git/go-git/v5/plumbing"
2020
"github.com/go-git/go-git/v5/plumbing/hash"
2121
"github.com/go-git/go-git/v5/storage"
2222
"github.com/go-git/go-git/v5/utils/ioutil"
2323

2424
"github.com/go-git/go-billy/v5"
25+
"github.com/go-git/go-billy/v5/helper/chroot"
2526
)
2627

2728
const (
@@ -81,6 +82,10 @@ type Options struct {
8182
// KeepDescriptors makes the file descriptors to be reused but they will
8283
// need to be manually closed calling Close().
8384
KeepDescriptors bool
85+
// AlternatesFS provides the billy filesystem to be used for Git Alternates.
86+
// If none is provided, it falls back to using the underlying instance used for
87+
// DotGit.
88+
AlternatesFS billy.Filesystem
8489
}
8590

8691
// The DotGit type represents a local git repository on disk. This
@@ -1146,28 +1151,55 @@ func (d *DotGit) Alternates() ([]*DotGit, error) {
11461151
}
11471152
defer f.Close()
11481153

1154+
fs := d.options.AlternatesFS
1155+
if fs == nil {
1156+
fs = d.fs
1157+
}
1158+
11491159
var alternates []*DotGit
1160+
seen := make(map[string]struct{})
11501161

11511162
// Read alternate paths line-by-line and create DotGit objects.
11521163
scanner := bufio.NewScanner(f)
11531164
for scanner.Scan() {
11541165
path := scanner.Text()
1155-
if !filepath.IsAbs(path) {
1156-
// For relative paths, we can perform an internal conversion to
1157-
// slash so that they work cross-platform.
1158-
slashPath := filepath.ToSlash(path)
1159-
// If the path is not absolute, it must be relative to object
1160-
// database (.git/objects/info).
1161-
// https://www.kernel.org/pub/software/scm/git/docs/gitrepository-layout.html
1162-
// Hence, derive a path relative to DotGit's root.
1163-
// "../../../reponame/.git/" -> "../../reponame/.git"
1164-
// Remove the first ../
1165-
relpath := filepath.Join(strings.Split(slashPath, "/")[1:]...)
1166-
normalPath := filepath.FromSlash(relpath)
1167-
path = filepath.Join(d.fs.Root(), normalPath)
1166+
1167+
// Avoid creating multiple dotgits for the same alternative path.
1168+
if _, ok := seen[path]; ok {
1169+
continue
1170+
}
1171+
1172+
seen[path] = struct{}{}
1173+
1174+
if filepath.IsAbs(path) {
1175+
// Handling absolute paths should be straight-forward. However, the default osfs (Chroot)
1176+
// tries to concatenate an abs path with the root path in some operations (e.g. Stat),
1177+
// which leads to unexpected errors. Therefore, make the path relative to the current FS instead.
1178+
if reflect.TypeOf(fs) == reflect.TypeOf(&chroot.ChrootHelper{}) {
1179+
path, err = filepath.Rel(fs.Root(), path)
1180+
if err != nil {
1181+
return nil, fmt.Errorf("cannot make path %q relative: %w", path, err)
1182+
}
1183+
}
1184+
} else {
1185+
// By Git conventions, relative paths should be based on the object database (.git/objects/info)
1186+
// location as per: https://www.kernel.org/pub/software/scm/git/docs/gitrepository-layout.html
1187+
// However, due to the nature of go-git and its filesystem handling via Billy, paths cannot
1188+
// cross its "chroot boundaries". Therefore, ignore any "../" and treat the path from the
1189+
// fs root. If this is not correct based on the dotgit fs, set a different one via AlternatesFS.
1190+
abs := filepath.Join(string(filepath.Separator), filepath.ToSlash(path))
1191+
path = filepath.FromSlash(abs)
1192+
}
1193+
1194+
// Aligns with upstream behavior: exit if target path is not a valid directory.
1195+
if fi, err := fs.Stat(path); err != nil || !fi.IsDir() {
1196+
return nil, fmt.Errorf("invalid object directory %q: %w", path, err)
1197+
}
1198+
afs, err := fs.Chroot(filepath.Dir(path))
1199+
if err != nil {
1200+
return nil, fmt.Errorf("cannot chroot %q: %w", path, err)
11681201
}
1169-
fs := osfs.New(filepath.Dir(path))
1170-
alternates = append(alternates, New(fs))
1202+
alternates = append(alternates, New(afs))
11711203
}
11721204

11731205
if err = scanner.Err(); err != nil {

storage/filesystem/dotgit/dotgit_test.go

+125-37
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ import (
66
"io"
77
"os"
88
"path/filepath"
9+
"regexp"
910
"runtime"
1011
"strings"
1112
"testing"
@@ -15,6 +16,7 @@ import (
1516
"github.com/go-git/go-billy/v5/util"
1617
fixtures "github.com/go-git/go-git-fixtures/v4"
1718
"github.com/go-git/go-git/v5/plumbing"
19+
"github.com/stretchr/testify/assert"
1820
. "gopkg.in/check.v1"
1921
)
2022

@@ -810,53 +812,139 @@ func (s *SuiteDotGit) TestPackRefs(c *C) {
810812
c.Assert(ref.Hash().String(), Equals, "b8d3ffab552895c19b9fcf7aa264d277cde33881")
811813
}
812814

813-
func (s *SuiteDotGit) TestAlternates(c *C) {
814-
fs, clean := s.TemporalFilesystem()
815-
defer clean()
815+
func TestAlternatesDefault(t *testing.T) {
816+
// Create a new dotgit object.
817+
dotFS := osfs.New(t.TempDir())
816818

817-
// Create a new dotgit object and initialize.
818-
dir := New(fs)
819-
err := dir.Initialize()
820-
c.Assert(err, IsNil)
819+
testAlternates(t, dotFS, dotFS)
820+
}
821821

822-
// Create alternates file.
823-
altpath := fs.Join("objects", "info", "alternates")
824-
f, err := fs.Create(altpath)
825-
c.Assert(err, IsNil)
822+
func TestAlternatesWithFS(t *testing.T) {
823+
// Create a new dotgit object with a specific FS for alternates.
824+
altFS := osfs.New(t.TempDir())
825+
dotFS, _ := altFS.Chroot("repo2")
826826

827-
// Multiple alternates.
828-
var strContent string
829-
if runtime.GOOS == "windows" {
830-
strContent = "C:\\Users\\username\\repo1\\.git\\objects\r\n..\\..\\..\\rep2\\.git\\objects"
831-
} else {
832-
strContent = "/Users/username/rep1//.git/objects\n../../../rep2//.git/objects"
827+
testAlternates(t, dotFS, altFS)
828+
}
829+
830+
func TestAlternatesWithBoundOS(t *testing.T) {
831+
// Create a new dotgit object with a specific FS for alternates.
832+
altFS := osfs.New(t.TempDir(), osfs.WithBoundOS())
833+
dotFS, _ := altFS.Chroot("repo2")
834+
835+
testAlternates(t, dotFS, altFS)
836+
}
837+
838+
func testAlternates(t *testing.T, dotFS, altFS billy.Filesystem) {
839+
tests := []struct {
840+
name string
841+
in []string
842+
inWindows []string
843+
setup func()
844+
wantErr bool
845+
wantRoots []string
846+
}{
847+
{
848+
name: "no alternates",
849+
},
850+
{
851+
name: "abs path",
852+
in: []string{filepath.Join(altFS.Root(), "./repo1/.git/objects")},
853+
inWindows: []string{filepath.Join(altFS.Root(), ".\\repo1\\.git\\objects")},
854+
setup: func() {
855+
err := altFS.MkdirAll(filepath.Join("repo1", ".git", "objects"), 0o700)
856+
assert.NoError(t, err)
857+
},
858+
wantRoots: []string{filepath.Join("repo1", ".git")},
859+
},
860+
{
861+
name: "rel path",
862+
in: []string{"../../../repo3//.git/objects"},
863+
inWindows: []string{"..\\..\\..\\repo3\\.git\\objects"},
864+
setup: func() {
865+
err := altFS.MkdirAll(filepath.Join("repo3", ".git", "objects"), 0o700)
866+
assert.NoError(t, err)
867+
},
868+
wantRoots: []string{filepath.Join("repo3", ".git")},
869+
},
870+
{
871+
name: "invalid abs path",
872+
in: []string{"/alt/target2"},
873+
inWindows: []string{"\\alt\\target2"},
874+
wantErr: true,
875+
},
876+
{
877+
name: "invalid rel path",
878+
in: []string{"../../../alt/target3"},
879+
inWindows: []string{"..\\..\\..\\alt\\target3"},
880+
wantErr: true,
881+
},
833882
}
834-
content := []byte(strContent)
835-
f.Write(content)
836-
f.Close()
837883

838-
dotgits, err := dir.Alternates()
839-
c.Assert(err, IsNil)
840-
if runtime.GOOS == "windows" {
841-
c.Assert(dotgits[0].fs.Root(), Equals, "C:\\Users\\username\\repo1\\.git")
842-
} else {
843-
c.Assert(dotgits[0].fs.Root(), Equals, "/Users/username/rep1/.git")
884+
for _, tc := range tests {
885+
t.Run(tc.name, func(t *testing.T) {
886+
dir := NewWithOptions(dotFS, Options{AlternatesFS: altFS})
887+
err := dir.Initialize()
888+
assert.NoError(t, err)
889+
890+
content := strings.Join(tc.in, "\n")
891+
if runtime.GOOS == "windows" {
892+
content = strings.Join(tc.inWindows, "\r\n")
893+
}
894+
895+
// Create alternates file.
896+
altpath := dotFS.Join("objects", "info", "alternates")
897+
f, err := dotFS.Create(altpath)
898+
assert.NoError(t, err)
899+
f.Write([]byte(content))
900+
f.Close()
901+
902+
if tc.setup != nil {
903+
tc.setup()
904+
}
905+
906+
dotgits, err := dir.Alternates()
907+
if tc.wantErr {
908+
assert.Error(t, err)
909+
} else {
910+
assert.NoError(t, err)
911+
}
912+
913+
for i, d := range dotgits {
914+
assert.Regexp(t, "^"+regexp.QuoteMeta(altFS.Root()), d.fs.Root())
915+
assert.Regexp(t, regexp.QuoteMeta(tc.wantRoots[i])+"$", d.fs.Root())
916+
}
917+
})
844918
}
919+
}
845920

846-
// For relative path:
847-
// /some/absolute/path/to/dot-git -> /some/absolute/path
848-
pathx := strings.Split(fs.Root(), string(filepath.Separator))
849-
pathx = pathx[:len(pathx)-2]
850-
// Use string.Join() to avoid malformed absolutepath on windows
851-
// C:Users\\User\\... instead of C:\\Users\\appveyor\\... .
852-
resolvedPath := strings.Join(pathx, string(filepath.Separator))
853-
// Append the alternate path to the resolvedPath
854-
expectedPath := fs.Join(string(filepath.Separator), resolvedPath, "rep2", ".git")
921+
func TestAlternatesDupes(t *testing.T) {
922+
dotFS := osfs.New(t.TempDir())
923+
dir := New(dotFS)
924+
err := dir.Initialize()
925+
assert.NoError(t, err)
926+
927+
path := filepath.Join(dotFS.Root(), "target3")
928+
dupes := []string{path, path, path, path, path}
929+
930+
content := strings.Join(dupes, "\n")
855931
if runtime.GOOS == "windows" {
856-
expectedPath = fs.Join(resolvedPath, "rep2", ".git")
932+
content = strings.Join(dupes, "\r\n")
857933
}
858934

859-
c.Assert(dotgits[1].fs.Root(), Equals, expectedPath)
935+
err = dotFS.MkdirAll("target3", 0o700)
936+
assert.NoError(t, err)
937+
938+
// Create alternates file.
939+
altpath := dotFS.Join("objects", "info", "alternates")
940+
f, err := dotFS.Create(altpath)
941+
assert.NoError(t, err)
942+
f.Write([]byte(content))
943+
f.Close()
944+
945+
dotgits, err := dir.Alternates()
946+
assert.NoError(t, err)
947+
assert.Len(t, dotgits, 1)
860948
}
861949

862950
type norwfs struct {

storage/filesystem/storage.go

+5
Original file line numberDiff line numberDiff line change
@@ -37,6 +37,10 @@ type Options struct {
3737
// LargeObjectThreshold maximum object size (in bytes) that will be read in to memory.
3838
// If left unset or set to 0 there is no limit
3939
LargeObjectThreshold int64
40+
// AlternatesFS provides the billy filesystem to be used for Git Alternates.
41+
// If none is provided, it falls back to using the underlying instance used for
42+
// DotGit.
43+
AlternatesFS billy.Filesystem
4044
}
4145

4246
// 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 {
4953
func NewStorageWithOptions(fs billy.Filesystem, cache cache.Object, ops Options) *Storage {
5054
dirOps := dotgit.Options{
5155
ExclusiveAccess: ops.ExclusiveAccess,
56+
AlternatesFS: ops.AlternatesFS,
5257
}
5358
dir := dotgit.NewWithOptions(fs, dirOps)
5459

0 commit comments

Comments
 (0)