diff --git a/cmd/entire/cli/benchutil/bench_test.go b/cmd/entire/cli/benchutil/bench_test.go index 2249e4499..5bbcc7c04 100644 --- a/cmd/entire/cli/benchutil/bench_test.go +++ b/cmd/entire/cli/benchutil/bench_test.go @@ -418,7 +418,7 @@ func benchBuildTree(entryCount int) func(*testing.B) { b.ResetTimer() for range b.N { - _, buildErr := checkpoint.BuildTreeFromEntries(freshRepo, entries) + _, buildErr := checkpoint.BuildTreeFromEntries(context.Background(), freshRepo, entries) if buildErr != nil { b.Fatalf("BuildTreeFromEntries: %v", buildErr) } diff --git a/cmd/entire/cli/benchutil/parse_tree_bench_test.go b/cmd/entire/cli/benchutil/parse_tree_bench_test.go index 39efaca3e..ab5147045 100644 --- a/cmd/entire/cli/benchutil/parse_tree_bench_test.go +++ b/cmd/entire/cli/benchutil/parse_tree_bench_test.go @@ -1,6 +1,7 @@ package benchutil import ( + "context" "fmt" "testing" @@ -73,7 +74,7 @@ func buildShardedMetadataTree(b *testing.B, repo *gogit.Repository, checkpointCo } } - hash, err := checkpoint.BuildTreeFromEntries(repo, entries) + hash, err := checkpoint.BuildTreeFromEntries(context.Background(), repo, entries) if err != nil { b.Fatalf("build tree: %v", err) } @@ -98,7 +99,7 @@ func buildFlatFileTree(b *testing.B, repo *gogit.Repository, fileCount int) plum } } - hash, err := checkpoint.BuildTreeFromEntries(repo, entries) + hash, err := checkpoint.BuildTreeFromEntries(context.Background(), repo, entries) if err != nil { b.Fatalf("build tree: %v", err) } @@ -138,7 +139,7 @@ func benchUpdateSubtreeTreeSurgery(priorCheckpoints int) func(*testing.B) { Hash: newBlobs[i], } } - cpTreeHash, err := checkpoint.BuildTreeFromEntries(repo, cpEntries) + cpTreeHash, err := checkpoint.BuildTreeFromEntries(context.Background(), repo, cpEntries) if err != nil { b.Fatalf("build checkpoint tree: %v", err) } @@ -203,7 +204,7 @@ func benchUpdateSubtreeFlattenRebuild(priorCheckpoints int) func(*testing.B) { } // Rebuild entire tree - _, err = checkpoint.BuildTreeFromEntries(repo, entries) + _, err = checkpoint.BuildTreeFromEntries(context.Background(), repo, entries) if err != nil { b.Fatalf("BuildTreeFromEntries: %v", err) } @@ -255,7 +256,7 @@ func benchApplyTreeChangesTreeSurgery(fileCount, changeCount int) func(*testing. b.ResetTimer() for range b.N { - _, err := checkpoint.ApplyTreeChanges(repo, rootTree, changes) + _, err := checkpoint.ApplyTreeChanges(context.Background(), repo, rootTree, changes) if err != nil { b.Fatalf("ApplyTreeChanges: %v", err) } @@ -307,7 +308,7 @@ func benchApplyTreeChangesFlattenRebuild(fileCount, changeCount int) func(*testi } // Rebuild - _, err = checkpoint.BuildTreeFromEntries(repo, entries) + _, err = checkpoint.BuildTreeFromEntries(context.Background(), repo, entries) if err != nil { b.Fatalf("BuildTreeFromEntries: %v", err) } diff --git a/cmd/entire/cli/checkpoint/checkpoint_test.go b/cmd/entire/cli/checkpoint/checkpoint_test.go index f2b1e1cb8..e655370de 100644 --- a/cmd/entire/cli/checkpoint/checkpoint_test.go +++ b/cmd/entire/cli/checkpoint/checkpoint_test.go @@ -660,7 +660,7 @@ func TestUpdateSummary_NotFound(t *testing.T) { store := NewGitStore(repo) // Ensure sessions branch exists - err := store.ensureSessionsBranch() + err := store.ensureSessionsBranch(context.Background()) if err != nil { t.Fatalf("ensureSessionsBranch() error = %v", err) } @@ -1534,7 +1534,7 @@ func TestReadCommitted_NonexistentCheckpoint(t *testing.T) { store := NewGitStore(repo) // Ensure sessions branch exists - err := store.ensureSessionsBranch() + err := store.ensureSessionsBranch(context.Background()) if err != nil { t.Fatalf("ensureSessionsBranch() error = %v", err) } @@ -1557,7 +1557,7 @@ func TestReadSessionContent_NonexistentCheckpoint(t *testing.T) { store := NewGitStore(repo) // Ensure sessions branch exists - err := store.ensureSessionsBranch() + err := store.ensureSessionsBranch(context.Background()) if err != nil { t.Fatalf("ensureSessionsBranch() error = %v", err) } @@ -1672,6 +1672,129 @@ func TestWriteTemporary_FirstCheckpoint_CapturesModifiedTrackedFiles(t *testing. } } +// TestWriteTemporary_PathNormalizationAndSkipping verifies that shadow branch writes +// normalize absolute in-repo paths back to repo-relative tree entries and skip invalid +// paths rather than encoding them into git trees. +func TestWriteTemporary_PathNormalizationAndSkipping(t *testing.T) { + tests := []struct { + name string + modifiedFiles func(repoRoot, mainFile string) []string + wantUpdated bool + }{ + { + name: "absolute in repo path is normalized", + modifiedFiles: func(_, mainFile string) []string { + return []string{mainFile} + }, + wantUpdated: true, + }, + { + name: "absolute outside repo path is skipped", + modifiedFiles: func(_, _ string) []string { + return []string{"C:/Users/rober/Vaults/Flowsign/main.go"} + }, + wantUpdated: false, + }, + { + name: "empty segment path is skipped", + modifiedFiles: func(_, _ string) []string { + return []string{"dir//main.go"} + }, + wantUpdated: false, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + tempDir := t.TempDir() + + repo, err := git.PlainInit(tempDir, false) + if err != nil { + t.Fatalf("failed to init git repo: %v", err) + } + + worktree, err := repo.Worktree() + if err != nil { + t.Fatalf("failed to get worktree: %v", err) + } + + mainFile := filepath.Join(tempDir, "main.go") + if err := os.WriteFile(mainFile, []byte("package main\n"), 0o644); err != nil { + t.Fatalf("failed to write main.go: %v", err) + } + if _, err := worktree.Add("main.go"); err != nil { + t.Fatalf("failed to add main.go: %v", err) + } + initialCommit, err := worktree.Commit("Initial commit", &git.CommitOptions{ + Author: &object.Signature{Name: "Test", Email: "test@test.com"}, + }) + if err != nil { + t.Fatalf("failed to commit: %v", err) + } + + updatedContent := "package main\n\nfunc main() {}\n" + if err := os.WriteFile(mainFile, []byte(updatedContent), 0o644); err != nil { + t.Fatalf("failed to update main.go: %v", err) + } + + t.Chdir(tempDir) + + metadataDir := filepath.Join(tempDir, ".entire", "metadata", "test-session") + if err := os.MkdirAll(metadataDir, 0o755); err != nil { + t.Fatalf("failed to create metadata dir: %v", err) + } + if err := os.WriteFile(filepath.Join(metadataDir, "full.jsonl"), []byte(`{"test": true}`), 0o644); err != nil { + t.Fatalf("failed to write transcript: %v", err) + } + + store := NewGitStore(repo) + result, err := store.WriteTemporary(context.Background(), WriteTemporaryOptions{ + SessionID: "test-session", + BaseCommit: initialCommit.String(), + ModifiedFiles: tt.modifiedFiles(tempDir, mainFile), + MetadataDir: ".entire/metadata/test-session", + MetadataDirAbs: metadataDir, + CommitMessage: "Checkpoint with path normalization", + AuthorName: "Test", + AuthorEmail: "test@test.com", + }) + if err != nil { + t.Fatalf("WriteTemporary() error = %v", err) + } + + commit, err := repo.CommitObject(result.CommitHash) + if err != nil { + t.Fatalf("failed to get commit object: %v", err) + } + + tree, err := commit.Tree() + if err != nil { + t.Fatalf("failed to get tree: %v", err) + } + + assertNoEmptyEntryNames(t, repo, commit.TreeHash, "") + + file, err := tree.File("main.go") + if err != nil { + t.Fatalf("main.go not found in checkpoint tree: %v", err) + } + + content, err := file.Contents() + if err != nil { + t.Fatalf("failed to read main.go content: %v", err) + } + + wantContent := "package main\n" + if tt.wantUpdated { + wantContent = updatedContent + } + if content != wantContent { + t.Errorf("unexpected main.go content\ngot:\n%s\nwant:\n%s", content, wantContent) + } + }) + } +} + // TestWriteTemporary_FirstCheckpoint_CapturesUntrackedFiles verifies that // the first checkpoint captures untracked files that exist in the working directory. func TestWriteTemporary_FirstCheckpoint_CapturesUntrackedFiles(t *testing.T) { diff --git a/cmd/entire/cli/checkpoint/committed.go b/cmd/entire/cli/checkpoint/committed.go index f17cc147e..e258a3485 100644 --- a/cmd/entire/cli/checkpoint/committed.go +++ b/cmd/entire/cli/checkpoint/committed.go @@ -61,7 +61,7 @@ func (s *GitStore) WriteCommitted(ctx context.Context, opts WriteCommittedOption } // Ensure sessions branch exists - if err := s.ensureSessionsBranch(); err != nil { + if err := s.ensureSessionsBranch(ctx); err != nil { return fmt.Errorf("failed to ensure sessions branch: %w", err) } @@ -98,7 +98,7 @@ func (s *GitStore) WriteCommitted(ctx context.Context, opts WriteCommittedOption } // Build checkpoint subtree and splice into root (O(depth) tree surgery) - newTreeHash, err := s.spliceCheckpointSubtree(rootTreeHash, opts.CheckpointID, basePath, entries) + newTreeHash, err := s.spliceCheckpointSubtree(ctx, rootTreeHash, opts.CheckpointID, basePath, entries) if err != nil { return err } @@ -151,7 +151,7 @@ func (s *GitStore) flattenCheckpointEntries(rootTreeHash plumbing.Hash, checkpoi // at the correct shard location in the root tree using O(depth) tree surgery. // basePath is like "a3/b2c4d5e6f7/" (with trailing slash). // Returns the new root tree hash. -func (s *GitStore) spliceCheckpointSubtree(rootTreeHash plumbing.Hash, checkpointID id.CheckpointID, basePath string, entries map[string]object.TreeEntry) (plumbing.Hash, error) { +func (s *GitStore) spliceCheckpointSubtree(ctx context.Context, rootTreeHash plumbing.Hash, checkpointID id.CheckpointID, basePath string, entries map[string]object.TreeEntry) (plumbing.Hash, error) { // Convert entries to relative paths (strip basePath prefix) relEntries := make(map[string]object.TreeEntry, len(entries)) for path, entry := range entries { @@ -163,7 +163,7 @@ func (s *GitStore) spliceCheckpointSubtree(rootTreeHash plumbing.Hash, checkpoin } // Build the checkpoint subtree from relative entries - checkpointTreeHash, err := BuildTreeFromEntries(s.repo, relEntries) + checkpointTreeHash, err := BuildTreeFromEntries(ctx, s.repo, relEntries) if err != nil { return plumbing.ZeroHash, fmt.Errorf("failed to build checkpoint subtree: %w", err) } @@ -461,7 +461,7 @@ func (s *GitStore) UpdateCheckpointSummary(ctx context.Context, checkpointID id. return err //nolint:wrapcheck // Propagating context cancellation } - if err := s.ensureSessionsBranch(); err != nil { + if err := s.ensureSessionsBranch(ctx); err != nil { return fmt.Errorf("failed to ensure sessions branch: %w", err) } @@ -503,7 +503,7 @@ func (s *GitStore) UpdateCheckpointSummary(ctx context.Context, checkpointID id. Hash: metadataHash, } - newTreeHash, err := s.spliceCheckpointSubtree(rootTreeHash, checkpointID, basePath, entries) + newTreeHash, err := s.spliceCheckpointSubtree(ctx, rootTreeHash, checkpointID, basePath, entries) if err != nil { return err } @@ -1154,7 +1154,7 @@ func (s *GitStore) UpdateSummary(ctx context.Context, checkpointID id.Checkpoint } // Ensure sessions branch exists - if err := s.ensureSessionsBranch(); err != nil { + if err := s.ensureSessionsBranch(ctx); err != nil { return fmt.Errorf("failed to ensure sessions branch: %w", err) } @@ -1217,7 +1217,7 @@ func (s *GitStore) UpdateSummary(ctx context.Context, checkpointID id.Checkpoint } // Build checkpoint subtree and splice into root (O(depth) tree surgery) - newTreeHash, err := s.spliceCheckpointSubtree(rootTreeHash, checkpointID, basePath, entries) + newTreeHash, err := s.spliceCheckpointSubtree(ctx, rootTreeHash, checkpointID, basePath, entries) if err != nil { return err } @@ -1252,7 +1252,7 @@ func (s *GitStore) UpdateCommitted(ctx context.Context, opts UpdateCommittedOpti } // Ensure sessions branch exists - if err := s.ensureSessionsBranch(); err != nil { + if err := s.ensureSessionsBranch(ctx); err != nil { return fmt.Errorf("failed to ensure sessions branch: %w", err) } @@ -1336,7 +1336,7 @@ func (s *GitStore) UpdateCommitted(ctx context.Context, opts UpdateCommittedOpti } // Build checkpoint subtree and splice into root (O(depth) tree surgery) - newTreeHash, err := s.spliceCheckpointSubtree(rootTreeHash, opts.CheckpointID, basePath, entries) + newTreeHash, err := s.spliceCheckpointSubtree(ctx, rootTreeHash, opts.CheckpointID, basePath, entries) if err != nil { return err } @@ -1405,7 +1405,7 @@ func (s *GitStore) replaceTranscript(ctx context.Context, transcript []byte, age } // ensureSessionsBranch ensures the entire/checkpoints/v1 branch exists. -func (s *GitStore) ensureSessionsBranch() error { +func (s *GitStore) ensureSessionsBranch(ctx context.Context) error { refName := plumbing.NewBranchReferenceName(paths.MetadataBranchName) _, err := s.repo.Reference(refName, true) if err == nil { @@ -1413,7 +1413,7 @@ func (s *GitStore) ensureSessionsBranch() error { } // Create orphan branch with empty tree - emptyTreeHash, err := BuildTreeFromEntries(s.repo, make(map[string]object.TreeEntry)) + emptyTreeHash, err := BuildTreeFromEntries(ctx, s.repo, make(map[string]object.TreeEntry)) if err != nil { return err } diff --git a/cmd/entire/cli/checkpoint/parse_tree.go b/cmd/entire/cli/checkpoint/parse_tree.go index 63f97a0a4..b830d62f6 100644 --- a/cmd/entire/cli/checkpoint/parse_tree.go +++ b/cmd/entire/cli/checkpoint/parse_tree.go @@ -1,10 +1,15 @@ package checkpoint import ( + "context" + "errors" "fmt" + "log/slog" + "path/filepath" "strings" "github.com/entireio/cli/cmd/entire/cli/checkpoint/id" + "github.com/entireio/cli/cmd/entire/cli/logging" "github.com/entireio/cli/cmd/entire/cli/paths" "github.com/go-git/go-git/v6" @@ -196,6 +201,7 @@ func storeTree(repo *git.Repository, entries []object.TreeEntry) (plumbing.Hash, // Unchanged subdirectories retain their hashes — this is the key optimization // over FlattenTree + BuildTreeFromEntries for sparse changes. func ApplyTreeChanges( + ctx context.Context, repo *git.Repository, rootTreeHash plumbing.Hash, changes []TreeChange, @@ -224,12 +230,19 @@ func ApplyTreeChanges( for i := range changes { c := changes[i] - first, rest := splitFirstSegment(c.Path) + normalizedPath, err := normalizeGitTreePath(c.Path) + if err != nil { + logInvalidGitTreePath(ctx, "apply tree change", c.Path, err) + continue + } + + first, rest := splitFirstSegment(normalizedPath) if grouped[first] == nil { grouped[first] = &dirChanges{} } if rest == "" { cc := c + cc.Path = normalizedPath grouped[first].fileChange = &cc } else { grouped[first].subChanges = append(grouped[first].subChanges, TreeChange{ @@ -263,7 +276,7 @@ func ApplyTreeChanges( if existing, ok := entryMap[name]; ok && existing.Mode == filemode.Dir { existingHash = existing.Hash } - newSubHash, err := ApplyTreeChanges(repo, existingHash, dc.subChanges) + newSubHash, err := ApplyTreeChanges(ctx, repo, existingHash, dc.subChanges) if err != nil { return plumbing.ZeroHash, fmt.Errorf("failed to apply changes in %s: %w", name, err) } @@ -319,6 +332,50 @@ func WalkCheckpointShards(repo *git.Repository, tree *object.Tree, fn func(cpID return nil } +func normalizeGitTreePath(path string) (string, error) { + if path == "" { + return "", errors.New("path is empty") + } + + path = filepath.ToSlash(path) + if isAbsoluteGitTreePath(path) { + return "", errors.New("path must be relative") + } + + parts := strings.Split(path, "/") + for _, part := range parts { + if part == "" { + return "", errors.New("path contains empty segment") + } + if part == "." || part == ".." { + return "", fmt.Errorf("path contains invalid segment %q", part) + } + } + + return path, nil +} + +func isAbsoluteGitTreePath(path string) bool { + if filepath.IsAbs(path) { + return true + } + + if len(path) >= 3 && path[1] == ':' && path[2] == '/' { + drive := path[0] + return (drive >= 'a' && drive <= 'z') || (drive >= 'A' && drive <= 'Z') + } + + return false +} + +func logInvalidGitTreePath(ctx context.Context, operation, path string, err error) { + logging.Warn(ctx, "skipping invalid git tree path", + slog.String("operation", operation), + slog.String("path", path), + slog.String("error", err.Error()), + ) +} + // splitFirstSegment splits "a/b/c" into ("a", "b/c"), and "file.txt" into ("file.txt", ""). func splitFirstSegment(path string) (first, rest string) { parts := strings.SplitN(path, "/", 2) diff --git a/cmd/entire/cli/checkpoint/parse_tree_test.go b/cmd/entire/cli/checkpoint/parse_tree_test.go index 90c77e471..6e949fda7 100644 --- a/cmd/entire/cli/checkpoint/parse_tree_test.go +++ b/cmd/entire/cli/checkpoint/parse_tree_test.go @@ -1,6 +1,7 @@ package checkpoint import ( + "context" "fmt" "testing" @@ -84,6 +85,25 @@ func flattenTreeHelper(t *testing.T, repo *git.Repository, treeHash plumbing.Has return result } +// assertNoEmptyEntryNames recursively verifies that a tree contains no empty entry names. +func assertNoEmptyEntryNames(t *testing.T, repo *git.Repository, treeHash plumbing.Hash, prefix string) { + t.Helper() + + tree := mustTreeObject(t, repo, treeHash) + for _, entry := range tree.Entries { + fullPath := entry.Name + if prefix != "" { + fullPath = prefix + "/" + entry.Name + } + if entry.Name == "" { + t.Fatalf("tree %s contains empty entry name at %q", treeHash, fullPath) + } + if entry.Mode == filemode.Dir { + assertNoEmptyEntryNames(t, repo, entry.Hash, fullPath) + } + } +} + func TestSplitFirstSegment(t *testing.T) { t.Parallel() @@ -137,6 +157,131 @@ func TestStoreTree_RoundTrip(t *testing.T) { } } +func TestApplyTreeChanges_SkipsInvalidPaths(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + path string + wantPresent string + }{ + { + name: "leading slash windows path", + path: "/C:/Users/r/Vaults/Flowsign/.entire/metadata/test-session/full.jsonl", + wantPresent: "valid.txt", + }, + { + name: "drive letter windows path", + path: "C:/Users/r/Vaults/Flowsign/.entire/metadata/test-session/full.jsonl", + wantPresent: "valid.txt", + }, + { + name: "empty segment", + path: "dir//file.txt", + wantPresent: "valid.txt", + }, + { + name: "dot segment", + path: "./dir/file.txt", + wantPresent: "valid.txt", + }, + { + name: "dot dot segment", + path: "../dir/file.txt", + wantPresent: "valid.txt", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + repo := mustInitBareRepo(t) + validBlob := storeBlob(t, repo, "valid") + invalidBlob := storeBlob(t, repo, "invalid") + + treeHash, err := ApplyTreeChanges(context.Background(), repo, plumbing.ZeroHash, []TreeChange{ + { + Path: "valid.txt", + Entry: &object.TreeEntry{ + Mode: filemode.Regular, + Hash: validBlob, + }, + }, + { + Path: tt.path, + Entry: &object.TreeEntry{ + Mode: filemode.Regular, + Hash: invalidBlob, + }, + }, + }) + if err != nil { + t.Fatalf("ApplyTreeChanges() error = %v", err) + } + + assertNoEmptyEntryNames(t, repo, treeHash, "") + files := flattenTreeHelper(t, repo, treeHash, "") + if len(files) != 1 { + t.Fatalf("expected 1 valid file, got %d: %v", len(files), files) + } + if files[tt.wantPresent] != validBlob { + t.Fatalf("expected valid file %q to be preserved", tt.wantPresent) + } + }) + } +} + +func TestBuildTreeFromEntries_SkipsInvalidPaths(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + path string + }{ + {name: "leading slash windows path", path: "/C:/repo/file.txt"}, + {name: "drive letter windows path", path: "C:/repo/file.txt"}, + {name: "empty segment", path: "dir//file.txt"}, + {name: "dot segment", path: "./file.txt"}, + {name: "dot dot segment", path: "../file.txt"}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + repo := mustInitBareRepo(t) + validBlob := storeBlob(t, repo, "valid") + invalidBlob := storeBlob(t, repo, "invalid") + + treeHash, err := BuildTreeFromEntries(context.Background(), repo, map[string]object.TreeEntry{ + "valid.txt": { + Name: "valid.txt", + Mode: filemode.Regular, + Hash: validBlob, + }, + tt.path: { + Name: tt.path, + Mode: filemode.Regular, + Hash: invalidBlob, + }, + }) + if err != nil { + t.Fatalf("BuildTreeFromEntries() error = %v", err) + } + + assertNoEmptyEntryNames(t, repo, treeHash, "") + files := flattenTreeHelper(t, repo, treeHash, "") + if len(files) != 1 { + t.Fatalf("expected 1 valid file, got %d: %v", len(files), files) + } + if files["valid.txt"] != validBlob { + t.Fatal("expected valid.txt to be preserved") + } + }) + } +} + func TestUpdateSubtree_CreateFromEmpty(t *testing.T) { t.Parallel() repo := mustInitBareRepo(t) @@ -452,7 +597,7 @@ func TestApplyTreeChanges_Empty(t *testing.T) { }) // No changes should return the same hash - result, err := ApplyTreeChanges(repo, rootTree, nil) + result, err := ApplyTreeChanges(context.Background(), repo, rootTree, nil) if err != nil { t.Fatalf("ApplyTreeChanges() error = %v", err) } @@ -471,7 +616,7 @@ func TestApplyTreeChanges_AddFile(t *testing.T) { {Name: "existing.txt", Mode: filemode.Regular, Hash: blob1}, }) - result, err := ApplyTreeChanges(repo, rootTree, []TreeChange{ + result, err := ApplyTreeChanges(context.Background(), repo, rootTree, []TreeChange{ {Path: "new.txt", Entry: &object.TreeEntry{ Name: "new.txt", Mode: filemode.Regular, Hash: blob2, }}, @@ -503,7 +648,7 @@ func TestApplyTreeChanges_DeleteFile(t *testing.T) { {Name: "keep.txt", Mode: filemode.Regular, Hash: blob1}, }) - result, err := ApplyTreeChanges(repo, rootTree, []TreeChange{ + result, err := ApplyTreeChanges(context.Background(), repo, rootTree, []TreeChange{ {Path: "delete.txt", Entry: nil}, // nil Entry means delete }) if err != nil { @@ -537,7 +682,7 @@ func TestApplyTreeChanges_ModifyNestedFile(t *testing.T) { }) // Modify src/handler.go - result, err := ApplyTreeChanges(repo, rootTree, []TreeChange{ + result, err := ApplyTreeChanges(context.Background(), repo, rootTree, []TreeChange{ {Path: "src/handler.go", Entry: &object.TreeEntry{ Name: "handler.go", Mode: filemode.Regular, Hash: blobNew, }}, @@ -575,7 +720,7 @@ func TestApplyTreeChanges_MultipleDirectories(t *testing.T) { }) // Modify dir1/a.txt and dir3/c.txt, leave dir2 untouched - result, err := ApplyTreeChanges(repo, rootTree, []TreeChange{ + result, err := ApplyTreeChanges(context.Background(), repo, rootTree, []TreeChange{ {Path: "dir1/a.txt", Entry: &object.TreeEntry{ Name: "a.txt", Mode: filemode.Regular, Hash: blobNew, }}, @@ -614,7 +759,7 @@ func TestApplyTreeChanges_CreateNestedFromEmpty(t *testing.T) { blob := storeBlob(t, repo, "deep-content") // Start from empty tree - result, err := ApplyTreeChanges(repo, plumbing.ZeroHash, []TreeChange{ + result, err := ApplyTreeChanges(context.Background(), repo, plumbing.ZeroHash, []TreeChange{ {Path: "a/b/c/file.txt", Entry: &object.TreeEntry{ Name: "file.txt", Mode: filemode.Regular, Hash: blob, }}, @@ -649,7 +794,7 @@ func TestApplyTreeChanges_MixedOperations(t *testing.T) { {Name: "modify.txt", Mode: filemode.Regular, Hash: blobOld}, }) - result, err := ApplyTreeChanges(repo, rootTree, []TreeChange{ + result, err := ApplyTreeChanges(context.Background(), repo, rootTree, []TreeChange{ // Delete {Path: "delete.txt", Entry: nil}, // Modify @@ -738,7 +883,7 @@ func TestUpdateSubtree_EquivalenceWithFlattenRebuild(t *testing.T) { Mode: filemode.Regular, Hash: newBlob, } - flatResult, err := BuildTreeFromEntries(repo, flatEntries) + flatResult, err := BuildTreeFromEntries(context.Background(), repo, flatEntries) if err != nil { t.Fatalf("BuildTreeFromEntries() error = %v", err) } diff --git a/cmd/entire/cli/checkpoint/temporary.go b/cmd/entire/cli/checkpoint/temporary.go index f4d22e926..4cda2c622 100644 --- a/cmd/entire/cli/checkpoint/temporary.go +++ b/cmd/entire/cli/checkpoint/temporary.go @@ -425,7 +425,7 @@ func (s *GitStore) addTaskMetadataToTree(ctx context.Context, baseTreeHash plumb }) } - return ApplyTreeChanges(s.repo, baseTreeHash, changes) + return ApplyTreeChanges(ctx, s.repo, baseTreeHash, changes) } // ListTemporaryCheckpoints lists all checkpoint commits on a shadow branch. @@ -733,15 +733,26 @@ func (s *GitStore) buildTreeWithChanges( // Deleted files → nil Entry means deletion for _, file := range deletedFiles { - changes = append(changes, TreeChange{Path: file, Entry: nil}) + relPath, relErr := normalizeRepoRelativeTreePath(repoRoot, file) + if relErr != nil { + logInvalidGitTreePath(ctx, "delete shadow branch entry", file, relErr) + continue + } + changes = append(changes, TreeChange{Path: relPath, Entry: nil}) } // Modified/new files → create blobs from disk for _, file := range modifiedFiles { - absPath := filepath.Join(repoRoot, file) + relPath, relErr := normalizeRepoRelativeTreePath(repoRoot, file) + if relErr != nil { + logInvalidGitTreePath(ctx, "add shadow branch entry", file, relErr) + continue + } + + absPath := filepath.Join(repoRoot, filepath.FromSlash(relPath)) if !fileExists(absPath) { // File disappeared since detection — treat as deletion - changes = append(changes, TreeChange{Path: file, Entry: nil}) + changes = append(changes, TreeChange{Path: relPath, Entry: nil}) continue } @@ -752,7 +763,7 @@ func (s *GitStore) buildTreeWithChanges( } changes = append(changes, TreeChange{ - Path: file, + Path: relPath, Entry: &object.TreeEntry{ Mode: mode, Hash: blobHash, @@ -762,14 +773,19 @@ func (s *GitStore) buildTreeWithChanges( // Metadata directory files if metadataDir != "" && metadataDirAbs != "" { - metaChanges, metaErr := addDirectoryToChanges(s.repo, metadataDirAbs, metadataDir) - if metaErr != nil { - return plumbing.ZeroHash, fmt.Errorf("failed to add metadata directory: %w", metaErr) + metadataRel, relErr := normalizeRepoRelativeTreePath(repoRoot, metadataDir) + if relErr != nil { + logInvalidGitTreePath(ctx, "add metadata directory", metadataDir, relErr) + } else { + metaChanges, metaErr := addDirectoryToChanges(s.repo, metadataDirAbs, metadataRel) + if metaErr != nil { + return plumbing.ZeroHash, fmt.Errorf("failed to add metadata directory: %w", metaErr) + } + changes = append(changes, metaChanges...) } - changes = append(changes, metaChanges...) } - return ApplyTreeChanges(s.repo, baseTreeHash, changes) + return ApplyTreeChanges(ctx, s.repo, baseTreeHash, changes) } // createCommit creates a commit object. @@ -984,7 +1000,7 @@ func addDirectoryToChanges(repo *git.Repository, dirPathAbs, dirPathRel string) // BuildTreeFromEntries builds a proper git tree structure from flattened file entries. // Exported for use by strategy package (push_common.go, session_test.go) -func BuildTreeFromEntries(repo *git.Repository, entries map[string]object.TreeEntry) (plumbing.Hash, error) { +func BuildTreeFromEntries(ctx context.Context, repo *git.Repository, entries map[string]object.TreeEntry) (plumbing.Hash, error) { // Build a tree structure root := &treeNode{ entries: make(map[string]*treeNode), @@ -993,7 +1009,12 @@ func BuildTreeFromEntries(repo *git.Repository, entries map[string]object.TreeEn // Insert all entries into the tree structure for fullPath, entry := range entries { - parts := strings.Split(fullPath, "/") + normalizedPath, err := normalizeGitTreePath(fullPath) + if err != nil { + logInvalidGitTreePath(ctx, "build tree entry", fullPath, err) + continue + } + parts := strings.Split(normalizedPath, "/") insertIntoTree(root, parts, entry) } @@ -1001,6 +1022,14 @@ func BuildTreeFromEntries(repo *git.Repository, entries map[string]object.TreeEn return buildTreeObject(repo, root) } +func normalizeRepoRelativeTreePath(repoRoot, path string) (string, error) { + if rel := paths.ToRelativePath(path, repoRoot); rel != "" && rel != "." { + return normalizeGitTreePath(rel) + } + + return normalizeGitTreePath(path) +} + // insertIntoTree inserts a file entry into the tree structure. func insertIntoTree(node *treeNode, pathParts []string, entry object.TreeEntry) { if len(pathParts) == 1 { diff --git a/cmd/entire/cli/checkpoint/tree_surgery_equiv_test.go b/cmd/entire/cli/checkpoint/tree_surgery_equiv_test.go index 481c92e37..2ab096e12 100644 --- a/cmd/entire/cli/checkpoint/tree_surgery_equiv_test.go +++ b/cmd/entire/cli/checkpoint/tree_surgery_equiv_test.go @@ -272,7 +272,7 @@ func flattenRebuildTree( } } - hash, err := BuildTreeFromEntries(repo, entries) + hash, err := BuildTreeFromEntries(context.Background(), repo, entries) if err != nil { t.Fatalf("build tree: %v", err) } @@ -318,7 +318,7 @@ func flattenRebuildTaskMetadata( Hash: blobHash, } - hash, err := BuildTreeFromEntries(repo, entries) + hash, err := BuildTreeFromEntries(context.Background(), repo, entries) if err != nil { t.Fatalf("build tree: %v", err) } diff --git a/cmd/entire/cli/checkpoint/v2_committed.go b/cmd/entire/cli/checkpoint/v2_committed.go index 1ad5b39ca..bc712da2a 100644 --- a/cmd/entire/cli/checkpoint/v2_committed.go +++ b/cmd/entire/cli/checkpoint/v2_committed.go @@ -171,7 +171,7 @@ func (s *V2GitStore) updateCommittedMain(ctx context.Context, opts UpdateCommitt } } - newTreeHash, err := s.gs.spliceCheckpointSubtree(rootTreeHash, opts.CheckpointID, basePath, entries) + newTreeHash, err := s.gs.spliceCheckpointSubtree(ctx, rootTreeHash, opts.CheckpointID, basePath, entries) if err != nil { return 0, err } @@ -189,7 +189,7 @@ func (s *V2GitStore) updateCommittedMain(ctx context.Context, opts UpdateCommitt // on /full/current while preserving other checkpoints' transcripts in the tree. func (s *V2GitStore) updateCommittedFullTranscript(ctx context.Context, opts UpdateCommittedOptions, sessionIndex int) error { refName := plumbing.ReferenceName(paths.V2FullCurrentRefName) - if err := s.ensureRef(refName); err != nil { + if err := s.ensureRef(ctx, refName); err != nil { return fmt.Errorf("failed to ensure /full/current ref: %w", err) } @@ -225,7 +225,7 @@ func (s *V2GitStore) updateCommittedFullTranscript(ctx context.Context, opts Upd } // Splice into existing root tree (preserves other checkpoints' transcripts) - newTreeHash, err := s.gs.spliceCheckpointSubtree(rootTreeHash, opts.CheckpointID, basePath, entries) + newTreeHash, err := s.gs.spliceCheckpointSubtree(ctx, rootTreeHash, opts.CheckpointID, basePath, entries) if err != nil { return err } @@ -241,7 +241,7 @@ func (s *V2GitStore) updateCommittedFullTranscript(ctx context.Context, opts Upd // Returns the session index used, so the caller can pass it to writeCommittedFullTranscript. func (s *V2GitStore) writeCommittedMain(ctx context.Context, opts WriteCommittedOptions) (int, error) { refName := plumbing.ReferenceName(paths.V2MainRefName) - if err := s.ensureRef(refName); err != nil { + if err := s.ensureRef(ctx, refName); err != nil { return 0, fmt.Errorf("failed to ensure /main ref: %w", err) } @@ -266,7 +266,7 @@ func (s *V2GitStore) writeCommittedMain(ctx context.Context, opts WriteCommitted } // Splice entries into root tree - newTreeHash, err := s.gs.spliceCheckpointSubtree(rootTreeHash, opts.CheckpointID, basePath, entries) + newTreeHash, err := s.gs.spliceCheckpointSubtree(ctx, rootTreeHash, opts.CheckpointID, basePath, entries) if err != nil { return 0, err } @@ -461,7 +461,7 @@ func (s *V2GitStore) writeCommittedFullTranscript(ctx context.Context, opts Writ } refName := plumbing.ReferenceName(paths.V2FullCurrentRefName) - if err := s.ensureRef(refName); err != nil { + if err := s.ensureRef(ctx, refName); err != nil { return fmt.Errorf("failed to ensure /full/current ref: %w", err) } @@ -497,7 +497,7 @@ func (s *V2GitStore) writeCommittedFullTranscript(ctx context.Context, opts Writ } // Splice checkpoint data into the root tree (preserves other checkpoints' transcripts) - newTreeHash, err := s.gs.spliceCheckpointSubtree(rootTreeHash, opts.CheckpointID, basePath, entries) + newTreeHash, err := s.gs.spliceCheckpointSubtree(ctx, rootTreeHash, opts.CheckpointID, basePath, entries) if err != nil { return err } @@ -637,7 +637,7 @@ func (s *V2GitStore) UpdateSummary(ctx context.Context, checkpointID id.Checkpoi Hash: metadataHash, } - newTreeHash, err := s.gs.spliceCheckpointSubtree(rootTreeHash, checkpointID, basePath, entries) + newTreeHash, err := s.gs.spliceCheckpointSubtree(ctx, rootTreeHash, checkpointID, basePath, entries) if err != nil { return err } diff --git a/cmd/entire/cli/checkpoint/v2_generation.go b/cmd/entire/cli/checkpoint/v2_generation.go index 620afc78e..ff2d83c7a 100644 --- a/cmd/entire/cli/checkpoint/v2_generation.go +++ b/cmd/entire/cli/checkpoint/v2_generation.go @@ -324,7 +324,7 @@ func (s *V2GitStore) rotateGeneration(ctx context.Context) error { } // Phase 2: Create fresh orphan /full/current (empty tree, no generation.json) - emptyTreeHash, err := BuildTreeFromEntries(s.repo, make(map[string]object.TreeEntry)) + emptyTreeHash, err := BuildTreeFromEntries(ctx, s.repo, make(map[string]object.TreeEntry)) if err != nil { return fmt.Errorf("rotation: failed to build empty tree: %w", err) } diff --git a/cmd/entire/cli/checkpoint/v2_generation_test.go b/cmd/entire/cli/checkpoint/v2_generation_test.go index 724e8d4ab..b9094a8c4 100644 --- a/cmd/entire/cli/checkpoint/v2_generation_test.go +++ b/cmd/entire/cli/checkpoint/v2_generation_test.go @@ -23,7 +23,7 @@ func TestReadGeneration_EmptyTree_ReturnsDefault(t *testing.T) { store := NewV2GitStore(repo, "origin") // Build an empty tree - emptyTree, err := BuildTreeFromEntries(repo, map[string]object.TreeEntry{}) + emptyTree, err := BuildTreeFromEntries(context.Background(), repo, map[string]object.TreeEntry{}) require.NoError(t, err) gen, err := store.readGeneration(emptyTree) @@ -48,7 +48,7 @@ func TestReadGeneration_ParsesJSON(t *testing.T) { entries := make(map[string]object.TreeEntry) require.NoError(t, store.writeGeneration(original, entries)) - treeHash, err := BuildTreeFromEntries(repo, entries) + treeHash, err := BuildTreeFromEntries(context.Background(), repo, entries) require.NoError(t, err) // Read it back @@ -78,7 +78,7 @@ func TestWriteGeneration_RoundTrips(t *testing.T) { assert.True(t, ok) // Build tree and read back - treeHash, err := BuildTreeFromEntries(repo, entries) + treeHash, err := BuildTreeFromEntries(context.Background(), repo, entries) require.NoError(t, err) gen, err := store.readGeneration(treeHash) @@ -102,7 +102,7 @@ func TestReadGenerationFromRef(t *testing.T) { entries := make(map[string]object.TreeEntry) require.NoError(t, store.writeGeneration(gen, entries)) - treeHash, err := BuildTreeFromEntries(repo, entries) + treeHash, err := BuildTreeFromEntries(context.Background(), repo, entries) require.NoError(t, err) refName := plumbing.ReferenceName(paths.V2FullCurrentRefName) @@ -131,7 +131,7 @@ func TestAddGenerationJSONToTree(t *testing.T) { Mode: 0o100644, Hash: plumbing.ZeroHash, // dummy } - rootTreeHash, err := BuildTreeFromEntries(repo, shardEntries) + rootTreeHash, err := BuildTreeFromEntries(context.Background(), repo, shardEntries) require.NoError(t, err) gen := GenerationMetadata{ @@ -292,7 +292,7 @@ func createArchivedRef(t *testing.T, repo *git.Repository, number int) { } entries := make(map[string]object.TreeEntry) require.NoError(t, store.writeGeneration(gen, entries)) - treeHash, err := BuildTreeFromEntries(repo, entries) + treeHash, err := BuildTreeFromEntries(context.Background(), repo, entries) require.NoError(t, err) authorName, authorEmail := GetGitAuthorFromRepo(repo) @@ -332,7 +332,7 @@ func TestListArchivedGenerations_ExcludesCurrent(t *testing.T) { store := NewV2GitStore(repo, "origin") // Create /full/current ref - require.NoError(t, store.ensureRef(plumbing.ReferenceName(paths.V2FullCurrentRefName))) + require.NoError(t, store.ensureRef(context.Background(), plumbing.ReferenceName(paths.V2FullCurrentRefName))) // Create an archived ref createArchivedRef(t, repo, 1) @@ -497,7 +497,7 @@ func TestReadGeneration_BackwardCompatible(t *testing.T) { Hash: blobHash, }, } - treeHash, err := BuildTreeFromEntries(repo, entries) + treeHash, err := BuildTreeFromEntries(context.Background(), repo, entries) require.NoError(t, err) // Should parse without error, ignoring the unknown checkpoints field diff --git a/cmd/entire/cli/checkpoint/v2_read_test.go b/cmd/entire/cli/checkpoint/v2_read_test.go index 5475981a9..514c7572d 100644 --- a/cmd/entire/cli/checkpoint/v2_read_test.go +++ b/cmd/entire/cli/checkpoint/v2_read_test.go @@ -199,7 +199,7 @@ func TestV2ReadSessionContent_ChunkedTranscript(t *testing.T) { chunk1 := []byte(`{"line":"three"}` + "\n" + `{"line":"four"}`) refName := plumbing.ReferenceName(paths.V2FullCurrentRefName) - err = v2Store.ensureRef(refName) + err = v2Store.ensureRef(context.Background(), refName) require.NoError(t, err) _, rootTreeHash, err := v2Store.GetRefState(refName) @@ -226,7 +226,7 @@ func TestV2ReadSessionContent_ChunkedTranscript(t *testing.T) { }, } - newTreeHash, err := v2Store.gs.spliceCheckpointSubtree(rootTreeHash, cpID, cpID.Path()+"/", entries) + newTreeHash, err := v2Store.gs.spliceCheckpointSubtree(context.Background(), rootTreeHash, cpID, cpID.Path()+"/", entries) require.NoError(t, err) parentHash, _, err := v2Store.GetRefState(refName) diff --git a/cmd/entire/cli/checkpoint/v2_store.go b/cmd/entire/cli/checkpoint/v2_store.go index 7a63fe01f..059080262 100644 --- a/cmd/entire/cli/checkpoint/v2_store.go +++ b/cmd/entire/cli/checkpoint/v2_store.go @@ -1,6 +1,7 @@ package checkpoint import ( + "context" "fmt" "github.com/go-git/go-git/v6" @@ -55,13 +56,13 @@ func NewV2GitStore(repo *git.Repository, fetchRemote string) *V2GitStore { // ensureRef ensures that a custom ref exists, creating an orphan commit // with an empty tree if it does not. -func (s *V2GitStore) ensureRef(refName plumbing.ReferenceName) error { +func (s *V2GitStore) ensureRef(ctx context.Context, refName plumbing.ReferenceName) error { _, err := s.repo.Reference(refName, true) if err == nil { return nil // Already exists } - emptyTreeHash, err := BuildTreeFromEntries(s.repo, make(map[string]object.TreeEntry)) + emptyTreeHash, err := BuildTreeFromEntries(ctx, s.repo, make(map[string]object.TreeEntry)) if err != nil { return fmt.Errorf("failed to build empty tree: %w", err) } diff --git a/cmd/entire/cli/checkpoint/v2_store_test.go b/cmd/entire/cli/checkpoint/v2_store_test.go index 3e4887713..c0762a7f6 100644 --- a/cmd/entire/cli/checkpoint/v2_store_test.go +++ b/cmd/entire/cli/checkpoint/v2_store_test.go @@ -62,7 +62,7 @@ func TestV2GitStore_EnsureRef_CreatesNewRef(t *testing.T) { require.Error(t, err) // Ensure creates it - require.NoError(t, store.ensureRef(refName)) + require.NoError(t, store.ensureRef(context.Background(), refName)) // Ref should now exist and point to a valid commit with an empty tree ref, err := repo.Reference(refName, true) @@ -83,12 +83,12 @@ func TestV2GitStore_EnsureRef_Idempotent(t *testing.T) { refName := plumbing.ReferenceName(paths.V2MainRefName) - require.NoError(t, store.ensureRef(refName)) + require.NoError(t, store.ensureRef(context.Background(), refName)) ref1, err := repo.Reference(refName, true) require.NoError(t, err) // Second call should be a no-op — same commit hash - require.NoError(t, store.ensureRef(refName)) + require.NoError(t, store.ensureRef(context.Background(), refName)) ref2, err := repo.Reference(refName, true) require.NoError(t, err) require.Equal(t, ref1.Hash(), ref2.Hash()) @@ -102,8 +102,8 @@ func TestV2GitStore_EnsureRef_DifferentRefs(t *testing.T) { mainRef := plumbing.ReferenceName(paths.V2MainRefName) fullRef := plumbing.ReferenceName(paths.V2FullCurrentRefName) - require.NoError(t, store.ensureRef(mainRef)) - require.NoError(t, store.ensureRef(fullRef)) + require.NoError(t, store.ensureRef(context.Background(), mainRef)) + require.NoError(t, store.ensureRef(context.Background(), fullRef)) // Both should exist independently _, err := repo.Reference(mainRef, true) @@ -118,7 +118,7 @@ func TestV2GitStore_GetRefState_ReturnsParentAndTree(t *testing.T) { store := NewV2GitStore(repo, "origin") refName := plumbing.ReferenceName(paths.V2MainRefName) - require.NoError(t, store.ensureRef(refName)) + require.NoError(t, store.ensureRef(context.Background(), refName)) parentHash, treeHash, err := store.GetRefState(refName) require.NoError(t, err) @@ -143,7 +143,7 @@ func TestV2GitStore_UpdateRef_CreatesCommit(t *testing.T) { store := NewV2GitStore(repo, "origin") refName := plumbing.ReferenceName(paths.V2MainRefName) - require.NoError(t, store.ensureRef(refName)) + require.NoError(t, store.ensureRef(context.Background(), refName)) parentHash, treeHash, err := store.GetRefState(refName) require.NoError(t, err) @@ -155,7 +155,7 @@ func TestV2GitStore_UpdateRef_CreatesCommit(t *testing.T) { entries := map[string]object.TreeEntry{ "test.txt": {Name: "test.txt", Mode: 0o100644, Hash: blobHash}, } - newTreeHash, err := BuildTreeFromEntries(repo, entries) + newTreeHash, err := BuildTreeFromEntries(context.Background(), repo, entries) require.NoError(t, err) require.NotEqual(t, treeHash, newTreeHash) diff --git a/cmd/entire/cli/migrate_test.go b/cmd/entire/cli/migrate_test.go index 6e23eef68..b013277ed 100644 --- a/cmd/entire/cli/migrate_test.go +++ b/cmd/entire/cli/migrate_test.go @@ -59,7 +59,7 @@ func buildTasksTreeHash(t *testing.T, repo *git.Repository, toolUseID string) pl blobHash, err := checkpoint.CreateBlobFromContent(repo, []byte(`{"tool_use_id":"`+toolUseID+`"}`)) require.NoError(t, err) - treeHash, err := checkpoint.BuildTreeFromEntries(repo, map[string]object.TreeEntry{ + treeHash, err := checkpoint.BuildTreeFromEntries(context.Background(), repo, map[string]object.TreeEntry{ toolUseID + "/checkpoint.json": {Mode: filemode.Regular, Hash: blobHash}, }) require.NoError(t, err) diff --git a/cmd/entire/cli/strategy/cleanup.go b/cmd/entire/cli/strategy/cleanup.go index 842573fce..d926c8d03 100644 --- a/cmd/entire/cli/strategy/cleanup.go +++ b/cmd/entire/cli/strategy/cleanup.go @@ -293,7 +293,7 @@ func DeleteOrphanedCheckpoints(ctx context.Context, checkpointIDs []string) (del } // Build new tree - newTreeHash, err := checkpoint.BuildTreeFromEntries(repo, entries) + newTreeHash, err := checkpoint.BuildTreeFromEntries(ctx, repo, entries) if err != nil { return nil, nil, fmt.Errorf("failed to build tree: %w", err) } diff --git a/cmd/entire/cli/strategy/content_overlap_test.go b/cmd/entire/cli/strategy/content_overlap_test.go index 65c149dd9..ce69ccd80 100644 --- a/cmd/entire/cli/strategy/content_overlap_test.go +++ b/cmd/entire/cli/strategy/content_overlap_test.go @@ -750,7 +750,7 @@ func TestFilesWithRemainingAgentChanges_UncommittedDeletion(t *testing.T) { require.NoError(t, err) delete(entries, "to_delete.txt") - treeHash, err := checkpoint.BuildTreeFromEntries(repo, entries) + treeHash, err := checkpoint.BuildTreeFromEntries(context.Background(), repo, entries) require.NoError(t, err) shadowCommitObj := &object.Commit{ @@ -1039,7 +1039,7 @@ func createShadowBranchWithContent(t *testing.T, repo *git.Repository, baseCommi } // Build tree from entries - treeHash, err := checkpoint.BuildTreeFromEntries(repo, entries) + treeHash, err := checkpoint.BuildTreeFromEntries(context.Background(), repo, entries) require.NoError(t, err) // Create commit diff --git a/cmd/entire/cli/strategy/metadata_reconcile.go b/cmd/entire/cli/strategy/metadata_reconcile.go index 696261a10..41ec0fc48 100644 --- a/cmd/entire/cli/strategy/metadata_reconcile.go +++ b/cmd/entire/cli/strategy/metadata_reconcile.go @@ -180,7 +180,7 @@ func ReconcileDisconnectedMetadataBranch( fmt.Fprintf(w, "[entire] Cherry-picking %d local checkpoint(s) onto remote...\n", len(dataCommits)) - newTip, err := cherryPickOnto(repo, remoteHash, dataCommits) + newTip, err := cherryPickOnto(ctx, repo, remoteHash, dataCommits) if err != nil { return fmt.Errorf("failed to cherry-pick local commits onto remote: %w", err) } @@ -253,7 +253,7 @@ func collectCommitChain(repo *git.Repository, tip plumbing.Hash) ([]*object.Comm // cherryPickOnto applies each commit's delta onto base, building a linear chain. // For each commit, it computes the full diff from its parent (additions, modifications, // and deletions), then applies that delta onto the current tip's tree. -func cherryPickOnto(repo *git.Repository, base plumbing.Hash, commits []*object.Commit) (plumbing.Hash, error) { +func cherryPickOnto(ctx context.Context, repo *git.Repository, base plumbing.Hash, commits []*object.Commit) (plumbing.Hash, error) { currentTip := base for _, commit := range commits { @@ -324,7 +324,7 @@ func cherryPickOnto(repo *git.Repository, base plumbing.Hash, commits []*object. delete(mergedEntries, path) } - mergedTreeHash, err := checkpoint.BuildTreeFromEntries(repo, mergedEntries) + mergedTreeHash, err := checkpoint.BuildTreeFromEntries(ctx, repo, mergedEntries) if err != nil { return plumbing.ZeroHash, fmt.Errorf("failed to build merged tree: %w", err) } diff --git a/cmd/entire/cli/strategy/push_common.go b/cmd/entire/cli/strategy/push_common.go index 1f464d2cc..72b282e97 100644 --- a/cmd/entire/cli/strategy/push_common.go +++ b/cmd/entire/cli/strategy/push_common.go @@ -284,7 +284,7 @@ func fetchAndRebaseSessionsCommon(ctx context.Context, target, branchName string return nil } - newTip, err := cherryPickOnto(repo, remoteRef.Hash(), localCommits) + newTip, err := cherryPickOnto(ctx, repo, remoteRef.Hash(), localCommits) if err != nil { return fmt.Errorf("failed to rebase local commits onto remote: %w", err) } diff --git a/cmd/entire/cli/strategy/push_v2.go b/cmd/entire/cli/strategy/push_v2.go index a778077b6..b94b2cf08 100644 --- a/cmd/entire/cli/strategy/push_v2.go +++ b/cmd/entire/cli/strategy/push_v2.go @@ -179,7 +179,7 @@ func fetchAndMergeRef(ctx context.Context, target string, refName plumbing.Refer return fmt.Errorf("failed to flatten remote tree: %w", err) } - mergedTreeHash, err := checkpoint.BuildTreeFromEntries(repo, entries) + mergedTreeHash, err := checkpoint.BuildTreeFromEntries(ctx, repo, entries) if err != nil { return fmt.Errorf("failed to build merged tree: %w", err) } @@ -309,7 +309,7 @@ func handleRotationConflict(ctx context.Context, target string, repo *git.Reposi } } - mergedTreeHash, err := checkpoint.BuildTreeFromEntries(repo, entries) + mergedTreeHash, err := checkpoint.BuildTreeFromEntries(ctx, repo, entries) if err != nil { return fmt.Errorf("failed to build merged tree: %w", err) } diff --git a/cmd/entire/cli/strategy/push_v2_test.go b/cmd/entire/cli/strategy/push_v2_test.go index a544e75bd..f00fe3239 100644 --- a/cmd/entire/cli/strategy/push_v2_test.go +++ b/cmd/entire/cli/strategy/push_v2_test.go @@ -36,7 +36,7 @@ func setupRepoWithV2Ref(t *testing.T) string { require.NoError(t, err) // Create v2 /main ref with an empty tree - emptyTree, err := checkpoint.BuildTreeFromEntries(repo, map[string]object.TreeEntry{}) + emptyTree, err := checkpoint.BuildTreeFromEntries(context.Background(), repo, map[string]object.TreeEntry{}) require.NoError(t, err) commitHash, err := checkpoint.CreateCommit(repo, emptyTree, plumbing.ZeroHash, @@ -331,7 +331,7 @@ func TestFetchAndMergeRef_RotationConflict(t *testing.T) { plumbing.NewHashReference(archiveRefName, archiveCommitHash))) // Create fresh orphan /full/current - emptyTree, err := checkpoint.BuildTreeFromEntries(remoteRepo, map[string]object.TreeEntry{}) + emptyTree, err := checkpoint.BuildTreeFromEntries(context.Background(), remoteRepo, map[string]object.TreeEntry{}) require.NoError(t, err) orphanHash, err := checkpoint.CreateCommit(remoteRepo, emptyTree, plumbing.ZeroHash, "Start generation", "Test", "test@test.com") diff --git a/cmd/entire/cli/strategy/session_test.go b/cmd/entire/cli/strategy/session_test.go index b242cef95..00e1d5b94 100644 --- a/cmd/entire/cli/strategy/session_test.go +++ b/cmd/entire/cli/strategy/session_test.go @@ -450,7 +450,7 @@ func createTestMultiSessionCheckpoint(t *testing.T, repo *git.Repository, checkp } // Build tree - treeHash, err := checkpoint.BuildTreeFromEntries(repo, entries) + treeHash, err := checkpoint.BuildTreeFromEntries(context.Background(), repo, entries) if err != nil { t.Fatalf("failed to build tree: %v", err) } @@ -566,7 +566,7 @@ func createTestMetadataBranchWithPrompt(t *testing.T, repo *git.Repository, sess } // Build tree - treeHash, err := checkpoint.BuildTreeFromEntries(repo, entries) + treeHash, err := checkpoint.BuildTreeFromEntries(context.Background(), repo, entries) if err != nil { t.Fatalf("failed to build tree: %v", err) } diff --git a/cmd/entire/cli/trail/store.go b/cmd/entire/cli/trail/store.go index fd484eec0..d0a25af8b 100644 --- a/cmd/entire/cli/trail/store.go +++ b/cmd/entire/cli/trail/store.go @@ -1,6 +1,7 @@ package trail import ( + "context" "encoding/json" "errors" "fmt" @@ -36,7 +37,7 @@ func NewStore(repo *git.Repository) *Store { } // EnsureBranch creates the entire/trails/v1 orphan branch if it doesn't exist. -func (s *Store) EnsureBranch() error { +func (s *Store) EnsureBranch(ctx context.Context) error { refName := plumbing.NewBranchReferenceName(paths.TrailsBranchName) _, err := s.repo.Reference(refName, true) if err == nil { @@ -44,7 +45,7 @@ func (s *Store) EnsureBranch() error { } // Create orphan branch with empty tree - emptyTreeHash, err := checkpoint.BuildTreeFromEntries(s.repo, make(map[string]object.TreeEntry)) + emptyTreeHash, err := checkpoint.BuildTreeFromEntries(ctx, s.repo, make(map[string]object.TreeEntry)) if err != nil { return fmt.Errorf("failed to build empty tree: %w", err) } @@ -64,12 +65,12 @@ func (s *Store) EnsureBranch() error { // Write writes trail metadata, discussion, and checkpoints to the entire/trails/v1 branch. // If checkpoints is nil, an empty checkpoints list is written. -func (s *Store) Write(metadata *Metadata, discussion *Discussion, checkpoints *Checkpoints) error { +func (s *Store) Write(ctx context.Context, metadata *Metadata, discussion *Discussion, checkpoints *Checkpoints) error { if metadata.TrailID.IsEmpty() { return errors.New("trail ID is required") } - if err := s.EnsureBranch(); err != nil { + if err := s.EnsureBranch(ctx); err != nil { return fmt.Errorf("failed to ensure trails branch: %w", err) } @@ -265,7 +266,7 @@ func (s *Store) List() ([]*Metadata, error) { // Update updates an existing trail's metadata. It reads the current metadata, // applies the provided update function, and writes it back. -func (s *Store) Update(trailID ID, updateFn func(*Metadata)) error { +func (s *Store) Update(ctx context.Context, trailID ID, updateFn func(*Metadata)) error { // ValidateID is called by Read, no need to duplicate here metadata, discussion, checkpoints, err := s.Read(trailID) if err != nil { @@ -275,17 +276,17 @@ func (s *Store) Update(trailID ID, updateFn func(*Metadata)) error { updateFn(metadata) metadata.UpdatedAt = time.Now() - return s.Write(metadata, discussion, checkpoints) + return s.Write(ctx, metadata, discussion, checkpoints) } // AddCheckpoint prepends a checkpoint reference to a trail's checkpoints list (newest first). // Only reads and writes the checkpoints.json file — metadata and discussion are untouched. -func (s *Store) AddCheckpoint(trailID ID, ref CheckpointRef) error { +func (s *Store) AddCheckpoint(ctx context.Context, trailID ID, ref CheckpointRef) error { if err := ValidateID(string(trailID)); err != nil { return err } - if err := s.EnsureBranch(); err != nil { + if err := s.EnsureBranch(ctx); err != nil { return fmt.Errorf("failed to ensure trails branch: %w", err) } @@ -336,12 +337,12 @@ func (s *Store) AddCheckpoint(trailID ID, ref CheckpointRef) error { } // Delete removes a trail from the entire/trails/v1 branch. -func (s *Store) Delete(trailID ID) error { +func (s *Store) Delete(ctx context.Context, trailID ID) error { if err := ValidateID(string(trailID)); err != nil { return err } - if err := s.EnsureBranch(); err != nil { + if err := s.EnsureBranch(ctx); err != nil { return fmt.Errorf("failed to ensure trails branch: %w", err) } diff --git a/cmd/entire/cli/trail/store_test.go b/cmd/entire/cli/trail/store_test.go index 7c4c4fae9..f87baea27 100644 --- a/cmd/entire/cli/trail/store_test.go +++ b/cmd/entire/cli/trail/store_test.go @@ -60,12 +60,12 @@ func TestStore_EnsureBranch(t *testing.T) { store := NewStore(repo) // First call should create the branch - if err := store.EnsureBranch(); err != nil { + if err := store.EnsureBranch(context.Background()); err != nil { t.Fatalf("EnsureBranch() error = %v", err) } // Second call should be idempotent - if err := store.EnsureBranch(); err != nil { + if err := store.EnsureBranch(context.Background()); err != nil { t.Fatalf("EnsureBranch() second call error = %v", err) } } @@ -97,7 +97,7 @@ func TestStore_WriteAndRead(t *testing.T) { discussion := &Discussion{Comments: []Comment{}} - if err := store.Write(metadata, discussion, nil); err != nil { + if err := store.Write(context.Background(), metadata, discussion, nil); err != nil { t.Fatalf("Write() error = %v", err) } @@ -152,7 +152,7 @@ func TestStore_FindByBranch(t *testing.T) { CreatedAt: now, UpdatedAt: now, } - if err := store.Write(meta, nil, nil); err != nil { + if err := store.Write(context.Background(), meta, nil, nil); err != nil { t.Fatalf("Write() error = %v", err) } } @@ -209,7 +209,7 @@ func TestStore_List(t *testing.T) { CreatedAt: now, UpdatedAt: now, } - if err := store.Write(meta, nil, nil); err != nil { + if err := store.Write(context.Background(), meta, nil, nil); err != nil { t.Fatalf("Write() error = %v", err) } @@ -247,12 +247,12 @@ func TestStore_Update(t *testing.T) { CreatedAt: now, UpdatedAt: now, } - if err := store.Write(meta, nil, nil); err != nil { + if err := store.Write(context.Background(), meta, nil, nil); err != nil { t.Fatalf("Write() error = %v", err) } // Update - if err := store.Update(id, func(m *Metadata) { + if err := store.Update(context.Background(), id, func(m *Metadata) { m.Title = "Updated" m.Status = StatusInProgress m.Labels = []string{"urgent"} @@ -301,12 +301,12 @@ func TestStore_Delete(t *testing.T) { CreatedAt: now, UpdatedAt: now, } - if err := store.Write(meta, nil, nil); err != nil { + if err := store.Write(context.Background(), meta, nil, nil); err != nil { t.Fatalf("Write() error = %v", err) } // Delete - if err := store.Delete(id); err != nil { + if err := store.Delete(context.Background(), id); err != nil { t.Fatalf("Delete() error = %v", err) } @@ -322,7 +322,7 @@ func TestStore_ReadNonExistent(t *testing.T) { repo := initTestRepo(t) store := NewStore(repo) - if err := store.EnsureBranch(); err != nil { + if err := store.EnsureBranch(context.Background()); err != nil { t.Fatalf("EnsureBranch() error = %v", err) } @@ -356,13 +356,13 @@ func TestStore_DeleteInvalidID(t *testing.T) { store := NewStore(repo) // Invalid format: uppercase hex - err := store.Delete(ID("ABCDEF123456")) + err := store.Delete(context.Background(), ID("ABCDEF123456")) if err == nil { t.Error("Delete() should fail for invalid trail ID") } // Path traversal attempt - err = store.Delete(ID("../../../etc")) + err = store.Delete(context.Background(), ID("../../../etc")) if err == nil { t.Error("Delete() should fail for path traversal ID") } @@ -396,7 +396,7 @@ func TestStore_AddCheckpointPreservesOtherFields(t *testing.T) { {ID: "c1", Author: "bob", Body: "looks good", CreatedAt: now}, }} - if err := store.Write(metadata, discussion, nil); err != nil { + if err := store.Write(context.Background(), metadata, discussion, nil); err != nil { t.Fatalf("Write() error = %v", err) } @@ -408,7 +408,7 @@ func TestStore_AddCheckpointPreservesOtherFields(t *testing.T) { CreatedAt: now, Summary: &firstSummary, } - if err := store.AddCheckpoint(trailID, cpRef); err != nil { + if err := store.AddCheckpoint(context.Background(), trailID, cpRef); err != nil { t.Fatalf("AddCheckpoint() error = %v", err) } @@ -459,7 +459,7 @@ func TestStore_AddCheckpointPreservesOtherFields(t *testing.T) { CreatedAt: now, Summary: &secondSummary, } - if err := store.AddCheckpoint(trailID, cpRef2); err != nil { + if err := store.AddCheckpoint(context.Background(), trailID, cpRef2); err != nil { t.Fatalf("AddCheckpoint() second call error = %v", err) }