Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@

## Unreleased

- Retry atomic branch-and-tag snapshot pushes after rebasing and retargeting unpublished tags.
- Update Go to 1.26.4 for current standard-library security fixes and add race and vulnerability CI gates.
- Preserve unpublished local mirror branches during pulls, clone the requested remote branch, and support caller-selected private repository directory modes.
- Allow managed sidecar trees to prune selected generated files while preserving unrelated files.
Expand Down
70 changes: 68 additions & 2 deletions mirror/history.go
Original file line number Diff line number Diff line change
Expand Up @@ -139,6 +139,72 @@ func ValidateTag(ctx context.Context, opts Options, tag string) error {

func PushAtomic(ctx context.Context, opts Options, refs ...string) error {
opts = normalize(opts)
out, err := pushAtomic(ctx, opts, refs...)
if err != nil {
return fmt.Errorf("atomic git push: %w\n%s", err, strings.TrimSpace(out))
}
return nil
}

// PushSnapshot atomically pushes the archive branch and an immutable snapshot
// tag, rebasing and retargeting unpublished tags before one retry when needed.
func PushSnapshot(ctx context.Context, opts Options, tag string) error {
opts = normalize(opts)
tag = strings.TrimSpace(tag)
if tag == "" {
return errors.New("snapshot tag is required")
}
if err := ValidateTag(ctx, opts, tag); err != nil {
return err
}
refs := []string{"HEAD:refs/heads/" + opts.Branch, "refs/tags/" + tag}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Ensure snapshot tags point at HEAD before pushing

When callers pass an existing tag that is not at the current commit, both new snapshot push helpers only validate the tag syntax and then include refs/tags/<tag> in the atomic push. Git accepts that refspec, so the remote branch can advance while the snapshot tag still points at an older commit, making tag-based archive reads return the wrong snapshot. Have these helpers create or verify the tag with the existing immutable-tag check before pushing, and ensure the retry path still publishes a tag retargeted to the rebased HEAD.

Useful? React with 👍 / 👎.

out, err := pushAtomic(ctx, opts, refs...)
if err == nil {
return nil
}
if !isNonFastForward(out) {
return fmt.Errorf("atomic snapshot push: %w\n%s", err, strings.TrimSpace(out))
}
if syncErr := SyncForWrite(ctx, opts); syncErr != nil {
return fmt.Errorf("sync before atomic snapshot push retry: %w", syncErr)
}
out, err = pushAtomic(ctx, opts, refs...)
if err != nil {
return fmt.Errorf("atomic snapshot push retry: %w\n%s", err, strings.TrimSpace(out))
}
return nil
}

// PushCurrentSnapshot atomically pushes the checked-out archive branch and an
// immutable snapshot tag, preserving legacy branch names during one retry.
func PushCurrentSnapshot(ctx context.Context, opts Options, tag string) error {
opts = normalize(opts)
tag = strings.TrimSpace(tag)
if tag == "" {
return errors.New("snapshot tag is required")
}
if err := ValidateTag(ctx, opts, tag); err != nil {
return err
}
refs := []string{"HEAD", "refs/tags/" + tag}
out, err := pushAtomic(ctx, opts, refs...)
if err == nil {
return nil
}
if !isNonFastForward(out) {
return fmt.Errorf("atomic current snapshot push: %w\n%s", err, strings.TrimSpace(out))
}
if syncErr := SyncCurrentForWrite(ctx, opts); syncErr != nil {
return fmt.Errorf("sync before atomic current snapshot push retry: %w", syncErr)
}
out, err = pushAtomic(ctx, opts, refs...)
if err != nil {
return fmt.Errorf("atomic current snapshot push retry: %w\n%s", err, strings.TrimSpace(out))
}
return nil
}

func pushAtomic(ctx context.Context, opts Options, refs ...string) (string, error) {
args := []string{"push", "--atomic", "-u", "origin"}
if len(refs) == 0 {
refs = []string{"HEAD:refs/heads/" + opts.Branch}
Expand All @@ -151,9 +217,9 @@ func PushAtomic(ctx context.Context, opts Options, refs ...string) error {
args = append(args, ref)
}
if len(args) == 4 {
return errors.New("at least one git ref is required")
return "", errors.New("at least one git ref is required")
}
return run(ctx, opts.RepoPath, opts.Git, args...)
return output(ctx, opts.RepoPath, opts.Git, args...)
}

func ListTreeFiles(ctx context.Context, opts Options, ref, root string) ([]TreeFile, error) {
Expand Down
118 changes: 118 additions & 0 deletions mirror/mirror_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -475,6 +475,81 @@ func TestSyncForWriteRebasesUnpushedCommit(t *testing.T) {
}
}

func TestPushSnapshotRebasesAndRetargetsTag(t *testing.T) {
ctx := context.Background()
dir := t.TempDir()
remote := filepath.Join(dir, "remote.git")
seed := filepath.Join(dir, "seed")
publisher := filepath.Join(dir, "publisher")
peer := filepath.Join(dir, "peer")
if err := run(ctx, "", "git", "init", "--bare", remote); err != nil {
t.Fatal(err)
}
seedOpts := Options{RepoPath: seed, Remote: remote, Branch: "main"}
if err := EnsureRemote(ctx, seedOpts); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(filepath.Join(seed, "manifest.json"), []byte("seed\n"), 0o600); err != nil {
t.Fatal(err)
}
if committed, err := Commit(ctx, seedOpts, "seed"); err != nil || !committed {
t.Fatalf("seed commit = %v, %v", committed, err)
}
if err := Push(ctx, seedOpts); err != nil {
t.Fatal(err)
}
for _, clone := range []string{publisher, peer} {
if err := run(ctx, "", "git", "clone", "--branch", "main", remote, clone); err != nil {
t.Fatal(err)
}
}
publisherOpts := Options{RepoPath: publisher, Branch: "main"}
if err := os.WriteFile(filepath.Join(publisher, "manifest.json"), []byte("publisher\n"), 0o600); err != nil {
t.Fatal(err)
}
if committed, err := Commit(ctx, publisherOpts, "publisher snapshot"); err != nil || !committed {
t.Fatalf("publisher commit = %v, %v", committed, err)
}
if tag, err := CreateImmutableTag(ctx, publisherOpts, "snapshot/race"); err != nil || tag != "snapshot/race" {
t.Fatalf("publisher tag = %q, %v", tag, err)
}
peerOpts := Options{RepoPath: peer, Branch: "main"}
if err := os.WriteFile(filepath.Join(peer, "peer.txt"), []byte("peer\n"), 0o600); err != nil {
t.Fatal(err)
}
if committed, err := Commit(ctx, peerOpts, "peer update"); err != nil || !committed {
t.Fatalf("peer commit = %v, %v", committed, err)
}
if err := Push(ctx, peerOpts); err != nil {
t.Fatal(err)
}
if err := PushSnapshot(ctx, publisherOpts, "snapshot/race"); err != nil {
t.Fatal(err)
}
head, err := ResolveCommit(ctx, publisherOpts, "HEAD")
if err != nil {
t.Fatal(err)
}
tagged, err := ResolveCommit(ctx, publisherOpts, "snapshot/race")
if err != nil {
t.Fatal(err)
}
if tagged != head {
t.Fatalf("snapshot tag = %s, want rebased HEAD %s", tagged, head)
}
remoteHead, err := ResolveCommit(ctx, Options{RepoPath: remote}, "refs/heads/main")
if err != nil {
t.Fatal(err)
}
remoteTag, err := ResolveCommit(ctx, Options{RepoPath: remote}, "refs/tags/snapshot/race")
if err != nil {
t.Fatal(err)
}
if remoteHead != head || remoteTag != head {
t.Fatalf("remote branch/tag = %s/%s, want %s", remoteHead, remoteTag, head)
}
}

func TestSyncCurrentForWritePreservesLegacyBranch(t *testing.T) {
ctx := context.Background()
dir := t.TempDir()
Expand Down Expand Up @@ -525,6 +600,43 @@ func TestSyncCurrentForWritePreservesLegacyBranch(t *testing.T) {
if strings.TrimSpace(branch) != "legacy" {
t.Fatalf("branch = %q, want legacy", strings.TrimSpace(branch))
}
if err := os.WriteFile(filepath.Join(repo, "local-snapshot.txt"), []byte("local\n"), 0o600); err != nil {
t.Fatal(err)
}
localOpts := Options{RepoPath: repo, Branch: "main"}
if committed, err := Commit(ctx, localOpts, "archive: local snapshot"); err != nil || !committed {
t.Fatalf("local snapshot commit = %v, %v", committed, err)
}
if tag, err := CreateImmutableTag(ctx, localOpts, "snapshot/legacy"); err != nil || tag != "snapshot/legacy" {
t.Fatalf("legacy snapshot tag = %q, %v", tag, err)
}
if err := os.WriteFile(filepath.Join(seed, "remote-two.txt"), []byte("remote two\n"), 0o600); err != nil {
t.Fatal(err)
}
if committed, err := Commit(ctx, seedOpts, "archive: remote two"); err != nil || !committed {
t.Fatalf("second remote commit = %v, %v", committed, err)
}
if err := PushAtomic(ctx, seedOpts, "HEAD"); err != nil {
t.Fatal(err)
}
if err := PushCurrentSnapshot(ctx, localOpts, "snapshot/legacy"); err != nil {
t.Fatal(err)
}
localHead, err := ResolveCommit(ctx, localOpts, "HEAD")
if err != nil {
t.Fatal(err)
}
remoteHead, err := ResolveCommit(ctx, Options{RepoPath: remote}, "refs/heads/legacy")
if err != nil {
t.Fatal(err)
}
remoteTag, err := ResolveCommit(ctx, Options{RepoPath: remote}, "refs/tags/snapshot/legacy")
if err != nil {
t.Fatal(err)
}
if remoteHead != localHead || remoteTag != localHead {
t.Fatalf("legacy remote branch/tag = %s/%s, want %s", remoteHead, remoteTag, localHead)
}
}

func TestCleanSQLiteSidecars(t *testing.T) {
Expand Down Expand Up @@ -687,6 +799,12 @@ func TestHistoryValidationAndLocalFetch(t *testing.T) {
if err := ValidateTag(ctx, opts, "bad tag"); err == nil {
t.Fatal("invalid tag validation should fail")
}
if err := PushSnapshot(ctx, opts, "snapshot/good:refs/heads/release"); err == nil {
t.Fatal("snapshot push should reject refspec syntax")
}
if err := PushCurrentSnapshot(ctx, opts, "snapshot/good:refs/heads/release"); err == nil {
t.Fatal("current snapshot push should reject refspec syntax")
}
if err := ValidateTag(ctx, opts, ""); err != nil {
t.Fatalf("empty optional tag: %v", err)
}
Expand Down