diff --git a/internal/checkpoint/sync.go b/internal/checkpoint/sync.go new file mode 100644 index 0000000..9c04258 --- /dev/null +++ b/internal/checkpoint/sync.go @@ -0,0 +1,166 @@ +package checkpoint + +import ( + "fmt" + "strings" + + "github.com/partio-io/cli/internal/git" +) + +// SyncWithRemote fetches the remote checkpoint branch using --filter=blob:none, +// merges remote checkpoint entries into the local branch by unioning tree entries, +// and updates the local ref. Returns true if the remote had the branch, false if not. +func (s *Store) SyncWithRemote(remote string) (bool, error) { + if err := git.FetchBranch(remote, checkpointBranch); err != nil { + return false, fmt.Errorf("fetching remote checkpoint branch: %w", err) + } + + remoteCommit, err := s.git("rev-parse", "FETCH_HEAD") + if err != nil { + return false, fmt.Errorf("resolving FETCH_HEAD: %w", err) + } + + localCommit, err := s.git("rev-parse", checkpointBranch) + if err != nil { + return false, fmt.Errorf("resolving local checkpoint branch: %w", err) + } + + if remoteCommit == localCommit { + return true, nil + } + + // If remote is already an ancestor of local, local is ahead — nothing to merge. + if s.isAncestor(remoteCommit, localCommit) { + return true, nil + } + + localTree, err := s.git("rev-parse", checkpointBranch+"^{tree}") + if err != nil { + return false, fmt.Errorf("getting local tree: %w", err) + } + + remoteTree, err := s.git("rev-parse", "FETCH_HEAD^{tree}") + if err != nil { + return false, fmt.Errorf("getting remote tree: %w", err) + } + + mergedTree, err := s.mergeTrees(localTree, remoteTree) + if err != nil { + return false, fmt.Errorf("merging checkpoint trees: %w", err) + } + + commitHash, err := s.git("commit-tree", mergedTree, + "-p", localCommit, + "-p", remoteCommit, + "-m", "sync: merge remote checkpoint entries", + ) + if err != nil { + return false, fmt.Errorf("creating merge commit: %w", err) + } + + _, err = s.git("update-ref", "refs/heads/"+checkpointBranch, commitHash) + if err != nil { + return false, fmt.Errorf("updating checkpoint branch ref: %w", err) + } + + return true, nil +} + +// isAncestor returns true if candidate is an ancestor of descendant. +func (s *Store) isAncestor(candidate, descendant string) bool { + _, err := s.git("merge-base", "--is-ancestor", candidate, descendant) + return err == nil +} + +// mergeTrees merges two checkpoint root trees by unioning shard entries. +// Within each shard, checkpoint entries (identified by UUID suffix) are unioned. +// Local entries take precedence when the same UUID exists in both. +func (s *Store) mergeTrees(localTree, remoteTree string) (string, error) { + localShards, err := s.parseTree(localTree) + if err != nil { + return "", fmt.Errorf("parsing local tree: %w", err) + } + + remoteShards, err := s.parseTree(remoteTree) + if err != nil { + return "", fmt.Errorf("parsing remote tree: %w", err) + } + + for name, remoteEntry := range remoteShards { + localEntry, exists := localShards[name] + if !exists { + localShards[name] = remoteEntry + } else { + mergedShardTree, err := s.mergeShardTrees(localEntry.hash, remoteEntry.hash) + if err != nil { + return "", fmt.Errorf("merging shard %s: %w", name, err) + } + localShards[name] = treeEntry{ + mode: localEntry.mode, + typ: localEntry.typ, + hash: mergedShardTree, + name: localEntry.name, + } + } + } + + var entries []treeEntry + for _, e := range localShards { + entries = append(entries, e) + } + return s.mktree(entries) +} + +// mergeShardTrees unions checkpoint entries within a shard tree. +// Local entries take precedence when the same checkpoint UUID exists in both. +func (s *Store) mergeShardTrees(localShardTree, remoteShardTree string) (string, error) { + localEntries, err := s.parseTree(localShardTree) + if err != nil { + return "", err + } + remoteEntries, err := s.parseTree(remoteShardTree) + if err != nil { + return "", err + } + + for name, e := range remoteEntries { + if _, exists := localEntries[name]; !exists { + localEntries[name] = e + } + } + + var entries []treeEntry + for _, e := range localEntries { + entries = append(entries, e) + } + return s.mktree(entries) +} + +// parseTree reads a git tree object and returns a map of name -> treeEntry. +func (s *Store) parseTree(tree string) (map[string]treeEntry, error) { + out, _ := s.git("ls-tree", tree) + result := make(map[string]treeEntry) + if out == "" { + return result, nil + } + for _, line := range strings.Split(out, "\n") { + if line == "" { + continue + } + parts := strings.Fields(line) + tabParts := strings.SplitN(line, "\t", 2) + name := "" + if len(tabParts) >= 2 { + name = tabParts[1] + } + if len(parts) >= 3 { + result[name] = treeEntry{ + mode: parts[0], + typ: parts[1], + hash: parts[2], + name: name, + } + } + } + return result, nil +} diff --git a/internal/checkpoint/sync_test.go b/internal/checkpoint/sync_test.go new file mode 100644 index 0000000..70b1c46 --- /dev/null +++ b/internal/checkpoint/sync_test.go @@ -0,0 +1,304 @@ +package checkpoint + +import ( + "os/exec" + "strings" + "testing" +) + +// initTestRepo creates a temporary git repo with a checkpoint orphan branch +// and returns a Store pointing at it. +func initTestRepo(t *testing.T) *Store { + t.Helper() + dir := t.TempDir() + + run := func(args ...string) string { + t.Helper() + cmd := exec.Command("git", args...) + cmd.Dir = dir + out, err := cmd.Output() + if err != nil { + t.Fatalf("git %v: %v", args, err) + } + return strings.TrimSpace(string(out)) + } + + run("init", "-b", "main") + run("config", "user.email", "test@example.com") + run("config", "user.name", "Test") + + // Create orphan checkpoint branch with empty tree + emptyTree := run("mktree") + initCommit := run("commit-tree", emptyTree, "-m", "init checkpoint branch") + run("update-ref", "refs/heads/"+checkpointBranch, initCommit) + + return NewStore(dir) +} + +// addCheckpointToStore writes a minimal checkpoint entry directly via git plumbing. +func addCheckpointToStore(t *testing.T, s *Store, id string) { + t.Helper() + shard := Shard(id) + rest := Rest(id) + + metaHash, err := s.hashObject(`{"id":"` + id + `"}`) + if err != nil { + t.Fatalf("hashObject: %v", err) + } + + cpTree, err := s.mktree([]treeEntry{ + {mode: "100644", typ: "blob", hash: metaHash, name: "metadata.json"}, + }) + if err != nil { + t.Fatalf("mktree cp: %v", err) + } + + currentTree, err := s.getCurrentTree() + if err != nil { + t.Fatalf("getCurrentTree: %v", err) + } + + newRoot, err := s.addToTree(currentTree, shard, rest, cpTree) + if err != nil { + t.Fatalf("addToTree: %v", err) + } + + parentCommit, err := s.git("rev-parse", checkpointBranch) + if err != nil { + t.Fatalf("rev-parse: %v", err) + } + + commitHash, err := s.git("commit-tree", newRoot, "-p", parentCommit, "-m", "checkpoint: "+id) + if err != nil { + t.Fatalf("commit-tree: %v", err) + } + + _, err = s.git("update-ref", "refs/heads/"+checkpointBranch, commitHash) + if err != nil { + t.Fatalf("update-ref: %v", err) + } +} + +func TestMergeTrees_DisjointShards(t *testing.T) { + s := initTestRepo(t) + + // Add checkpoint with shard "ab" locally + addCheckpointToStore(t, s, "ab1234567890") + + localTree, err := s.git("rev-parse", checkpointBranch+"^{tree}") + if err != nil { + t.Fatalf("rev-parse local tree: %v", err) + } + + // Build a separate tree with shard "cd" to simulate a remote tree + s2 := initTestRepo(t) + addCheckpointToStore(t, s2, "cd1234567890") + remoteTree, err := s2.git("rev-parse", checkpointBranch+"^{tree}") + if err != nil { + t.Fatalf("rev-parse remote tree: %v", err) + } + + // Transplant the remote tree object into s's object store by fetching via pack + // Instead, manually build a combined tree using s's merge logic + // We need to create the remote shard tree in s's object store + remoteShard, err := s2.git("rev-parse", checkpointBranch+"^{tree}:cd") + if err != nil { + t.Fatalf("rev-parse remote shard: %v", err) + } + + // Add the remote shard tree hash to s's object store by re-creating it + remoteMetaHash, err := s.hashObject(`{"id":"cd1234567890"}`) + if err != nil { + t.Fatalf("hashObject remote: %v", err) + } + _ = remoteShard + + remoteCpTree, err := s.mktree([]treeEntry{ + {mode: "100644", typ: "blob", hash: remoteMetaHash, name: "metadata.json"}, + }) + if err != nil { + t.Fatalf("mktree remote cp: %v", err) + } + + remoteShardTree, err := s.mktree([]treeEntry{ + {mode: "040000", typ: "tree", hash: remoteCpTree, name: "1234567890"}, + }) + if err != nil { + t.Fatalf("mktree remote shard: %v", err) + } + _ = remoteTree + + // Build a simulated remote root tree in s's object store + simulatedRemoteTree, err := s.mktree([]treeEntry{ + {mode: "040000", typ: "tree", hash: remoteShardTree, name: "cd"}, + }) + if err != nil { + t.Fatalf("mktree simulated remote: %v", err) + } + + merged, err := s.mergeTrees(localTree, simulatedRemoteTree) + if err != nil { + t.Fatalf("mergeTrees: %v", err) + } + + // Merged tree should contain both "ab" and "cd" shards + out, err := s.git("ls-tree", merged) + if err != nil { + t.Fatalf("ls-tree merged: %v", err) + } + + if !strings.Contains(out, "ab") { + t.Errorf("expected merged tree to contain shard 'ab', got:\n%s", out) + } + if !strings.Contains(out, "cd") { + t.Errorf("expected merged tree to contain shard 'cd', got:\n%s", out) + } +} + +func TestMergeTrees_OverlappingShard(t *testing.T) { + s := initTestRepo(t) + + // Add two checkpoints with the same shard "ab" locally + addCheckpointToStore(t, s, "ab1111111111") + + localTree, err := s.git("rev-parse", checkpointBranch+"^{tree}") + if err != nil { + t.Fatalf("rev-parse local tree: %v", err) + } + + // Build a simulated remote tree with different checkpoint in same shard "ab" + remoteMetaHash, err := s.hashObject(`{"id":"ab2222222222"}`) + if err != nil { + t.Fatalf("hashObject: %v", err) + } + + remoteCpTree, err := s.mktree([]treeEntry{ + {mode: "100644", typ: "blob", hash: remoteMetaHash, name: "metadata.json"}, + }) + if err != nil { + t.Fatalf("mktree: %v", err) + } + + remoteShardTree, err := s.mktree([]treeEntry{ + {mode: "040000", typ: "tree", hash: remoteCpTree, name: "2222222222"}, + }) + if err != nil { + t.Fatalf("mktree shard: %v", err) + } + + simulatedRemoteTree, err := s.mktree([]treeEntry{ + {mode: "040000", typ: "tree", hash: remoteShardTree, name: "ab"}, + }) + if err != nil { + t.Fatalf("mktree remote: %v", err) + } + + merged, err := s.mergeTrees(localTree, simulatedRemoteTree) + if err != nil { + t.Fatalf("mergeTrees: %v", err) + } + + // The merged shard should contain both checkpoint entries + mergedShardTree, err := s.git("rev-parse", merged+":ab") + if err != nil { + t.Fatalf("rev-parse merged shard: %v", err) + } + + out, err := s.git("ls-tree", mergedShardTree) + if err != nil { + t.Fatalf("ls-tree merged shard: %v", err) + } + + if !strings.Contains(out, "1111111111") { + t.Errorf("expected merged shard to contain local checkpoint, got:\n%s", out) + } + if !strings.Contains(out, "2222222222") { + t.Errorf("expected merged shard to contain remote checkpoint, got:\n%s", out) + } +} + +func TestMergeTrees_LocalWinsOnConflict(t *testing.T) { + s := initTestRepo(t) + + // Add checkpoint locally + addCheckpointToStore(t, s, "ab1234567890") + + localTree, err := s.git("rev-parse", checkpointBranch+"^{tree}") + if err != nil { + t.Fatalf("rev-parse local tree: %v", err) + } + + // Build a simulated remote tree with the same checkpoint ID but different content + remoteMetaHash, err := s.hashObject(`{"id":"ab1234567890","extra":"remote"}`) + if err != nil { + t.Fatalf("hashObject: %v", err) + } + + remoteCpTree, err := s.mktree([]treeEntry{ + {mode: "100644", typ: "blob", hash: remoteMetaHash, name: "metadata.json"}, + }) + if err != nil { + t.Fatalf("mktree: %v", err) + } + + remoteShardTree, err := s.mktree([]treeEntry{ + {mode: "040000", typ: "tree", hash: remoteCpTree, name: Rest("ab1234567890")}, + }) + if err != nil { + t.Fatalf("mktree shard: %v", err) + } + + simulatedRemoteTree, err := s.mktree([]treeEntry{ + {mode: "040000", typ: "tree", hash: remoteShardTree, name: "ab"}, + }) + if err != nil { + t.Fatalf("mktree remote: %v", err) + } + + merged, err := s.mergeTrees(localTree, simulatedRemoteTree) + if err != nil { + t.Fatalf("mergeTrees: %v", err) + } + + // Local tree should be preserved (same hash as before merging) + if merged != localTree { + // The shard "ab" should still contain only one "34567890" entry (local wins) + mergedShardTree, err := s.git("rev-parse", merged+":ab") + if err != nil { + t.Fatalf("rev-parse merged shard: %v", err) + } + + localShardTree, err := s.git("rev-parse", localTree+":ab") + if err != nil { + t.Fatalf("rev-parse local shard: %v", err) + } + + if mergedShardTree != localShardTree { + t.Errorf("expected local shard tree to be preserved, got different tree") + } + } +} + +func TestIsAncestor(t *testing.T) { + s := initTestRepo(t) + + // Initial commit on checkpoint branch + commit1, err := s.git("rev-parse", checkpointBranch) + if err != nil { + t.Fatalf("rev-parse: %v", err) + } + + addCheckpointToStore(t, s, "ab1234567890") + + commit2, err := s.git("rev-parse", checkpointBranch) + if err != nil { + t.Fatalf("rev-parse: %v", err) + } + + if !s.isAncestor(commit1, commit2) { + t.Error("commit1 should be ancestor of commit2") + } + if s.isAncestor(commit2, commit1) { + t.Error("commit2 should not be ancestor of commit1") + } +} diff --git a/internal/git/fetch_branch.go b/internal/git/fetch_branch.go new file mode 100644 index 0000000..c7d9991 --- /dev/null +++ b/internal/git/fetch_branch.go @@ -0,0 +1,14 @@ +package git + +import "fmt" + +// FetchBranch fetches a remote branch using --filter=blob:none to skip blob objects. +// Only tree and commit objects are downloaded, which is sufficient for merging +// checkpoint entries where only the tree structure is needed. +func FetchBranch(remote, branch string) error { + _, err := execGit("fetch", "--filter=blob:none", remote, branch) + if err != nil { + return fmt.Errorf("fetching %s from %s: %w", branch, remote, err) + } + return nil +} diff --git a/internal/hooks/prepush.go b/internal/hooks/prepush.go index 9878077..5712b15 100644 --- a/internal/hooks/prepush.go +++ b/internal/hooks/prepush.go @@ -3,6 +3,7 @@ package hooks import ( "log/slog" + "github.com/partio-io/cli/internal/checkpoint" "github.com/partio-io/cli/internal/config" "github.com/partio-io/cli/internal/git" ) @@ -29,6 +30,11 @@ func runPrePush(repoRoot string, cfg config.Config) error { return nil } + store := checkpoint.NewStore(repoRoot) + if _, err := store.SyncWithRemote("origin"); err != nil { + slog.Debug("could not sync remote checkpoint branch", "error", err) + } + if err := git.PushBranch("origin", git.CheckpointBranch); err != nil { slog.Warn("could not push checkpoint branch", "error", err) // Don't fail the push