Skip to content

Commit 82bf182

Browse files
authored
fix: preserve private mirror branches (#30)
* fix: preserve private mirror branches * fix: clone requested mirror branch * test: make mirror checks portable
1 parent 3b290d3 commit 82bf182

3 files changed

Lines changed: 196 additions & 3 deletions

File tree

CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@
33
## Unreleased
44

55
- Update Go to 1.26.4 for current standard-library security fixes and add race and vulnerability CI gates.
6+
- Preserve unpublished local mirror branches during pulls, clone the requested remote branch, and support caller-selected private repository directory modes.
67
- Allow managed sidecar trees to prune selected generated files while preserving unrelated files.
78
- Add non-mutating archive tag validation and rebase-based write synchronization for durable multi-machine backup retries.
89
- Add reusable Git snapshot history/tag/ref restoration, SQLite bundle snapshots, managed snapshot sidecars, mapped sync-state adapters, FTS5 helpers, and the shared contact-export contract; refresh stable dependencies.

mirror/mirror.go

Lines changed: 44 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,8 @@ type Options struct {
1515
Remote string
1616
Branch string
1717
Git string
18+
// DirMode enforces repository-root permissions on POSIX filesystems when nonzero.
19+
DirMode os.FileMode
1820
}
1921

2022
func EnsureRepo(ctx context.Context, opts Options) error {
@@ -23,29 +25,48 @@ func EnsureRepo(ctx context.Context, opts Options) error {
2325
return errors.New("repo path is required")
2426
}
2527
if _, err := os.Stat(filepath.Join(opts.RepoPath, ".git")); err == nil {
28+
if opts.DirMode != 0 {
29+
if err := os.Chmod(opts.RepoPath, opts.DirMode); err != nil {
30+
return fmt.Errorf("secure repo path: %w", err)
31+
}
32+
}
2633
if opts.Remote != "" {
2734
return setOrigin(ctx, opts)
2835
}
2936
return nil
3037
}
3138
if opts.Remote != "" {
32-
if err := os.MkdirAll(filepath.Dir(opts.RepoPath), 0o755); err != nil {
39+
if err := os.MkdirAll(filepath.Dir(opts.RepoPath), creationDirMode(opts)); err != nil {
3340
return fmt.Errorf("create repo parent: %w", err)
3441
}
3542
if err := run(ctx, "", opts.Git, "clone", opts.Remote, opts.RepoPath); err != nil {
3643
return err
3744
}
45+
if opts.DirMode != 0 {
46+
if err := os.Chmod(opts.RepoPath, opts.DirMode); err != nil {
47+
return fmt.Errorf("set repo permissions: %w", err)
48+
}
49+
}
3850
if opts.Branch != "" {
51+
remoteRef := "refs/remotes/origin/" + opts.Branch
52+
if _, err := output(ctx, opts.RepoPath, opts.Git, "rev-parse", "--verify", remoteRef); err == nil {
53+
return run(ctx, opts.RepoPath, opts.Git, "checkout", "-B", opts.Branch, "origin/"+opts.Branch)
54+
}
3955
return run(ctx, opts.RepoPath, opts.Git, "checkout", "-B", opts.Branch)
4056
}
4157
return nil
4258
}
43-
if err := os.MkdirAll(opts.RepoPath, 0o755); err != nil {
59+
if err := os.MkdirAll(opts.RepoPath, creationDirMode(opts)); err != nil {
4460
return fmt.Errorf("create repo path: %w", err)
4561
}
4662
if err := run(ctx, opts.RepoPath, opts.Git, "init"); err != nil {
4763
return err
4864
}
65+
if opts.DirMode != 0 {
66+
if err := os.Chmod(opts.RepoPath, opts.DirMode); err != nil {
67+
return fmt.Errorf("set repo permissions: %w", err)
68+
}
69+
}
4970
if opts.Branch != "" {
5071
return run(ctx, opts.RepoPath, opts.Git, "checkout", "-B", opts.Branch)
5172
}
@@ -92,12 +113,25 @@ func PullCurrent(ctx context.Context, opts Options) error {
92113
if err := run(ctx, opts.RepoPath, opts.Git, "fetch", "--prune", "origin"); err != nil {
93114
return err
94115
}
95-
if _, err := output(ctx, opts.RepoPath, opts.Git, "rev-parse", "--verify", "refs/heads/"+opts.Branch); err != nil {
116+
remoteRef := "refs/remotes/origin/" + opts.Branch
117+
_, remoteErr := output(ctx, opts.RepoPath, opts.Git, "rev-parse", "--verify", remoteRef)
118+
_, localErr := output(ctx, opts.RepoPath, opts.Git, "rev-parse", "--verify", "refs/heads/"+opts.Branch)
119+
if localErr != nil && remoteErr != nil {
120+
return run(ctx, opts.RepoPath, opts.Git, "checkout", "-B", opts.Branch)
121+
}
122+
if localErr != nil {
96123
return run(ctx, opts.RepoPath, opts.Git, "checkout", "-B", opts.Branch, "origin/"+opts.Branch)
97124
}
98125
if err := run(ctx, opts.RepoPath, opts.Git, "checkout", opts.Branch); err != nil {
99126
return err
100127
}
128+
if remoteErr != nil {
129+
trackedRemote, err := output(ctx, opts.RepoPath, opts.Git, "config", "--get", "branch."+opts.Branch+".remote")
130+
if err == nil && strings.TrimSpace(trackedRemote) == "origin" {
131+
return fmt.Errorf("tracked remote branch origin/%s is missing", opts.Branch)
132+
}
133+
return nil
134+
}
101135
return run(ctx, opts.RepoPath, opts.Git, "pull", "--ff-only", "origin", opts.Branch)
102136
}
103137

@@ -339,6 +373,13 @@ func normalize(opts Options) Options {
339373
return opts
340374
}
341375

376+
func creationDirMode(opts Options) os.FileMode {
377+
if opts.DirMode != 0 {
378+
return opts.DirMode
379+
}
380+
return 0o755
381+
}
382+
342383
func setOrigin(ctx context.Context, opts Options) error {
343384
current, err := output(ctx, opts.RepoPath, opts.Git, "remote", "get-url", "origin")
344385
if err != nil {

mirror/mirror_test.go

Lines changed: 151 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import (
44
"context"
55
"os"
66
"path/filepath"
7+
"runtime"
78
"strings"
89
"testing"
910
)
@@ -67,6 +68,82 @@ func TestEnsureRepoUpdatesExistingOrigin(t *testing.T) {
6768
}
6869
}
6970

71+
func TestEnsureRepoAppliesPrivateDirectoryMode(t *testing.T) {
72+
ctx := context.Background()
73+
dir := t.TempDir()
74+
remote := filepath.Join(dir, "remote.git")
75+
if err := run(ctx, "", "git", "init", "--bare", remote); err != nil {
76+
t.Fatal(err)
77+
}
78+
repo := filepath.Join(dir, "share")
79+
if err := EnsureRemote(ctx, Options{RepoPath: repo, Remote: remote, Branch: "main", DirMode: 0o750}); err != nil {
80+
t.Fatal(err)
81+
}
82+
if runtime.GOOS == "windows" {
83+
return
84+
}
85+
info, err := os.Stat(repo)
86+
if err != nil {
87+
t.Fatal(err)
88+
}
89+
if mode := info.Mode().Perm(); mode != 0o750 {
90+
t.Fatalf("repo mode = %o, want 750", mode)
91+
}
92+
localRepo := filepath.Join(dir, "local-share")
93+
if err := os.Mkdir(localRepo, 0o700); err != nil {
94+
t.Fatal(err)
95+
}
96+
if err := EnsureRepo(ctx, Options{RepoPath: localRepo, Branch: "main", DirMode: 0o750}); err != nil {
97+
t.Fatal(err)
98+
}
99+
info, err = os.Stat(localRepo)
100+
if err != nil {
101+
t.Fatal(err)
102+
}
103+
if mode := info.Mode().Perm(); mode != 0o750 {
104+
t.Fatalf("local repo mode = %o, want 750", mode)
105+
}
106+
}
107+
108+
func TestEnsureRepoClonesRequestedRemoteBranch(t *testing.T) {
109+
ctx := context.Background()
110+
dir := t.TempDir()
111+
seed := filepath.Join(dir, "seed")
112+
remote := filepath.Join(dir, "remote.git")
113+
repo := filepath.Join(dir, "share")
114+
if err := run(ctx, "", "git", "init", "-b", "main", seed); err != nil {
115+
t.Fatal(err)
116+
}
117+
if err := os.WriteFile(filepath.Join(seed, "manifest.json"), []byte("release\n"), 0o600); err != nil {
118+
t.Fatal(err)
119+
}
120+
if committed, err := Commit(ctx, Options{RepoPath: seed, Branch: "main"}, "release"); err != nil || !committed {
121+
t.Fatalf("commit = %v, %v", committed, err)
122+
}
123+
if err := run(ctx, seed, "git", "branch", "release"); err != nil {
124+
t.Fatal(err)
125+
}
126+
if err := os.WriteFile(filepath.Join(seed, "manifest.json"), []byte("main\n"), 0o600); err != nil {
127+
t.Fatal(err)
128+
}
129+
if committed, err := Commit(ctx, Options{RepoPath: seed, Branch: "main"}, "main"); err != nil || !committed {
130+
t.Fatalf("commit = %v, %v", committed, err)
131+
}
132+
if err := run(ctx, "", "git", "clone", "--bare", seed, remote); err != nil {
133+
t.Fatal(err)
134+
}
135+
if err := EnsureRepo(ctx, Options{RepoPath: repo, Remote: remote, Branch: "release"}); err != nil {
136+
t.Fatal(err)
137+
}
138+
body, err := os.ReadFile(filepath.Join(repo, "manifest.json"))
139+
if err != nil {
140+
t.Fatal(err)
141+
}
142+
if strings.ReplaceAll(string(body), "\r\n", "\n") != "release\n" {
143+
t.Fatalf("manifest = %q, want release", body)
144+
}
145+
}
146+
70147
func TestPushWritesCurrentBranchToOrigin(t *testing.T) {
71148
ctx := context.Background()
72149
dir := t.TempDir()
@@ -227,6 +304,80 @@ func TestPullCurrentUsesExistingOrigin(t *testing.T) {
227304
}
228305
}
229306

307+
func TestPullCurrentPreservesUnpublishedLocalBranch(t *testing.T) {
308+
ctx := context.Background()
309+
dir := t.TempDir()
310+
remote := filepath.Join(dir, "remote.git")
311+
repo := filepath.Join(dir, "share")
312+
if err := run(ctx, "", "git", "init", "--bare", remote); err != nil {
313+
t.Fatal(err)
314+
}
315+
opts := Options{RepoPath: repo, Remote: remote, Branch: "private"}
316+
if err := EnsureRemote(ctx, opts); err != nil {
317+
t.Fatal(err)
318+
}
319+
if err := os.WriteFile(filepath.Join(repo, "manifest.json"), []byte("local\n"), 0o600); err != nil {
320+
t.Fatal(err)
321+
}
322+
if committed, err := Commit(ctx, opts, "local snapshot"); err != nil || !committed {
323+
t.Fatalf("commit = %v, %v", committed, err)
324+
}
325+
headBefore, err := output(ctx, repo, "git", "rev-parse", "HEAD")
326+
if err != nil {
327+
t.Fatal(err)
328+
}
329+
if err := PullCurrent(ctx, Options{RepoPath: repo, Branch: "private"}); err != nil {
330+
t.Fatal(err)
331+
}
332+
headAfter, err := output(ctx, repo, "git", "rev-parse", "HEAD")
333+
if err != nil {
334+
t.Fatal(err)
335+
}
336+
if strings.TrimSpace(headAfter) != strings.TrimSpace(headBefore) {
337+
t.Fatalf("HEAD moved from %s to %s", strings.TrimSpace(headBefore), strings.TrimSpace(headAfter))
338+
}
339+
}
340+
341+
func TestPullCurrentRejectsDeletedTrackedBranch(t *testing.T) {
342+
ctx := context.Background()
343+
dir := t.TempDir()
344+
remote := filepath.Join(dir, "remote.git")
345+
seed := filepath.Join(dir, "seed")
346+
repo := filepath.Join(dir, "share")
347+
if err := run(ctx, "", "git", "init", "--bare", remote); err != nil {
348+
t.Fatal(err)
349+
}
350+
if err := run(ctx, "", "git", "clone", remote, seed); err != nil {
351+
t.Fatal(err)
352+
}
353+
if err := run(ctx, seed, "git", "checkout", "-B", "main"); err != nil {
354+
t.Fatal(err)
355+
}
356+
if err := os.WriteFile(filepath.Join(seed, "manifest.json"), []byte("remote\n"), 0o600); err != nil {
357+
t.Fatal(err)
358+
}
359+
seedOpts := Options{RepoPath: seed, Remote: remote, Branch: "main"}
360+
if committed, err := Commit(ctx, seedOpts, "remote snapshot"); err != nil || !committed {
361+
t.Fatalf("commit = %v, %v", committed, err)
362+
}
363+
if err := Push(ctx, seedOpts); err != nil {
364+
t.Fatal(err)
365+
}
366+
if err := Pull(ctx, Options{RepoPath: repo, Remote: remote, Branch: "main"}); err != nil {
367+
t.Fatal(err)
368+
}
369+
if err := run(ctx, repo, "git", "branch", "--set-upstream-to", "origin/main", "main"); err != nil {
370+
t.Fatal(err)
371+
}
372+
if err := run(ctx, remote, "git", "update-ref", "-d", "refs/heads/main"); err != nil {
373+
t.Fatal(err)
374+
}
375+
err := PullCurrent(ctx, Options{RepoPath: repo, Branch: "main"})
376+
if err == nil || !strings.Contains(err.Error(), "tracked remote branch origin/main is missing") {
377+
t.Fatalf("PullCurrent error = %v", err)
378+
}
379+
}
380+
230381
func TestSyncForWriteRebasesUnpushedCommit(t *testing.T) {
231382
ctx := context.Background()
232383
dir := t.TempDir()

0 commit comments

Comments
 (0)