fork of go-git with some jj specific features

Merge pull request #953 from pjbgf/alternates

storage: filesystem, Add option to set a specific FS for alternates

authored by Paulo Gomes and committed by GitHub cec7da63 4f614891

Changed files
+203 -64
storage
filesystem
+4
go.mod
··· 20 20 github.com/pjbgf/sha1cd v0.3.0 21 21 github.com/sergi/go-diff v1.1.0 22 22 github.com/skeema/knownhosts v1.2.1 23 + github.com/stretchr/testify v1.8.4 23 24 github.com/xanzy/ssh-agent v0.3.3 24 25 golang.org/x/crypto v0.16.0 25 26 golang.org/x/net v0.19.0 ··· 33 34 github.com/anmitsu/go-shlex v0.0.0-20200514113438-38f4b401e2be // indirect 34 35 github.com/cloudflare/circl v1.3.3 // indirect 35 36 github.com/cyphar/filepath-securejoin v0.2.4 // indirect 37 + github.com/davecgh/go-spew v1.1.1 // indirect 36 38 github.com/kr/pretty v0.3.1 // indirect 37 39 github.com/kr/text v0.2.0 // indirect 40 + github.com/pmezard/go-difflib v1.0.0 // indirect 38 41 github.com/rogpeppe/go-internal v1.11.0 // indirect 39 42 golang.org/x/mod v0.12.0 // indirect 40 43 golang.org/x/tools v0.13.0 // indirect 41 44 gopkg.in/warnings.v0 v0.1.2 // indirect 45 + gopkg.in/yaml.v3 v3.0.1 // indirect 42 46 )
+1
go.sum
··· 69 69 github.com/stretchr/testify v1.2.2/go.mod h1:a8OnRcib4nhh0OaRAV+Yts87kKdq0PP7pXfy6kDkUVs= 70 70 github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4= 71 71 github.com/stretchr/testify v1.8.4 h1:CcVxjf3Q8PM0mHUKJCdn+eZZtm5yQwehR5yeSVQQcUk= 72 + github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo= 72 73 github.com/xanzy/ssh-agent v0.3.3 h1:+/15pJfg/RsTxqYcX6fHqOXZwwMP+2VyYWJeWM2qQFM= 73 74 github.com/xanzy/ssh-agent v0.3.3/go.mod h1:6dzNDKs0J9rVPHPhaGCukekBHKqfl+L3KghI1Bc68Uw= 74 75 github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY=
+48 -16
storage/filesystem/dotgit/dotgit.go
··· 10 10 "os" 11 11 "path" 12 12 "path/filepath" 13 + "reflect" 13 14 "runtime" 14 15 "sort" 15 16 "strings" 16 17 "time" 17 18 18 - "github.com/go-git/go-billy/v5/osfs" 19 19 "github.com/go-git/go-git/v5/plumbing" 20 20 "github.com/go-git/go-git/v5/plumbing/hash" 21 21 "github.com/go-git/go-git/v5/storage" 22 22 "github.com/go-git/go-git/v5/utils/ioutil" 23 23 24 24 "github.com/go-git/go-billy/v5" 25 + "github.com/go-git/go-billy/v5/helper/chroot" 25 26 ) 26 27 27 28 const ( ··· 81 82 // KeepDescriptors makes the file descriptors to be reused but they will 82 83 // need to be manually closed calling Close(). 83 84 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 84 89 } 85 90 86 91 // The DotGit type represents a local git repository on disk. This ··· 1146 1151 } 1147 1152 defer f.Close() 1148 1153 1154 + fs := d.options.AlternatesFS 1155 + if fs == nil { 1156 + fs = d.fs 1157 + } 1158 + 1149 1159 var alternates []*DotGit 1160 + seen := make(map[string]struct{}) 1150 1161 1151 1162 // Read alternate paths line-by-line and create DotGit objects. 1152 1163 scanner := bufio.NewScanner(f) 1153 1164 for scanner.Scan() { 1154 1165 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) 1168 1192 } 1169 - fs := osfs.New(filepath.Dir(path)) 1170 - alternates = append(alternates, New(fs)) 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) 1201 + } 1202 + alternates = append(alternates, New(afs)) 1171 1203 } 1172 1204 1173 1205 if err = scanner.Err(); err != nil {
+125 -37
storage/filesystem/dotgit/dotgit_test.go
··· 6 6 "io" 7 7 "os" 8 8 "path/filepath" 9 + "regexp" 9 10 "runtime" 10 11 "strings" 11 12 "testing" ··· 15 16 "github.com/go-git/go-billy/v5/util" 16 17 fixtures "github.com/go-git/go-git-fixtures/v4" 17 18 "github.com/go-git/go-git/v5/plumbing" 19 + "github.com/stretchr/testify/assert" 18 20 . "gopkg.in/check.v1" 19 21 ) 20 22 ··· 810 812 c.Assert(ref.Hash().String(), Equals, "b8d3ffab552895c19b9fcf7aa264d277cde33881") 811 813 } 812 814 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()) 816 818 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 + } 821 821 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") 826 826 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 + }, 833 882 } 834 - content := []byte(strContent) 835 - f.Write(content) 836 - f.Close() 837 883 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 + }) 844 918 } 919 + } 845 920 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") 855 931 if runtime.GOOS == "windows" { 856 - expectedPath = fs.Join(resolvedPath, "rep2", ".git") 932 + content = strings.Join(dupes, "\r\n") 857 933 } 858 934 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) 860 948 } 861 949 862 950 type norwfs struct {
+5
storage/filesystem/storage.go
··· 37 37 // LargeObjectThreshold maximum object size (in bytes) that will be read in to memory. 38 38 // If left unset or set to 0 there is no limit 39 39 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 40 44 } 41 45 42 46 // NewStorage returns a new Storage backed by a given `fs.Filesystem` and cache. ··· 49 53 func NewStorageWithOptions(fs billy.Filesystem, cache cache.Object, ops Options) *Storage { 50 54 dirOps := dotgit.Options{ 51 55 ExclusiveAccess: ops.ExclusiveAccess, 56 + AlternatesFS: ops.AlternatesFS, 52 57 } 53 58 dir := dotgit.NewWithOptions(fs, dirOps) 54 59
+20 -11
worktree_test.go
··· 16 16 fixtures "github.com/go-git/go-git-fixtures/v4" 17 17 "github.com/go-git/go-git/v5/config" 18 18 "github.com/go-git/go-git/v5/plumbing" 19 + "github.com/go-git/go-git/v5/plumbing/cache" 19 20 "github.com/go-git/go-git/v5/plumbing/filemode" 20 21 "github.com/go-git/go-git/v5/plumbing/format/gitignore" 21 22 "github.com/go-git/go-git/v5/plumbing/format/index" 22 23 "github.com/go-git/go-git/v5/plumbing/object" 24 + "github.com/go-git/go-git/v5/storage/filesystem" 23 25 "github.com/go-git/go-git/v5/storage/memory" 26 + "github.com/stretchr/testify/assert" 24 27 25 28 "github.com/go-git/go-billy/v5/memfs" 26 29 "github.com/go-git/go-billy/v5/osfs" ··· 2198 2201 c.Assert(err, IsNil) 2199 2202 } 2200 2203 2201 - func (s *WorktreeSuite) TestAlternatesRepo(c *C) { 2204 + func TestAlternatesRepo(t *testing.T) { 2202 2205 fs := fixtures.ByTag("alternates").One().Worktree() 2203 2206 2204 2207 // Open 1st repo. 2205 2208 rep1fs, err := fs.Chroot("rep1") 2206 - c.Assert(err, IsNil) 2209 + assert.NoError(t, err) 2207 2210 rep1, err := PlainOpen(rep1fs.Root()) 2208 - c.Assert(err, IsNil) 2211 + assert.NoError(t, err) 2209 2212 2210 2213 // Open 2nd repo. 2211 2214 rep2fs, err := fs.Chroot("rep2") 2212 - c.Assert(err, IsNil) 2213 - rep2, err := PlainOpen(rep2fs.Root()) 2214 - c.Assert(err, IsNil) 2215 + assert.NoError(t, err) 2216 + d, _ := rep2fs.Chroot(GitDirName) 2217 + storer := filesystem.NewStorageWithOptions(d, 2218 + cache.NewObjectLRUDefault(), filesystem.Options{ 2219 + AlternatesFS: fs, 2220 + }) 2221 + rep2, err := Open(storer, rep2fs) 2222 + 2223 + assert.NoError(t, err) 2215 2224 2216 2225 // Get the HEAD commit from the main repo. 2217 2226 h, err := rep1.Head() 2218 - c.Assert(err, IsNil) 2227 + assert.NoError(t, err) 2219 2228 commit1, err := rep1.CommitObject(h.Hash()) 2220 - c.Assert(err, IsNil) 2229 + assert.NoError(t, err) 2221 2230 2222 2231 // Get the HEAD commit from the shared repo. 2223 2232 h, err = rep2.Head() 2224 - c.Assert(err, IsNil) 2233 + assert.NoError(t, err) 2225 2234 commit2, err := rep2.CommitObject(h.Hash()) 2226 - c.Assert(err, IsNil) 2235 + assert.NoError(t, err) 2227 2236 2228 - c.Assert(commit1.String(), Equals, commit2.String()) 2237 + assert.Equal(t, commit1.String(), commit2.String()) 2229 2238 } 2230 2239 2231 2240 func (s *WorktreeSuite) TestGrep(c *C) {