diff --git a/config_test.go b/config_test.go index 1e19544..5e31baa 100644 --- a/config_test.go +++ b/config_test.go @@ -108,13 +108,13 @@ func Test_diffWorktrees(t *testing.T) { Remote: "user@host.xz:path/to/repo1.git", Root: "/root", Interval: 10 * time.Second, GitGC: "always", Worktrees: []mirror.WorktreeConfig{ - {Link: "link", Ref: "master", Pathspec: ""}, - {Link: "link2", Ref: "other-branch", Pathspec: "path"}, + {Link: "link", Ref: "master", Pathspecs: nil}, + {Link: "link2", Ref: "other-branch", Pathspecs: []string{"path1", "path2/**/*.yaml", "*.c"}}, }, }, wantNewWTCs: []mirror.WorktreeConfig{ {Link: "link", Ref: "master"}, - {Link: "link2", Ref: "other-branch", Pathspec: "path"}, + {Link: "link2", Ref: "other-branch", Pathspecs: []string{"path1", "path2/**/*.yaml", "*.c"}}, }, wantRemovedWTs: nil, }, @@ -124,23 +124,47 @@ func Test_diffWorktrees(t *testing.T) { Remote: "user@host.xz:path/to/repo1.git", Root: "/root", Interval: 10 * time.Second, GitGC: "always", Worktrees: []mirror.WorktreeConfig{ - {Link: "link", Ref: "master", Pathspec: ""}, - {Link: "link2", Ref: "other-branch", Pathspec: "path"}, + {Link: "link", Ref: "master", Pathspecs: nil}, + {Link: "link2", Ref: "other-branch", Pathspecs: []string{"path1", "path2/**/*.yaml", "*.c"}}, + {Link: "link3", Ref: "other-branch", Pathspecs: []string{"path"}}, }, }, newRepoConf: &mirror.RepositoryConfig{ Remote: "user@host.xz:path/to/repo1.git", Root: "/root", Interval: 10 * time.Second, GitGC: "always", Worktrees: []mirror.WorktreeConfig{ - {Link: "link", Ref: "master", Pathspec: "new-path"}, - {Link: "link2", Ref: "new-branch", Pathspec: "path"}, + {Link: "link", Ref: "master", Pathspecs: []string{"new-path"}}, + {Link: "link2", Ref: "new-branch", Pathspecs: []string{"path1", "path2/**/*.yaml", "*.c"}}, + {Link: "link3", Ref: "other-branch", Pathspecs: []string{"path", "new-path"}}, }, }, wantNewWTCs: []mirror.WorktreeConfig{ - {Link: "link", Ref: "master", Pathspec: "new-path"}, - {Link: "link2", Ref: "new-branch", Pathspec: "path"}, + {Link: "link", Ref: "master", Pathspecs: []string{"new-path"}}, + {Link: "link2", Ref: "new-branch", Pathspecs: []string{"path1", "path2/**/*.yaml", "*.c"}}, + {Link: "link3", Ref: "other-branch", Pathspecs: []string{"path", "new-path"}}, }, - wantRemovedWTs: []string{"link", "link2"}, + wantRemovedWTs: []string{"link", "link2", "link3"}, + }, + { + name: "rearrange-path", + initialRepoConf: &mirror.RepositoryConfig{ + Remote: "user@host.xz:path/to/repo1.git", + Root: "/root", Interval: 10 * time.Second, GitGC: "always", + Worktrees: []mirror.WorktreeConfig{ + {Link: "link", Ref: "master", Pathspecs: []string{"a", "b/**/c"}}, + {Link: "link2", Ref: "other-branch", Pathspecs: []string{"path1", "path2/**/*.yaml", "*.c"}}, + }, + }, + newRepoConf: &mirror.RepositoryConfig{ + Remote: "user@host.xz:path/to/repo1.git", + Root: "/root", Interval: 10 * time.Second, GitGC: "always", + Worktrees: []mirror.WorktreeConfig{ + {Link: "link", Ref: "master", Pathspecs: []string{"b/**/c", "a"}}, + {Link: "link2", Ref: "other-branch", Pathspecs: []string{"path1", "*.c", "path2/**/*.yaml"}}, + }, + }, + wantNewWTCs: nil, + wantRemovedWTs: nil, }, { name: "add_new_link", @@ -148,20 +172,20 @@ func Test_diffWorktrees(t *testing.T) { Remote: "user@host.xz:path/to/repo1.git", Root: "/root", Interval: 10 * time.Second, GitGC: "always", Worktrees: []mirror.WorktreeConfig{ - {Link: "link", Ref: "master", Pathspec: ""}, - {Link: "link2", Ref: "other-branch", Pathspec: "path"}, + {Link: "link", Ref: "master", Pathspecs: nil}, + {Link: "link2", Ref: "other-branch", Pathspecs: []string{"path1", "path2/**/*.yaml", "*.c"}}, }, }, newRepoConf: &mirror.RepositoryConfig{ Remote: "user@host.xz:path/to/repo1.git", Root: "/root", Interval: 10 * time.Second, GitGC: "always", Worktrees: []mirror.WorktreeConfig{ - {Link: "link", Ref: "master", Pathspec: ""}, - {Link: "link3", Ref: "other-branch", Pathspec: "path"}, + {Link: "link", Ref: "master", Pathspecs: nil}, + {Link: "link3", Ref: "other-branch", Pathspecs: []string{"path1", "path2/**/*.yaml", "*.c"}}, }, }, wantNewWTCs: []mirror.WorktreeConfig{ - {Link: "link3", Ref: "other-branch", Pathspec: "path"}, + {Link: "link3", Ref: "other-branch", Pathspecs: []string{"path1", "path2/**/*.yaml", "*.c"}}, }, wantRemovedWTs: []string{"link2"}, }, diff --git a/pkg/mirror/config.go b/pkg/mirror/config.go index d34194f..e92f23e 100644 --- a/pkg/mirror/config.go +++ b/pkg/mirror/config.go @@ -74,8 +74,8 @@ type WorktreeConfig struct { // are supported. default is HEAD Ref string `yaml:"ref"` - // Pathspec of the dirs to checkout if required - Pathspec string `yaml:"pathspec"` + // Pathspecs of the dirs to checkout if required + Pathspecs []string `yaml:"pathspecs"` } // Auth represents authentication config of the repository diff --git a/pkg/mirror/repository.go b/pkg/mirror/repository.go index 0ef92c8..bf5a368 100644 --- a/pkg/mirror/repository.go +++ b/pkg/mirror/repository.go @@ -148,13 +148,16 @@ func (r *Repository) AddWorktreeLink(wtc WorktreeConfig) error { } wt := &WorkTreeLink{ - link: wtc.Link, - linkAbs: linkAbs, - ref: wtc.Ref, - pathspec: wtc.Pathspec, - log: r.log.With("worktree", wtc.Link), + link: wtc.Link, + linkAbs: linkAbs, + ref: wtc.Ref, + pathspecs: wtc.Pathspecs, + log: r.log.With("worktree", wtc.Link), } + // pathspecs must be sorted for for worktree equality checks + slices.Sort(wt.pathspecs) + r.workTreeLinks[wtc.Link] = wt return nil } @@ -363,10 +366,8 @@ func (r *Repository) cloneByRef(ctx context.Context, dst, ref, pathspec string, args := []string{"reset", "--hard", ref} // git reset --hard - if out, err := runGitCommand(ctx, r.log, nil, dst, args...); err != nil { + if _, err := runGitCommand(ctx, r.log, nil, dst, args...); err != nil { return "", err - } else { - fmt.Println(out) } // get the hash of the repos HEAD @@ -708,7 +709,7 @@ func (r *Repository) hash(ctx context.Context, ref, path string) (string, error) // it will remove worktree if tracking ref is removed from the remote func (r *Repository) ensureWorktreeLink(ctx context.Context, wl *WorkTreeLink) error { // get remote hash from mirrored repo for the worktree link - remoteHash, err := r.hash(ctx, wl.ref, wl.pathspec) + remoteHash, err := r.hash(ctx, wl.ref, "") if err != nil { return fmt.Errorf("unable to get hash for worktree:%s err:%w", wl.link, err) } @@ -798,10 +799,11 @@ func (r *Repository) createWorktree(ctx context.Context, wl *WorkTreeLink, hash // only checkout required path if specified args := []string{"checkout", hash} - if wl.pathspec != "" { - args = append(args, "--", wl.pathspec) + if len(wl.pathspecs) > 0 { + args = append(args, "--") + args = append(args, wl.pathspecs...) } - // git checkout -- + // git checkout -- if _, err := runGitCommand(ctx, wl.log, nil, wtPath, args...); err != nil { return "", err } diff --git a/pkg/mirror/repository_test.go b/pkg/mirror/repository_test.go index c9338d5..309aa78 100644 --- a/pkg/mirror/repository_test.go +++ b/pkg/mirror/repository_test.go @@ -139,12 +139,12 @@ func TestRepo_AddWorktreeLink(t *testing.T) { args args wantErr bool }{ - {"all-valid", args{wtc: WorktreeConfig{"link", "master", ""}}, false}, - {"all-valid-with-path", args{wtc: WorktreeConfig{"link2", "other-branch", "path"}}, false}, - {"duplicate-link", args{wtc: WorktreeConfig{"link", "master", ""}}, true}, - {"no-link", args{wtc: WorktreeConfig{"", "master", ""}}, true}, - {"no-ref", args{wtc: WorktreeConfig{"link3", "", ""}}, false}, - {"absLink", args{wtc: WorktreeConfig{"/tmp/link", "tag", ""}}, false}, + {"all-valid", args{wtc: WorktreeConfig{"link", "master", []string{}}}, false}, + {"all-valid-with-paths", args{wtc: WorktreeConfig{"link2", "other-branch", []string{"path1", "path2/**/*.yaml", "*.c"}}}, false}, + {"duplicate-link", args{wtc: WorktreeConfig{"link", "master", []string{}}}, true}, + {"no-link", args{wtc: WorktreeConfig{"", "master", []string{}}}, true}, + {"no-ref", args{wtc: WorktreeConfig{"link3", "", []string{}}}, false}, + {"absLink", args{wtc: WorktreeConfig{"/tmp/link", "tag", []string{}}}, false}, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { @@ -155,10 +155,10 @@ func TestRepo_AddWorktreeLink(t *testing.T) { } // compare all worktree links want := map[string]*WorkTreeLink{ - "link": {link: "link", linkAbs: "/tmp/root/link", ref: "master"}, - "link2": {link: "link2", linkAbs: "/tmp/root/link2", ref: "other-branch", pathspec: "path"}, - "link3": {link: "link3", linkAbs: "/tmp/root/link3", ref: "HEAD"}, - "/tmp/link": {link: "/tmp/link", linkAbs: "/tmp/link", ref: "tag"}, + "link": {link: "link", linkAbs: "/tmp/root/link", ref: "master", pathspecs: []string{}}, + "link2": {link: "link2", linkAbs: "/tmp/root/link2", ref: "other-branch", pathspecs: []string{"*.c", "path1", "path2/**/*.yaml"}}, + "link3": {link: "link3", linkAbs: "/tmp/root/link3", ref: "HEAD", pathspecs: []string{}}, + "/tmp/link": {link: "/tmp/link", linkAbs: "/tmp/link", ref: "tag", pathspecs: []string{}}, } if diff := cmp.Diff(want, r.workTreeLinks, cmpopts.IgnoreFields(WorkTreeLink{}, "log"), cmp.AllowUnexported(WorkTreeLink{})); diff != "" { t.Errorf("Repo.AddWorktreeLink() worktreelinks mismatch (-want +got):\n%s", diff) diff --git a/pkg/mirror/worktree.go b/pkg/mirror/worktree.go index 0ea420e..87e9b00 100644 --- a/pkg/mirror/worktree.go +++ b/pkg/mirror/worktree.go @@ -5,21 +5,25 @@ import ( "fmt" "log/slog" "path/filepath" + "slices" "strings" ) type WorkTreeLink struct { - link string // link name as its specified in config, might not be unique only use it for logging - linkAbs string // the path at which to create a symlink to the worktree dir - ref string // the ref of the worktree - pathspec string // pathspec of the dirs to checkout - log *slog.Logger + link string // link name as its specified in config, might not be unique only use it for logging + linkAbs string // the path at which to create a symlink to the worktree dir + ref string // the ref of the worktree + pathspecs []string // pathspecs of the paths to checkout + log *slog.Logger } func (wt *WorkTreeLink) Equals(wtc WorktreeConfig) bool { + sortedConfigPaths := slices.Clone(wtc.Pathspecs) + slices.Sort(sortedConfigPaths) + return wt.link == wtc.Link && - wt.pathspec == wtc.Pathspec && - wt.ref == wtc.Ref + wt.ref == wtc.Ref && + slices.Compare(wt.pathspecs, sortedConfigPaths) == 0 } // worktreeDirName will generate worktree name for specific worktree link diff --git a/pkg/mirror/z_e2e_race_test.go b/pkg/mirror/z_e2e_race_test.go index f408027..959a8c3 100644 --- a/pkg/mirror/z_e2e_race_test.go +++ b/pkg/mirror/z_e2e_race_test.go @@ -4,7 +4,6 @@ package mirror import ( "context" - "log" "os" "path/filepath" "sync" @@ -31,7 +30,7 @@ func Test_mirror_detect_race(t *testing.T) { repo := mustCreateRepoAndMirror(t, upstream, root, link1, ref1) // add worktree for HEAD - if err := repo.AddWorktreeLink(WorktreeConfig{link2, ref2, ""}); err != nil { + if err := repo.AddWorktreeLink(WorktreeConfig{link2, ref2, []string{}}); err != nil { t.Fatalf("unable to add worktree error: %v", err) } // mirror again for 2nd worktree @@ -51,7 +50,7 @@ func Test_mirror_detect_race(t *testing.T) { t.Log("TEST-2: forward HEAD") fileSHA2 := mustCommit(t, upstream, "file", testName+"-2") - t.Run("test-1", func(t *testing.T) { + t.Run("clone-test", func(t *testing.T) { wg := &sync.WaitGroup{} // all following assertions will always be true // this test is about testing deadlocks and detecting race conditions @@ -60,7 +59,8 @@ func Test_mirror_detect_race(t *testing.T) { go func() { defer wg.Done() if err := repo.Mirror(ctx); err != nil { - log.Fatalf("unable to mirror error: %v", err) + t.Error("unable to mirror", "err", err) + os.Exit(1) } assertLinkedFile(t, root, link1, "file", testName+"-2") @@ -81,7 +81,8 @@ func Test_mirror_detect_race(t *testing.T) { go func() { defer wg.Done() if err := repo.Mirror(ctx); err != nil { - log.Fatalf("unable to mirror error: %v", err) + t.Error("unable to mirror error", "err", err) + os.Exit(1) } }() diff --git a/pkg/mirror/z_e2e_test.go b/pkg/mirror/z_e2e_test.go index 7d76d6a..729087b 100644 --- a/pkg/mirror/z_e2e_test.go +++ b/pkg/mirror/z_e2e_test.go @@ -184,7 +184,7 @@ func Test_mirror_head_and_main(t *testing.T) { repo := mustCreateRepoAndMirror(t, upstream, root, link1, ref1) // add worktree for HEAD - if err := repo.AddWorktreeLink(WorktreeConfig{link2, ref2, ""}); err != nil { + if err := repo.AddWorktreeLink(WorktreeConfig{link2, ref2, []string{}}); err != nil { t.Fatalf("unable to add worktree error: %v", err) } // mirror again for 2nd worktree @@ -282,7 +282,7 @@ func Test_mirror_other_branch(t *testing.T) { repo := mustCreateRepoAndMirror(t, upstream, root, link1, ref1) // add 2nd worktree - if err := repo.AddWorktreeLink(WorktreeConfig{link2, ref2, ""}); err != nil { + if err := repo.AddWorktreeLink(WorktreeConfig{link2, ref2, []string{}}); err != nil { t.Fatalf("unable to add worktree error: %v", err) } // mirror again for 2nd worktree @@ -343,6 +343,7 @@ func Test_mirror_with_pathspec(t *testing.T) { link1 := "link1" // on testBranchMain branch link2 := "link2" // on remote HEAD -- dir2 link3 := "link3" // on remote HEAD -- dir3 + link4 := "link4" // on remote HEAD -- dir2 dir3 ref1 := testMainBranch ref2 := "HEAD" ref3 := "HEAD" @@ -354,13 +355,7 @@ func Test_mirror_with_pathspec(t *testing.T) { firstSHA := mustInitRepo(t, upstream, "file", t.Name()+"-main-1") repo := mustCreateRepoAndMirror(t, upstream, root, link1, ref1) - // add worktree for HEAD - if err := repo.AddWorktreeLink(WorktreeConfig{link2, ref2, pathSpec2}); err != nil { - t.Fatalf("unable to add worktree error: %v", err) - } - if err := repo.AddWorktreeLink(WorktreeConfig{link3, ref3, pathSpec3}); err != nil { - t.Fatalf("unable to add worktree error: %v", err) - } + // mirror again for 2nd worktree if err := repo.Mirror(txtCtx); err != nil { t.Fatalf("unable to mirror error: %v", err) @@ -375,6 +370,15 @@ func Test_mirror_with_pathspec(t *testing.T) { mustCommit(t, upstream, "file", t.Name()+"-main-2") mustCommit(t, upstream, filepath.Join("dir2", "file"), t.Name()+"-main-2") + + // add worktree for HEAD on dir2 + if err := repo.AddWorktreeLink(WorktreeConfig{link2, ref2, []string{pathSpec2}}); err != nil { + t.Fatalf("unable to add worktree error: %v", err) + } + if err := repo.AddWorktreeLink(WorktreeConfig{link4, ref2, []string{pathSpec2}}); err != nil { + t.Fatalf("unable to add worktree error: %v", err) + } + // mirror new commits if err := repo.Mirror(txtCtx); err != nil { t.Fatalf("unable to mirror error: %v", err) @@ -388,11 +392,26 @@ func Test_mirror_with_pathspec(t *testing.T) { assertMissingLinkFile(t, root, link3, "file") assertMissingLinkFile(t, root, link3, filepath.Join("dir2", "file")) + assertMissingLinkFile(t, root, link4, "file") + assertLinkedFile(t, root, link4, filepath.Join("dir2", "file"), t.Name()+"-main-2") + t.Log("TEST-3: forward HEAD and create dir3 to test link3") mustCommit(t, upstream, "file", t.Name()+"-main-3") mustCommit(t, upstream, filepath.Join("dir2", "file"), t.Name()+"-main-3") mustCommit(t, upstream, filepath.Join("dir3", "file"), t.Name()+"-main-3") + + // add worktree for HEAD on dir3 + if err := repo.AddWorktreeLink(WorktreeConfig{link3, ref3, []string{pathSpec3}}); err != nil { + t.Fatalf("unable to add worktree error: %v", err) + } + // update worktree link4 + if err := repo.RemoveWorktreeLink(link4); err != nil { + t.Fatalf("unable to add worktree error: %v", err) + } + if err := repo.AddWorktreeLink(WorktreeConfig{link4, ref2, []string{pathSpec3, pathSpec2}}); err != nil { + t.Fatalf("unable to add worktree error: %v", err) + } // mirror new commits if err := repo.Mirror(txtCtx); err != nil { t.Fatalf("unable to mirror error: %v", err) @@ -409,9 +428,25 @@ func Test_mirror_with_pathspec(t *testing.T) { assertMissingLinkFile(t, root, link3, filepath.Join("dir2", "file")) assertLinkedFile(t, root, link3, filepath.Join("dir3", "file"), t.Name()+"-main-3") + assertMissingLinkFile(t, root, link4, "file") + assertLinkedFile(t, root, link4, filepath.Join("dir2", "file"), t.Name()+"-main-3") + assertLinkedFile(t, root, link4, filepath.Join("dir3", "file"), t.Name()+"-main-3") + t.Log("TEST-3: move HEAD backward by 3 commit to original state") mustExec(t, upstream, "git", "reset", "-q", "--hard", firstSHA) + + // remove worktrees with pathspec which doesn't exit + if err := repo.RemoveWorktreeLink(link2); err != nil { + t.Fatalf("unable to add worktree error: %v", err) + } + if err := repo.RemoveWorktreeLink(link3); err != nil { + t.Fatalf("unable to add worktree error: %v", err) + } + if err := repo.RemoveWorktreeLink(link4); err != nil { + t.Fatalf("unable to add worktree error: %v", err) + } + // mirror new commits if err := repo.Mirror(txtCtx); err != nil { t.Fatalf("unable to mirror error: %v", err) @@ -448,7 +483,7 @@ func Test_mirror_switch_branch_after_restart(t *testing.T) { repo1 := mustCreateRepoAndMirror(t, upstream, root, link1, ref1) // add 2nd worktree - if err := repo1.AddWorktreeLink(WorktreeConfig{link2, ref2, ""}); err != nil { + if err := repo1.AddWorktreeLink(WorktreeConfig{link2, ref2, []string{}}); err != nil { t.Fatalf("unable to add worktree error: %v", err) } // mirror again for 2nd worktree @@ -464,7 +499,7 @@ func Test_mirror_switch_branch_after_restart(t *testing.T) { repo2 := mustCreateRepoAndMirror(t, upstream, root, link1, ref2) // add 2nd worktree - if err := repo2.AddWorktreeLink(WorktreeConfig{link2, ref1, ""}); err != nil { + if err := repo2.AddWorktreeLink(WorktreeConfig{link2, ref1, []string{}}); err != nil { t.Fatalf("unable to add worktree error: %v", err) } // mirror again for 2nd worktree @@ -524,7 +559,7 @@ func Test_mirror_tag_sha(t *testing.T) { repo := mustCreateRepoAndMirror(t, upstream, root, link1, ref1) // add 2nd worktree - if err := repo.AddWorktreeLink(WorktreeConfig{link2, ref2, ""}); err != nil { + if err := repo.AddWorktreeLink(WorktreeConfig{link2, ref2, []string{}}); err != nil { t.Fatalf("unable to add worktree error: %v", err) } // mirror again for 2nd worktree @@ -1251,7 +1286,7 @@ func Test_mirror_loop(t *testing.T) { repo := mustCreateRepoAndMirror(t, upstream, root, link1, ref1) // add worktree for HEAD - if err := repo.AddWorktreeLink(WorktreeConfig{link2, ref2, ""}); err != nil { + if err := repo.AddWorktreeLink(WorktreeConfig{link2, ref2, []string{}}); err != nil { t.Fatalf("unable to add worktree error: %v", err) } @@ -1341,7 +1376,7 @@ func Test_RepoPool_Success(t *testing.T) { // add worktree // we will verify this worktree in next mirror loop - if err := rp.AddWorktreeLink(remote1, WorktreeConfig{"link3", "", ""}); err != nil { + if err := rp.AddWorktreeLink(remote1, WorktreeConfig{"link3", "", []string{}}); err != nil { t.Fatalf("unexpected err:%s", err) } @@ -1612,7 +1647,7 @@ func mustCreateRepoAndMirror(t *testing.T, upstream, root, link, ref string) *Re t.Fatalf("unable to create new repo error: %v", err) } if link != "" { - if err := repo.AddWorktreeLink(WorktreeConfig{link, ref, ""}); err != nil { + if err := repo.AddWorktreeLink(WorktreeConfig{link, ref, []string{}}); err != nil { t.Fatalf("unable to add worktree error: %v", err) } } diff --git a/readme.md b/readme.md index a3a8d78..aa19cc8 100644 --- a/readme.md +++ b/readme.md @@ -78,13 +78,16 @@ repositories: # ref represents the git reference of the worktree branch, tags or hash # are supported. default is HEAD - pathspec: example - # pathspec of the dirs to checkout if required, optional if omitted - # whole repo will be checked out + pathspecs: + - path + - path2/*.yaml + # pathspecs is the pattern used to checkout paths in Git commands. + # its optional, if omitted whole repo will be checked out ``` +For more details about `pathspecs`, see [git glossary](https://git-scm.com/docs/gitglossary#Documentation/gitglossary.txt-aiddefpathspecapathspec) App can load changes in config without restart. At repository level only adding and removing repository is supported. changes in interval, timeout and auth will require an app restart. At worktree level apart from adding or removing, changes in existing worktree's -link, ref and pathspec is supported. \ No newline at end of file +link, ref and pathspecs is supported. \ No newline at end of file