diff --git a/CLAUDE.md b/CLAUDE.md index 54942c96a8..760e28df8c 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -444,6 +444,40 @@ The manual-commit strategy (`manual_commit*.go`) does not modify the active bran - PrePush hook can push `entire/checkpoints/v1` branch alongside user pushes - Safe to use on main/master since it never modifies commit history +##### v1.1: custom-ref storage (opt-in) + +When `strategy_options.checkpoints_version` is set to `1.1` (number `1.1` +or string `"1.1"`) in `.entire/settings.json`, metadata is stored at the +custom ref `refs/entire/checkpoints/v1.1` instead of the branch +`refs/heads/entire/checkpoints/v1`. Same format, same trailers, same +sharded tree layout; only the ref location differs. The custom ref is +invisible to `git branch -a` and to GitHub's branch UI, and is not pulled +by default `git clone`. The `v1.1` suffix (vs. plain `v1`) keeps the +custom ref distinct from the legacy branch so a single repo can hold +both refs side-by-side while history is being migrated. + +Activation is **manual opt-in**: edit `.entire/settings.json` to set +`strategy_options.checkpoints_version` to `1.1`. `entire enable` does not +auto-enroll. On existing v1 repos with prior history, the custom ref +starts empty — past checkpoints on the legacy branch are NOT +automatically reachable under 1.1. A future migration command can handle +that; today, a user who wants their old history reachable can run, once: + +``` +git update-ref refs/entire/checkpoints/v1.1 refs/heads/entire/checkpoints/v1 +``` + +The fetch refspec +`+refs/entire/checkpoints/v1.1:refs/entire/remotes/origin/checkpoints/v1.1` +is installed on origin by `entire enable` whenever settings already +specify 1.1. Push and rebase-on-non-FF use the same machinery as the +legacy branch path via `strategy.pushRefIfNeeded`. + +Key entry points: `checkpoint.MetadataRef(ctx)`, +`checkpoint.MetadataTrackingRef(ctx)`, +`paths.MetadataRefName`, `paths.MetadataTrackingRefName`, +`settings.EntireSettings.UsesCustomMetadataRef`. + #### Key Files - `strategy.go` - Interface definition and context structs (`StepContext`, `TaskStepContext`, `RewindPoint`, etc.) diff --git a/cmd/entire/cli/attach.go b/cmd/entire/cli/attach.go index 49f3c67fe0..4e968afd71 100644 --- a/cmd/entire/cli/attach.go +++ b/cmd/entire/cli/attach.go @@ -22,6 +22,7 @@ import ( "github.com/entireio/cli/cmd/entire/cli/logging" "github.com/entireio/cli/cmd/entire/cli/paths" "github.com/entireio/cli/cmd/entire/cli/session" + "github.com/entireio/cli/cmd/entire/cli/settings" "github.com/entireio/cli/cmd/entire/cli/strategy" "github.com/entireio/cli/cmd/entire/cli/trailers" "github.com/entireio/cli/cmd/entire/cli/validation" @@ -31,7 +32,6 @@ import ( "charm.land/huh/v2" "github.com/go-git/go-git/v6" - "github.com/go-git/go-git/v6/plumbing" "github.com/go-git/go-git/v6/plumbing/object" "github.com/spf13/cobra" ) @@ -384,7 +384,7 @@ func ensureCheckpointAvailable(ctx, logCtx context.Context, repo *git.Repository } } - branchDescription := "entire/checkpoints/v1 branch" + branchDescription := cpkg.RefDisplayName(cpkg.MetadataRef(ctx)) + " ref" return repo, fmt.Errorf( "checkpoint %s referenced by HEAD is missing from the local %s after a refresh attempt. Creating a fresh checkpoint here would overwrite the original session data on push. Run:\n\n %s\n\nthen re-run attach. If the colleague who made this commit hasn't pushed their checkpoint metadata yet, ask them to do so first", checkpointID.String(), branchDescription, suggestCheckpointFetchCommand(logCtx), @@ -400,10 +400,10 @@ func refreshCheckpointRefs(ctx context.Context) (*git.Repository, error) { } // checkpointPresentLocally reports whether the checkpoint already exists on -// the local v1 ref we would write to. Remote-tracking alone is not enough; -// see ensureCheckpointAvailable. +// the local metadata ref we would write to. Remote-tracking alone is not +// enough; see ensureCheckpointAvailable. func checkpointPresentLocally(ctx context.Context, repo *git.Repository, checkpointID id.CheckpointID) (bool, error) { - localRef := plumbing.NewBranchReferenceName(paths.MetadataBranchName) + localRef := cpkg.MetadataRef(ctx) if _, err := repo.Reference(localRef, true); err != nil { // Local branch ref doesn't exist — treat as "not present locally". // We deliberately do not fall back to remote-tracking: see @@ -418,9 +418,12 @@ func checkpointPresentLocally(ctx context.Context, repo *git.Repository, checkpo } // suggestCheckpointFetchCommand returns a git fetch command the user can -// paste to pull the missing v1 metadata branch. +// paste to pull the missing metadata ref. Legacy v1 lives on a regular branch +// and its short name is enough, while 1.1 uses a fully-qualified src:dst pair +// so the fetched ref lands in the tracking namespace instead of clobbering the +// local ref. func suggestCheckpointFetchCommand(ctx context.Context) string { - ref := "entire/checkpoints/v1:entire/checkpoints/v1" + ref := metadataFetchRefspec(ctx) if remote.Configured(ctx) { if url, err := remote.FetchURL(ctx); err == nil && url != "" { return fmt.Sprintf("git fetch %s %s", url, ref) @@ -429,6 +432,17 @@ func suggestCheckpointFetchCommand(ctx context.Context) string { return "git fetch origin " + ref } +// metadataFetchRefspec returns the src:dst pair a user should paste into +// `git fetch origin ` to pull v1 metadata. For legacy v1 it +// refers to the branch by short name; for 1.1 it uses the fully-qualified +// custom ref and the separate tracking namespace. +func metadataFetchRefspec(ctx context.Context) string { + if settings.UsesCustomMetadataRef(ctx) { + return "+" + paths.MetadataRefName + ":" + paths.MetadataTrackingRefName + } + return paths.MetadataBranchName + ":" + paths.MetadataBranchName +} + func resolveCheckpointID(headCommit *object.Commit) (id.CheckpointID, bool) { existing := trailers.ParseAllCheckpoints(headCommit.Message) if len(existing) > 0 { diff --git a/cmd/entire/cli/attach_test.go b/cmd/entire/cli/attach_test.go index 14f68e5c39..cca6d91c82 100644 --- a/cmd/entire/cli/attach_test.go +++ b/cmd/entire/cli/attach_test.go @@ -384,7 +384,7 @@ func TestAttach_RefusesWhenCheckpointMissingFromLocalBranch(t *testing.T) { if err == nil { t.Fatal("expected error: checkpoint referenced by HEAD is missing locally and attach should refuse") } - if !strings.Contains(err.Error(), "missing from the local entire/checkpoints/v1 branch") { + if !strings.Contains(err.Error(), "missing from the local entire/checkpoints/v1 ref") { t.Errorf("error message should explain the missing-branch situation; got: %v", err) } if !strings.Contains(err.Error(), "git fetch origin entire/checkpoints/v1") { @@ -463,7 +463,7 @@ func TestAttach_RefusesWhenCheckpointOnlyInRemoteTrackingRef(t *testing.T) { if err == nil { t.Fatal("expected attach to refuse when checkpoint is only in the remote-tracking ref") } - if !strings.Contains(err.Error(), "missing from the local entire/checkpoints/v1 branch") { + if !strings.Contains(err.Error(), "missing from the local entire/checkpoints/v1 ref") { t.Errorf("error should explain the local-branch gap; got: %v", err) } @@ -1173,7 +1173,7 @@ func TestAttach_ReviewRefusesWhenCheckpointMissingFromLocalBranch(t *testing.T) if err == nil { t.Fatal("expected error: checkpoint referenced by HEAD is missing locally and attach should refuse") } - if !strings.Contains(err.Error(), "missing from the local entire/checkpoints/v1 branch") { + if !strings.Contains(err.Error(), "missing from the local entire/checkpoints/v1 ref") { t.Errorf("error message should explain the missing-branch situation; got: %v", err) } if !strings.Contains(err.Error(), "git fetch origin entire/checkpoints/v1") { diff --git a/cmd/entire/cli/checkpoint/blob_resolver_test.go b/cmd/entire/cli/checkpoint/blob_resolver_test.go index 95a94165c2..ed186165c4 100644 --- a/cmd/entire/cli/checkpoint/blob_resolver_test.go +++ b/cmd/entire/cli/checkpoint/blob_resolver_test.go @@ -16,7 +16,7 @@ func TestBlobResolver_HasBlob_Present(t *testing.T) { repo, store, cpID := setupRepoForUpdate(t) // Get the metadata branch tree - tree, err := store.getSessionsBranchTree() + tree, err := store.getSessionsBranchTree(context.Background()) if err != nil { t.Fatalf("getSessionsBranchTree() error = %v", err) } @@ -56,7 +56,7 @@ func TestBlobResolver_ReadBlob(t *testing.T) { repo, store, cpID := setupRepoForUpdate(t) - tree, err := store.getSessionsBranchTree() + tree, err := store.getSessionsBranchTree(context.Background()) if err != nil { t.Fatalf("getSessionsBranchTree() error = %v", err) } @@ -102,7 +102,7 @@ func TestCollectTranscriptBlobHashes_SingleSession(t *testing.T) { _, store, cpID := setupRepoForUpdate(t) - tree, err := store.getSessionsBranchTree() + tree, err := store.getSessionsBranchTree(context.Background()) if err != nil { t.Fatalf("getSessionsBranchTree() error = %v", err) } @@ -147,7 +147,7 @@ func TestCollectTranscriptBlobHashes_MultiSession(t *testing.T) { t.Fatalf("WriteCommitted() for second session error = %v", err) } - tree, err := store.getSessionsBranchTree() + tree, err := store.getSessionsBranchTree(context.Background()) if err != nil { t.Fatalf("getSessionsBranchTree() error = %v", err) } @@ -188,7 +188,7 @@ func TestCollectTranscriptBlobHashes_NonexistentCheckpoint(t *testing.T) { _, store, _ := setupRepoForUpdate(t) - tree, err := store.getSessionsBranchTree() + tree, err := store.getSessionsBranchTree(context.Background()) if err != nil { t.Fatalf("getSessionsBranchTree() error = %v", err) } diff --git a/cmd/entire/cli/checkpoint/committed.go b/cmd/entire/cli/checkpoint/committed.go index 964d016854..808e82ee5a 100644 --- a/cmd/entire/cli/checkpoint/committed.go +++ b/cmd/entire/cli/checkpoint/committed.go @@ -76,7 +76,7 @@ func (s *GitStore) WriteCommitted(ctx context.Context, opts WriteCommittedOption } // Get branch ref and root tree hash (O(1), no flatten) - parentHash, rootTreeHash, err := s.getSessionsBranchRef() + parentHash, rootTreeHash, err := s.getSessionsBranchRef(ctx) if err != nil { return err } @@ -123,7 +123,7 @@ func (s *GitStore) WriteCommitted(ctx context.Context, opts WriteCommittedOption return err } - refName := plumbing.NewBranchReferenceName(paths.MetadataBranchName) + refName := MetadataRef(ctx) newRef := plumbing.NewHashReference(refName, newCommitHash) if err := s.repo.Storer.SetReference(newRef); err != nil { return fmt.Errorf("failed to set branch reference: %w", err) @@ -339,8 +339,8 @@ func (s *GitStore) writeStandardCheckpointEntries(ctx context.Context, opts Writ slog.String("write_session_id", opts.SessionID), slog.Bool("existing_summary_nil", existingSummary == nil)) return fmt.Errorf( - "refusing to overwrite session 0 of checkpoint %s: existing session ID %q differs from write session ID %q. The checkpoint tree is inconsistent (session 0 belongs to a different session than this write claims). No automated repair exists for this shape — please report it along with the output of `git ls-tree entire/checkpoints/v1 %s/`", - opts.CheckpointID, existingMeta.SessionID, opts.SessionID, opts.CheckpointID.Path(), + "refusing to overwrite session 0 of checkpoint %s: existing session ID %q differs from write session ID %q. The checkpoint tree is inconsistent (session 0 belongs to a different session than this write claims). No automated repair exists for this shape — please report it along with the output of `git ls-tree %s %s/`", + opts.CheckpointID, existingMeta.SessionID, opts.SessionID, RefDisplayName(MetadataRef(ctx)), opts.CheckpointID.Path(), ) } } @@ -549,7 +549,7 @@ func (s *GitStore) UpdateCheckpointSummary(ctx context.Context, checkpointID id. return fmt.Errorf("failed to ensure sessions branch: %w", err) } - parentHash, rootTreeHash, err := s.getSessionsBranchRef() + parentHash, rootTreeHash, err := s.getSessionsBranchRef(ctx) if err != nil { return err } @@ -599,7 +599,7 @@ func (s *GitStore) UpdateCheckpointSummary(ctx context.Context, checkpointID id. return err } - refName := plumbing.NewBranchReferenceName(paths.MetadataBranchName) + refName := MetadataRef(ctx) newRef := plumbing.NewHashReference(refName, newCommitHash) if err := s.repo.Storer.SetReference(newRef); err != nil { return fmt.Errorf("failed to set branch reference: %w", err) @@ -1213,7 +1213,7 @@ func (s *GitStore) ListCommitted(ctx context.Context) ([]CommittedInfo, error) { return nil, err //nolint:wrapcheck // Propagating context cancellation } - tree, err := s.getSessionsBranchTree() + tree, err := s.getSessionsBranchTree(ctx) if err != nil { return []CommittedInfo{}, nil //nolint:nilerr // No sessions branch means empty list } @@ -1354,7 +1354,7 @@ func (s *GitStore) UpdateSummary(ctx context.Context, checkpointID id.Checkpoint } // Get branch ref and root tree hash (O(1), no flatten) - parentHash, rootTreeHash, err := s.getSessionsBranchRef() + parentHash, rootTreeHash, err := s.getSessionsBranchRef(ctx) if err != nil { return err } @@ -1424,7 +1424,7 @@ func (s *GitStore) UpdateSummary(ctx context.Context, checkpointID id.Checkpoint return err } - refName := plumbing.NewBranchReferenceName(paths.MetadataBranchName) + refName := MetadataRef(ctx) newRef := plumbing.NewHashReference(refName, newCommitHash) if err := s.repo.Storer.SetReference(newRef); err != nil { return fmt.Errorf("failed to set branch reference: %w", err) @@ -1452,7 +1452,7 @@ func (s *GitStore) UpdateCommitted(ctx context.Context, opts UpdateCommittedOpti } // Get branch ref and root tree hash (O(1), no flatten) - parentHash, rootTreeHash, err := s.getSessionsBranchRef() + parentHash, rootTreeHash, err := s.getSessionsBranchRef(ctx) if err != nil { return err } @@ -1543,7 +1543,7 @@ func (s *GitStore) UpdateCommitted(ctx context.Context, opts UpdateCommittedOpti return err } - refName := plumbing.NewBranchReferenceName(paths.MetadataBranchName) + refName := MetadataRef(ctx) newRef := plumbing.NewHashReference(refName, newCommitHash) if err := s.repo.Storer.SetReference(newRef); err != nil { return fmt.Errorf("failed to set branch reference: %w", err) @@ -1680,9 +1680,9 @@ func PrecomputeTranscriptBlobs(ctx context.Context, repo *git.Repository, transc }, nil } -// ensureSessionsBranch ensures the entire/checkpoints/v1 branch exists. +// ensureSessionsBranch ensures the v1 metadata ref exists. func (s *GitStore) ensureSessionsBranch(ctx context.Context) error { - refName := plumbing.NewBranchReferenceName(paths.MetadataBranchName) + refName := MetadataRef(ctx) _, err := s.repo.Reference(refName, true) if err == nil { return nil // Branch exists @@ -1729,24 +1729,24 @@ func (s *GitStore) maybeMergeVercelConfig(ctx context.Context, rootTreeHash plum // If a blob fetcher is configured on the store, File() calls on the returned // tree will automatically fetch missing blobs from the remote. func (s *GitStore) getFetchingTree(ctx context.Context) (*FetchingTree, error) { - tree, err := s.getSessionsBranchTree() + tree, err := s.getSessionsBranchTree(ctx) if err != nil { return nil, err } return NewFetchingTree(ctx, tree, s.repo.Storer, s.blobFetcher), nil } -// getSessionsBranchTree returns the tree object for the entire/checkpoints/v1 branch. -// Falls back to origin/entire/checkpoints/v1 if the local branch doesn't exist. -func (s *GitStore) getSessionsBranchTree() (*object.Tree, error) { - refName := plumbing.NewBranchReferenceName(paths.MetadataBranchName) +// getSessionsBranchTree returns the tree object for the v1 metadata ref. +// Falls back to the remote-tracking ref if the local ref doesn't exist. +func (s *GitStore) getSessionsBranchTree(ctx context.Context) (*object.Tree, error) { + refName := MetadataRef(ctx) ref, err := s.repo.Reference(refName, true) if err != nil { - // Local branch doesn't exist, try remote-tracking branch - remoteRefName := plumbing.NewRemoteReferenceName("origin", paths.MetadataBranchName) + // Local ref doesn't exist, try remote-tracking + remoteRefName := MetadataTrackingRef(ctx) ref, err = s.repo.Reference(remoteRefName, true) if err != nil { - return nil, fmt.Errorf("sessions branch not found: %w", err) + return nil, fmt.Errorf("metadata ref %q not found: %w", RefDisplayName(refName), err) } } @@ -2152,7 +2152,7 @@ type Author struct { // Finds the commit whose subject matches "Checkpoint: " and returns its author. // Returns empty Author if the checkpoint is not found or the sessions branch doesn't exist. func (s *GitStore) GetCheckpointAuthor(ctx context.Context, checkpointID id.CheckpointID) (Author, error) { - return getCheckpointAuthorFromRef(ctx, s.repo, plumbing.NewBranchReferenceName(paths.MetadataBranchName), checkpointID) + return getCheckpointAuthorFromRef(ctx, s.repo, MetadataRef(ctx), checkpointID) } func getCheckpointAuthorFromRef(ctx context.Context, repo *git.Repository, refName plumbing.ReferenceName, checkpointID id.CheckpointID) (Author, error) { diff --git a/cmd/entire/cli/checkpoint/metadata_ref.go b/cmd/entire/cli/checkpoint/metadata_ref.go new file mode 100644 index 0000000000..0634dae065 --- /dev/null +++ b/cmd/entire/cli/checkpoint/metadata_ref.go @@ -0,0 +1,84 @@ +package checkpoint + +import ( + "context" + "strings" + + "github.com/go-git/go-git/v6/plumbing" + + "github.com/entireio/cli/cmd/entire/cli/paths" + "github.com/entireio/cli/cmd/entire/cli/settings" +) + +// RefDisplayName produces a short, log-friendly name for a metadata ref by +// stripping the refs/heads/ or refs/entire/ prefix. Use this for user-facing +// messages so legacy v1 ("entire/checkpoints/v1") and 1.1 +// ("checkpoints/v1.1") both display naturally. Returns the input unchanged +// when neither prefix matches. +func RefDisplayName(ref plumbing.ReferenceName) string { + s := string(ref) + for _, prefix := range []string{"refs/heads/", "refs/entire/"} { + if strings.HasPrefix(s, prefix) { + return strings.TrimPrefix(s, prefix) + } + } + return s +} + +// MetadataRef returns the plumbing.ReferenceName for v1 metadata storage, +// resolved from settings. +// +// Legacy v1 repos return the branch ref (refs/heads/entire/checkpoints/v1). +// checkpoints_version 1.1 repos return the custom ref +// (refs/entire/checkpoints/v1.1). Falls back to the legacy branch ref when +// settings cannot be loaded. +// +// 1.1 repos start with an empty custom ref — prior history on the legacy +// branch is NOT automatically reachable from the new ref. Users who want +// the old checkpoints under 1.1 can run, once: +// +// git update-ref refs/entire/checkpoints/v1.1 refs/heads/entire/checkpoints/v1 +func MetadataRef(ctx context.Context) plumbing.ReferenceName { + if settings.UsesCustomMetadataRef(ctx) { + return plumbing.ReferenceName(paths.MetadataRefName) + } + return plumbing.NewBranchReferenceName(paths.MetadataBranchName) +} + +// MetadataTrackingRef returns the plumbing.ReferenceName for the origin +// remote-tracking counterpart of the v1 metadata ref. Use this for code +// paths that are explicitly about the origin tracking ref (doctor, +// resume promotion, EnsureMetadataBranch's origin check, fetch from +// origin). For the push hook (which can push to any remote name), use +// MetadataTrackingRefForRemote with the actual push remote. +// +// For legacy v1: refs/remotes/origin/entire/checkpoints/v1. +// For 1.1: refs/entire/remotes/origin/checkpoints/v1.1. +// +// The 1.1 tracking ref is intentionally NOT the same as the local ref — +// mapping a fetched ref to itself would clobber local writes on every +// fetch. The separate namespace preserves local commits the way the +// standard refs/heads/* ↔ refs/remotes/origin/* mapping does. +func MetadataTrackingRef(ctx context.Context) plumbing.ReferenceName { + return MetadataTrackingRefForRemote(ctx, "origin") +} + +// MetadataTrackingRefForRemote returns the local remote-tracking ref for +// the v1 metadata ref under a specific remote name. The push hook uses +// this so non-origin pushes (e.g. `git push upstream`) compare against +// the right tracking ref, not always origin's. +// +// For legacy v1: refs/remotes//entire/checkpoints/v1. +// For 1.1: refs/entire/remotes//checkpoints/v1.1. +// +// Note: for 1.1, only the origin refspec is installed by `entire enable` +// (see installMetadataRefspec). Tracking refs for non-origin remotes will +// not be populated by `git fetch` until a user installs the equivalent +// refspec by hand. The push hook treats a missing tracking ref as +// "needs push" — safe but suboptimal. +func MetadataTrackingRefForRemote(ctx context.Context, remoteName string) plumbing.ReferenceName { + if settings.UsesCustomMetadataRef(ctx) { + return plumbing.ReferenceName(paths.BuildMetadataTrackingRef(remoteName)) + } + return plumbing.NewRemoteReferenceName(remoteName, paths.MetadataBranchName) +} diff --git a/cmd/entire/cli/checkpoint/metadata_ref_test.go b/cmd/entire/cli/checkpoint/metadata_ref_test.go new file mode 100644 index 0000000000..82ef4c25f8 --- /dev/null +++ b/cmd/entire/cli/checkpoint/metadata_ref_test.go @@ -0,0 +1,113 @@ +package checkpoint_test + +import ( + "context" + "os" + "path/filepath" + "testing" + + "github.com/go-git/go-git/v6/plumbing" + + "github.com/entireio/cli/cmd/entire/cli/checkpoint" + "github.com/entireio/cli/cmd/entire/cli/paths" + "github.com/entireio/cli/cmd/entire/cli/testutil" +) + +func writeSettings(t *testing.T, repoDir string, contents string) { + t.Helper() + if err := os.MkdirAll(filepath.Join(repoDir, paths.EntireDir), 0o755); err != nil { + t.Fatalf("mkdir .entire: %v", err) + } + if err := os.WriteFile(filepath.Join(repoDir, paths.EntireDir, paths.SettingsFileName), []byte(contents), 0o644); err != nil { + t.Fatalf("write settings: %v", err) + } +} + +func TestMetadataRef_LegacyVsCustom(t *testing.T) { + // Not t.Parallel() — uses t.Chdir for settings.Load CWD resolution. + dir := t.TempDir() + testutil.InitRepo(t, dir) + t.Chdir(dir) + + // No settings: legacy branch ref. + if got := checkpoint.MetadataRef(context.Background()); got != plumbing.NewBranchReferenceName(paths.MetadataBranchName) { + t.Fatalf("default = %s; want legacy branch ref", got) + } + + // checkpoints_version: 1.1 → custom ref. + writeSettings(t, dir, `{"strategy_options":{"checkpoints_version":"1.1"}}`) + if got := checkpoint.MetadataRef(context.Background()); got != plumbing.ReferenceName(paths.MetadataRefName) { + t.Fatalf("1.1 = %s; want %s", got, paths.MetadataRefName) + } + + // checkpoints_version: 1 → legacy. + writeSettings(t, dir, `{"strategy_options":{"checkpoints_version":1}}`) + if got := checkpoint.MetadataRef(context.Background()); got != plumbing.NewBranchReferenceName(paths.MetadataBranchName) { + t.Fatalf("v1 = %s; want legacy branch ref", got) + } +} + +func TestRefDisplayName(t *testing.T) { + t.Parallel() + cases := []struct { + name string + in plumbing.ReferenceName + want string + }{ + {"legacy v1 branch", plumbing.NewBranchReferenceName(paths.MetadataBranchName), "entire/checkpoints/v1"}, + {"v1.1 custom ref", plumbing.ReferenceName(paths.MetadataRefName), "checkpoints/v1.1"}, + {"v1.1 tracking ref", plumbing.ReferenceName(paths.MetadataTrackingRefName), "remotes/origin/checkpoints/v1.1"}, + {"unrecognized prefix returned verbatim", plumbing.ReferenceName("refs/tags/v1"), "refs/tags/v1"}, + {"empty", plumbing.ReferenceName(""), ""}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + if got := checkpoint.RefDisplayName(tc.in); got != tc.want { + t.Fatalf("RefDisplayName(%q) = %q; want %q", tc.in, got, tc.want) + } + }) + } +} + +func TestMetadataTrackingRef_LegacyVsCustom(t *testing.T) { + dir := t.TempDir() + testutil.InitRepo(t, dir) + t.Chdir(dir) + + if got := checkpoint.MetadataTrackingRef(context.Background()); got != plumbing.NewRemoteReferenceName("origin", paths.MetadataBranchName) { + t.Fatalf("default tracking = %s; want legacy tracking", got) + } + + writeSettings(t, dir, `{"strategy_options":{"checkpoints_version":1.1}}`) + if got := checkpoint.MetadataTrackingRef(context.Background()); got != plumbing.ReferenceName(paths.MetadataTrackingRefName) { + t.Fatalf("1.1 tracking = %s; want %s", got, paths.MetadataTrackingRefName) + } +} + +func TestMetadataTrackingRefForRemote_UsesActualRemoteName(t *testing.T) { + dir := t.TempDir() + testutil.InitRepo(t, dir) + t.Chdir(dir) + + // Legacy v1 with a non-origin remote. + got := checkpoint.MetadataTrackingRefForRemote(context.Background(), "upstream") + want := plumbing.NewRemoteReferenceName("upstream", paths.MetadataBranchName) + if got != want { + t.Fatalf("v1 tracking for upstream = %s; want %s", got, want) + } + + // 1.1 with a non-origin remote. + writeSettings(t, dir, `{"strategy_options":{"checkpoints_version":"1.1"}}`) + got = checkpoint.MetadataTrackingRefForRemote(context.Background(), "upstream") + want = plumbing.ReferenceName("refs/entire/remotes/upstream/checkpoints/v1.1") + if got != want { + t.Fatalf("1.1 tracking for upstream = %s; want %s", got, want) + } + + // 1.1 with origin matches the default helper. + got = checkpoint.MetadataTrackingRefForRemote(context.Background(), "origin") + if got != plumbing.ReferenceName(paths.MetadataTrackingRefName) { + t.Fatalf("1.1 tracking for origin = %s; want %s (the documented default)", got, paths.MetadataTrackingRefName) + } +} diff --git a/cmd/entire/cli/checkpoint/parse_tree.go b/cmd/entire/cli/checkpoint/parse_tree.go index b853fe8f50..3ecd205b3d 100644 --- a/cmd/entire/cli/checkpoint/parse_tree.go +++ b/cmd/entire/cli/checkpoint/parse_tree.go @@ -11,7 +11,6 @@ import ( "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" "github.com/go-git/go-git/v6/plumbing" @@ -442,10 +441,10 @@ func splitFirstSegment(path string) (first, rest string) { return parts[0], parts[1] } -// getSessionsBranchRef returns the sessions branch parent commit hash and root tree hash +// getSessionsBranchRef returns the v1 metadata ref's commit hash and root tree hash // without flattening the tree. -func (s *GitStore) getSessionsBranchRef() (plumbing.Hash, plumbing.Hash, error) { - refName := plumbing.NewBranchReferenceName(paths.MetadataBranchName) +func (s *GitStore) getSessionsBranchRef(ctx context.Context) (plumbing.Hash, plumbing.Hash, error) { + refName := MetadataRef(ctx) ref, err := s.repo.Reference(refName, true) if err != nil { return plumbing.ZeroHash, plumbing.ZeroHash, fmt.Errorf("failed to get sessions branch reference: %w", err) diff --git a/cmd/entire/cli/doctor.go b/cmd/entire/cli/doctor.go index fa85fa9b43..a4763994be 100644 --- a/cmd/entire/cli/doctor.go +++ b/cmd/entire/cli/doctor.go @@ -336,7 +336,7 @@ func checkDisconnectedMetadata(cmd *cobra.Command, force bool) error { defer repo.Close() ctx := cmd.Context() - remoteRefName := plumbing.NewRemoteReferenceName("origin", paths.MetadataBranchName) + remoteRefName := checkpoint.MetadataTrackingRef(ctx) disconnected, err := strategy.IsMetadataDisconnected(ctx, repo, remoteRefName) if err != nil { return fmt.Errorf("could not check metadata branch state: %w", err) diff --git a/cmd/entire/cli/explain.go b/cmd/entire/cli/explain.go index 51282cf483..ebab9a9aba 100644 --- a/cmd/entire/cli/explain.go +++ b/cmd/entire/cli/explain.go @@ -762,7 +762,9 @@ func loadCheckpointForExplain(ctx context.Context, errW io.Writer, lookup *expla // inside this function so the caller's spinner provides continuous // feedback. func prefetchCheckpointBlobs(ctx context.Context, _ io.Writer, repo *git.Repository, cpID id.CheckpointID) { - v1FT := buildCheckpointFetchingTree(ctx, repo, cpID, "v1", loadV1MetadataRootTree) + v1FT := buildCheckpointFetchingTree(ctx, repo, cpID, "v1", func(r *git.Repository) (*object.Tree, error) { + return loadV1MetadataRootTree(ctx, r) + }) v2FT := buildCheckpointFetchingTree(ctx, repo, cpID, "v2", loadV2MainRootTree) missingCount := 0 @@ -826,11 +828,11 @@ func runPreFetch(ctx context.Context, ft *checkpoint.FetchingTree, cpID id.Check } } -func loadV1MetadataRootTree(repo *git.Repository) (*object.Tree, error) { - if tree, err := strategy.GetMetadataBranchTree(repo); err == nil { +func loadV1MetadataRootTree(ctx context.Context, repo *git.Repository) (*object.Tree, error) { + if tree, err := strategy.GetMetadataBranchTree(ctx, repo); err == nil { return tree, nil } - tree, err := strategy.GetRemoteMetadataBranchTree(repo) + tree, err := strategy.GetRemoteMetadataBranchTree(ctx, repo) if err != nil { return nil, fmt.Errorf("read v1 metadata tree (local + remote-tracking): %w", err) } diff --git a/cmd/entire/cli/git_operations.go b/cmd/entire/cli/git_operations.go index 357c5397a5..a2e991cceb 100644 --- a/cmd/entire/cli/git_operations.go +++ b/cmd/entire/cli/git_operations.go @@ -10,6 +10,7 @@ import ( "strings" "time" + cpkg "github.com/entireio/cli/cmd/entire/cli/checkpoint" "github.com/entireio/cli/cmd/entire/cli/checkpoint/remote" "github.com/entireio/cli/cmd/entire/cli/logging" "github.com/entireio/cli/cmd/entire/cli/paths" @@ -427,8 +428,13 @@ type fetchMetadataOpts struct { Unshallow bool } +// fetchMetadataFromOrigin fetches the v1 metadata ref from origin into the +// remote-tracking ref, then safely advances the local ref to match. +// Honors the 1.1 custom-ref namespace when configured. func fetchMetadataFromOrigin(ctx context.Context, fopts fetchMetadataOpts) error { - branchName := paths.MetadataBranchName + localRef := cpkg.MetadataRef(ctx) + trackingRef := cpkg.MetadataTrackingRef(ctx) + refDisplay := cpkg.RefDisplayName(localRef) ctx, cancel := context.WithTimeout(ctx, 2*time.Minute) defer cancel() @@ -438,7 +444,7 @@ func fetchMetadataFromOrigin(ctx context.Context, fopts fetchMetadataOpts) error return fmt.Errorf("failed to resolve fetch target: %w", err) } - refSpec := fmt.Sprintf("+refs/heads/%s:refs/remotes/origin/%s", branchName, branchName) + refSpec := fmt.Sprintf("+%s:%s", string(localRef), string(trackingRef)) output, fetchErr := remote.Fetch(ctx, remote.FetchOptions{ Remote: fetchTarget, @@ -452,7 +458,7 @@ func fetchMetadataFromOrigin(ctx context.Context, fopts fetchMetadataOpts) error if ctx.Err() == context.DeadlineExceeded { return errors.New("fetch timed out after 2 minutes") } - return formatFilteredFetchError("failed to fetch "+branchName, fetchTarget, output, fetchErr) + return formatFilteredFetchError("failed to fetch "+refDisplay, fetchTarget, output, fetchErr) } repo, err := openRepository(ctx) @@ -461,12 +467,12 @@ func fetchMetadataFromOrigin(ctx context.Context, fopts fetchMetadataOpts) error } defer repo.Close() - remoteRef, err := repo.Reference(plumbing.NewRemoteReferenceName("origin", branchName), true) + remoteRef, err := repo.Reference(trackingRef, true) if err != nil { - return fmt.Errorf("branch '%s' not found on origin: %w", branchName, err) + return fmt.Errorf("ref '%s' not found on origin: %w", refDisplay, err) } - if err := strategy.SafelyAdvanceLocalRef(ctx, repo, plumbing.NewBranchReferenceName(branchName), remoteRef.Hash()); err != nil { - return fmt.Errorf("failed to advance local %s branch: %w", branchName, err) + if err := strategy.SafelyAdvanceLocalRef(ctx, repo, localRef, remoteRef.Hash()); err != nil { + return fmt.Errorf("failed to advance local %s: %w", refDisplay, err) } return nil } diff --git a/cmd/entire/cli/integration_test/v1_metadata_ref_workflow_test.go b/cmd/entire/cli/integration_test/v1_metadata_ref_workflow_test.go new file mode 100644 index 0000000000..c33a7ec15f --- /dev/null +++ b/cmd/entire/cli/integration_test/v1_metadata_ref_workflow_test.go @@ -0,0 +1,118 @@ +//go:build integration + +package integration + +import ( + "os/exec" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/entireio/cli/cmd/entire/cli/paths" + "github.com/entireio/cli/cmd/entire/cli/testutil" +) + +// TestV1MetadataRef_FreshRepo_ManualOptIn_FullWorkflow exercises the +// manual-opt-in path: a fresh repo gets checkpoints_version=1.1 in +// settings.json, then runs a session and commit. The custom ref +// refs/entire/checkpoints/v1.1 should be created; the legacy branch +// refs/heads/entire/checkpoints/v1 should not exist. +func TestV1MetadataRef_FreshRepo_ManualOptIn_FullWorkflow(t *testing.T) { + t.Parallel() + env := NewFeatureBranchEnv(t) + defer env.Cleanup() + + env.InitEntireWithOptions(map[string]any{ + "checkpoints_version": "1.1", + }) + + session := env.NewSession() + require.NoError(t, env.SimulateUserPromptSubmitWithPrompt(session.ID, "Add hello")) + env.WriteFile("hello.txt", "world") + session.CreateTranscript("Add hello", []FileChange{{Path: "hello.txt", Content: "world"}}) + require.NoError(t, env.SimulateStop(session.ID, session.TranscriptPath)) + + env.GitAdd("hello.txt") + env.GitCommitWithShadowHooks("Add hello") + + assert.True(t, env.RefExists(paths.MetadataRefName), + "custom ref %s should exist on a fresh 1.1 repo after first commit", paths.MetadataRefName) + assert.False(t, env.BranchExists(paths.MetadataBranchName), + "legacy branch refs/heads/%s should NOT be created on a fresh 1.1 repo", paths.MetadataBranchName) +} + +// TestLegacyV1_StillWorks_Regression verifies that v1 (default) behavior is +// unchanged: the legacy branch is created, the custom ref is not. +func TestLegacyV1_StillWorks_Regression(t *testing.T) { + t.Parallel() + env := NewFeatureBranchEnv(t) + defer env.Cleanup() + + env.InitEntire() + + session := env.NewSession() + require.NoError(t, env.SimulateUserPromptSubmitWithPrompt(session.ID, "Add plain")) + env.WriteFile("plain.txt", "plain") + session.CreateTranscript("Add plain", []FileChange{{Path: "plain.txt", Content: "plain"}}) + require.NoError(t, env.SimulateStop(session.ID, session.TranscriptPath)) + env.GitAdd("plain.txt") + env.GitCommitWithShadowHooks("Add plain") + + assert.True(t, env.BranchExists(paths.MetadataBranchName), + "legacy v1 branch should exist after a v1 commit") + assert.False(t, env.RefExists(paths.MetadataRefName), + "custom ref should NOT exist on a v1 repo") +} + +// TestV1MetadataRef_PushAndFetch_NonFF exercises pushRefIfNeeded at the +// custom-ref namespace: two diverging "machines" (clones from the same bare +// remote) push 1.1 metadata commits; the second push triggers +// fetchAndRebaseSessionsCommon at the custom ref pair. +func TestV1MetadataRef_PushAndFetch_NonFF(t *testing.T) { + t.Parallel() + env := NewFeatureBranchEnv(t) + defer env.Cleanup() + + env.InitEntireWithOptions(map[string]any{"checkpoints_version": "1.1"}) + bareDir := env.SetupBareRemote() + + // Round 1: one commit on the 1.1 repo, pushed to origin. + session1 := env.NewSession() + require.NoError(t, env.SimulateUserPromptSubmitWithPrompt(session1.ID, "Add round1")) + env.WriteFile("round1.txt", "1") + session1.CreateTranscript("Add round1", []FileChange{{Path: "round1.txt", Content: "1"}}) + require.NoError(t, env.SimulateStop(session1.ID, session1.TranscriptPath)) + env.GitAdd("round1.txt") + env.GitCommitWithShadowHooks("Add round1") + env.RunPrePush("origin") + + // Verify the custom ref landed on the bare remote. + assert.True(t, bareRefExists(t, bareDir, paths.MetadataRefName), + "custom ref %s should exist on bare remote after first push", paths.MetadataRefName) + + // Round 2: a second commit on the same 1.1 repo, pushed again. This is + // fast-forward so it should not trigger the rebase path, but it exercises + // the same code path with a real custom-ref refspec. + session2 := env.NewSession() + require.NoError(t, env.SimulateUserPromptSubmitWithPrompt(session2.ID, "Add round2")) + env.WriteFile("round2.txt", "2") + session2.CreateTranscript("Add round2", []FileChange{{Path: "round2.txt", Content: "2"}}) + require.NoError(t, env.SimulateStop(session2.ID, session2.TranscriptPath)) + env.GitAdd("round2.txt") + env.GitCommitWithShadowHooks("Add round2") + env.RunPrePush("origin") + + assert.True(t, bareRefExists(t, bareDir, paths.MetadataRefName), + "custom ref should still exist on bare remote after second push") + assert.False(t, bareRefExists(t, bareDir, "refs/heads/"+paths.MetadataBranchName), + "legacy branch should NOT be pushed by a 1.1 repo") +} + +func bareRefExists(t *testing.T, bareDir, refName string) bool { + t.Helper() + cmd := exec.CommandContext(t.Context(), "git", "show-ref", "--verify", "--quiet", refName) + cmd.Dir = bareDir + cmd.Env = testutil.GitIsolatedEnv() + return cmd.Run() == nil +} diff --git a/cmd/entire/cli/paths/paths.go b/cmd/entire/cli/paths/paths.go index 5c3edab48d..041e5ff01f 100644 --- a/cmd/entire/cli/paths/paths.go +++ b/cmd/entire/cli/paths/paths.go @@ -42,6 +42,35 @@ const ( // MetadataBranchName is the orphan branch used by manual-commit strategy to store metadata const MetadataBranchName = "entire/checkpoints/v1" +// MetadataRefName is the custom ref used when checkpoints_version is "1.1". +// Lives under refs/entire/ for the same reasons the legacy V2*RefName does: +// invisible in GitHub's branch UI and not fetched by default by `git clone`. +// +// The v1.1 suffix (vs. plain v1) keeps the ref name distinct from the +// legacy branch refs/heads/entire/checkpoints/v1, so a single repo can +// hold both without colliding while history is being migrated. +const MetadataRefName = "refs/entire/checkpoints/v1.1" + +// MetadataTrackingRefPrefix and MetadataTrackingRefSuffix build the local +// remote-tracking ref for the 1.1 custom metadata namespace. The remote +// name goes between them — see paths.BuildMetadataTrackingRef. +const ( + MetadataTrackingRefPrefix = "refs/entire/remotes/" + MetadataTrackingRefSuffix = "/checkpoints/v1.1" +) + +// MetadataTrackingRefName is the local tracking ref for MetadataRefName on +// `origin`. Distinct from MetadataRefName so that fetches don't clobber +// local writes (mirroring how refs/heads/* and refs/remotes/origin/* are +// distinct). +const MetadataTrackingRefName = MetadataTrackingRefPrefix + "origin" + MetadataTrackingRefSuffix + +// BuildMetadataTrackingRef returns the 1.1 tracking-ref name for the given +// remote (e.g. "origin" → refs/entire/remotes/origin/checkpoints/v1.1). +func BuildMetadataTrackingRef(remoteName string) string { + return MetadataTrackingRefPrefix + remoteName + MetadataTrackingRefSuffix +} + // Legacy v2 ref names use custom refs under refs/entire/ (not refs/heads/). // They are retained for read fallback while checkpoints v2 is rolled back. const ( diff --git a/cmd/entire/cli/resume.go b/cmd/entire/cli/resume.go index 3a3e96d563..8654894c08 100644 --- a/cmd/entire/cli/resume.go +++ b/cmd/entire/cli/resume.go @@ -328,7 +328,7 @@ func readCheckpointInfoFromLocalTrees(ctx context.Context, repo *git.Repository, slog.String("error", err.Error()), ) - v1Tree, err := strategy.GetMetadataBranchTree(repo) + v1Tree, err := strategy.GetMetadataBranchTree(ctx, repo) if err == nil { info, infoErr := readCheckpointInfoFromLocalTree(ctx, repo, checkpointID, "v1", v1Tree) if infoErr == nil { @@ -419,7 +419,7 @@ func getMetadataTree(ctx context.Context) (*object.Tree, *git.Repository, error) // Helper to log ref hash for a repo's metadata branch logRefHash := func(repo *git.Repository, source string) { - ref, refErr := repo.Reference(plumbing.NewBranchReferenceName("entire/checkpoints/v1"), true) + ref, refErr := repo.Reference(checkpoint.MetadataRef(ctx), true) if refErr != nil { logging.Debug(logCtx, "metadata branch ref not found", slog.String("source", source), @@ -440,7 +440,7 @@ func getMetadataTree(ctx context.Context) (*object.Tree, *git.Repository, error) freshRepo, freshErr := openRepository(ctx) if freshErr == nil { logRefHash(freshRepo, "checkpoint-remote") - metadataTree, treeErr := strategy.GetMetadataBranchTree(freshRepo) + metadataTree, treeErr := strategy.GetMetadataBranchTree(ctx, freshRepo) if treeErr == nil { logging.Debug(logCtx, "metadata tree obtained via checkpoint remote fetch", slog.String("tree_hash", metadataTree.Hash.String()), @@ -464,7 +464,7 @@ func getMetadataTree(ctx context.Context) (*object.Tree, *git.Repository, error) freshRepo, repoErr := openRepository(ctx) if repoErr == nil { logRefHash(freshRepo, "treeless-fetch") - metadataTree, treeErr := strategy.GetMetadataBranchTree(freshRepo) + metadataTree, treeErr := strategy.GetMetadataBranchTree(ctx, freshRepo) if treeErr == nil { logging.Debug(logCtx, "metadata tree obtained via treeless fetch", slog.String("tree_hash", metadataTree.Hash.String()), @@ -486,7 +486,7 @@ func getMetadataTree(ctx context.Context) (*object.Tree, *git.Repository, error) localRepo, repoErr := openRepository(ctx) if repoErr == nil { logRefHash(localRepo, "local") - metadataTree, err := strategy.GetMetadataBranchTree(localRepo) + metadataTree, err := strategy.GetMetadataBranchTree(ctx, localRepo) if err == nil { logging.Debug(logCtx, "metadata tree obtained from local branch", slog.String("tree_hash", metadataTree.Hash.String()), @@ -504,7 +504,7 @@ func getMetadataTree(ctx context.Context) (*object.Tree, *git.Repository, error) freshRepo, repoErr := openRepository(ctx) if repoErr == nil { logRefHash(freshRepo, "full-fetch") - metadataTree, treeErr := strategy.GetMetadataBranchTree(freshRepo) + metadataTree, treeErr := strategy.GetMetadataBranchTree(ctx, freshRepo) if treeErr == nil { logging.Debug(logCtx, "metadata tree obtained via full fetch", slog.String("tree_hash", metadataTree.Hash.String()), @@ -528,7 +528,7 @@ func getMetadataTree(ctx context.Context) (*object.Tree, *git.Repository, error) return nil, nil, fmt.Errorf("failed to open repository: %w", repoErr) } logRefHash(remoteRepo, "remote-tracking") - remoteTree, remoteErr := strategy.GetRemoteMetadataBranchTree(remoteRepo) + remoteTree, remoteErr := strategy.GetRemoteMetadataBranchTree(ctx, remoteRepo) if remoteErr == nil { logging.Debug(logCtx, "metadata tree obtained from remote-tracking branch") return remoteTree, remoteRepo, nil @@ -780,7 +780,7 @@ func checkRemoteMetadata(ctx context.Context, w, errW io.Writer, checkpointID id ) } else { defer freshRepo.Close() - if metadataTree, treeErr := strategy.GetMetadataBranchTree(freshRepo); treeErr != nil { + if metadataTree, treeErr := strategy.GetMetadataBranchTree(ctx, freshRepo); treeErr != nil { logging.Debug(logCtx, "checkpoint remote: fetch succeeded but tree read failed", slog.String("error", treeErr.Error()), ) @@ -802,7 +802,7 @@ func checkRemoteMetadata(ctx context.Context, w, errW io.Writer, checkpointID id } // Fall back to origin's remote-tracking branch - if remoteTree, treeErr := strategy.GetRemoteMetadataBranchTree(repo); treeErr == nil { + if remoteTree, treeErr := strategy.GetRemoteMetadataBranchTree(ctx, repo); treeErr == nil { metadata, err := tryReadCheckpointFromTree(ctx, remoteTree, repo, checkpointID) if err == nil { return resumeSession(ctx, w, errW, metadata, false) @@ -825,7 +825,7 @@ func checkRemoteMetadata(ctx context.Context, w, errW io.Writer, checkpointID id ) } else { defer freshRepo.Close() - if metadataTree, treeErr := strategy.GetMetadataBranchTree(freshRepo); treeErr != nil { + if metadataTree, treeErr := strategy.GetMetadataBranchTree(ctx, freshRepo); treeErr != nil { logging.Debug(logCtx, "origin metadata fetch succeeded but local branch read failed", slog.String("error", treeErr.Error()), ) @@ -853,9 +853,10 @@ func checkRemoteMetadata(ctx context.Context, w, errW io.Writer, checkpointID id } fmt.Fprintf(errW, "Ensure you have access to the checkpoint remote configured in .entire/settings.json.\n") } else { - fmt.Fprintf(errW, "Checkpoint '%s' found in commit but the entire/checkpoints/v1 branch is not available locally or on the remote.\n", checkpointID) - fmt.Fprintf(errW, "This can happen if the metadata branch was not pushed. Try:\n") - fmt.Fprintf(errW, " git fetch origin entire/checkpoints/v1:entire/checkpoints/v1\n") + refDisplay := checkpoint.RefDisplayName(checkpoint.MetadataRef(ctx)) + fmt.Fprintf(errW, "Checkpoint '%s' found in commit but the %s ref is not available locally or on the remote.\n", checkpointID, refDisplay) + fmt.Fprintf(errW, "This can happen if the metadata ref was not pushed. Try:\n") + fmt.Fprintf(errW, " git fetch origin %s\n", metadataFetchRefspec(ctx)) } return nil } @@ -866,8 +867,8 @@ func checkRemoteMetadata(ctx context.Context, w, errW io.Writer, checkpointID id // refs/remotes/origin/...: the committed-checkpoint store only falls back to // origin/... when the local ref is *missing*, not when it's behind. func promoteRemoteTrackingMetadataBranch(ctx context.Context, repo *git.Repository) { - localRefName := plumbing.NewBranchReferenceName(paths.MetadataBranchName) - remoteRef, err := repo.Reference(plumbing.NewRemoteReferenceName("origin", paths.MetadataBranchName), true) + localRefName := checkpoint.MetadataRef(ctx) + remoteRef, err := repo.Reference(checkpoint.MetadataTrackingRef(ctx), true) if err != nil { return } diff --git a/cmd/entire/cli/resume_test.go b/cmd/entire/cli/resume_test.go index eaf11f8831..a879a6bd9f 100644 --- a/cmd/entire/cli/resume_test.go +++ b/cmd/entire/cli/resume_test.go @@ -150,7 +150,7 @@ func setupResumeTestRepo(t *testing.T, tmpDir string, createFeatureBranch bool) } // Ensure entire/checkpoints/v1 branch exists - if err := strategy.EnsureMetadataBranch(repo); err != nil { + if err := strategy.EnsureMetadataBranch(context.Background(), repo); err != nil { t.Fatalf("Failed to create metadata branch: %v", err) } @@ -339,7 +339,7 @@ func createCheckpointOnMetadataBranchFull(t *testing.T, repo *git.Repository, se t.Helper() // Get existing metadata branch or create it - if err := strategy.EnsureMetadataBranch(repo); err != nil { + if err := strategy.EnsureMetadataBranch(context.Background(), repo); err != nil { t.Fatalf("Failed to ensure metadata branch: %v", err) } diff --git a/cmd/entire/cli/settings/settings.go b/cmd/entire/cli/settings/settings.go index bea299c180..f80114482b 100644 --- a/cmd/entire/cli/settings/settings.go +++ b/cmd/entire/cli/settings/settings.go @@ -12,7 +12,6 @@ import ( "os" "os/exec" "path/filepath" - "strconv" "strings" "sync" "time" @@ -1137,6 +1136,48 @@ func (s *EntireSettings) IsCheckpointsV2Enabled() bool { return ok && val } +// Recognized `strategy_options.checkpoints_version` literals. Accepted as +// `int`, `float64`, or `string` per the parsing rules in +// parseCheckpointsVersion. +const ( + checkpointsVersion1FloatLit = 1.0 + checkpointsVersion11FloatLit = 1.1 + checkpointsVersion2FloatLit = 2.0 + checkpointsVersion1StringLit = "1" + checkpointsVersion11StringLit = "1.1" + checkpointsVersion2StringLit = "2" +) + +// UsesCustomMetadataRef reports whether checkpoints metadata is stored at the +// custom ref (refs/entire/checkpoints/v1.1) rather than the legacy branch +// (refs/heads/entire/checkpoints/v1). True iff checkpoints_version is 1.1. +func (s *EntireSettings) UsesCustomMetadataRef() bool { + if s == nil || s.StrategyOptions == nil { + return false + } + val, ok := s.StrategyOptions["checkpoints_version"] + if !ok { + return false + } + switch v := val.(type) { + case float64: + return v == checkpointsVersion11FloatLit + case string: + return v == checkpointsVersion11StringLit + } + return false +} + +// UsesCustomMetadataRef is the package-level convenience wrapper. Returns +// false when settings cannot be loaded. +func UsesCustomMetadataRef(ctx context.Context) bool { + s, err := Load(ctx) + if err != nil { + return false + } + return s.UsesCustomMetadataRef() +} + // CheckpointsVersion returns the configured checkpoints format version from // strategy_options.checkpoints_version. Returns 1 when unset, invalid, or // unsupported. Version 2 is no longer an exclusive storage mode; reads use @@ -1191,19 +1232,24 @@ func warnCheckpointsV2Disallowed(val any) { } func parseCheckpointsVersion(val any) (int, bool) { - v, ok := val.(int) - if ok && (v == 1 || v == 2) { - return v, true - } - floatV, ok := val.(float64) - if ok && (floatV == 1 || floatV == 2) { - return int(floatV), true - } - stringV, ok := val.(string) - if ok { - parsed, err := strconv.Atoi(stringV) - if err == nil && (parsed == 1 || parsed == 2) { - return parsed, true + switch v := val.(type) { + case int: + if v == 1 || v == 2 { + return v, true + } + case float64: + switch v { + case checkpointsVersion1FloatLit, checkpointsVersion11FloatLit: + return 1, true + case checkpointsVersion2FloatLit: + return 2, true + } + case string: + switch v { + case checkpointsVersion1StringLit, checkpointsVersion11StringLit: + return 1, true + case checkpointsVersion2StringLit: + return 2, true } } return 1, false diff --git a/cmd/entire/cli/settings/settings_test.go b/cmd/entire/cli/settings/settings_test.go index 2782cd9df1..f3f8c8d6e8 100644 --- a/cmd/entire/cli/settings/settings_test.go +++ b/cmd/entire/cli/settings/settings_test.go @@ -856,6 +856,77 @@ func TestCheckpointsVersion(t *testing.T) { } } +func TestEntireSettings_UsesCustomMetadataRef(t *testing.T) { + t.Parallel() + cases := []struct { + name string + opts map[string]any + want bool + }{ + {"nil opts", nil, false}, + {"missing key", map[string]any{}, false}, + {"float 1.1", map[string]any{"checkpoints_version": 1.1}, true}, + {"string 1.1", map[string]any{"checkpoints_version": "1.1"}, true}, + {"int 1", map[string]any{"checkpoints_version": 1}, false}, + {"int 2", map[string]any{"checkpoints_version": 2}, false}, + {"float 1.5", map[string]any{"checkpoints_version": 1.5}, false}, + {"junk string", map[string]any{"checkpoints_version": "junk"}, false}, + {"bool", map[string]any{"checkpoints_version": true}, false}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + s := &EntireSettings{StrategyOptions: tc.opts} + if got := s.UsesCustomMetadataRef(); got != tc.want { + t.Fatalf("UsesCustomMetadataRef() = %v; want %v", got, tc.want) + } + }) + } +} + +func TestEntireSettings_OneDotOneMajorVersionStillOne(t *testing.T) { + t.Parallel() + s := &EntireSettings{StrategyOptions: map[string]any{"checkpoints_version": 1.1}} + if got := s.CheckpointsVersion(); got != 1 { + t.Fatalf("CheckpointsVersion() = %d; want 1", got) + } + if got := s.IsCheckpointsV2Enabled(); got { + t.Fatalf("IsCheckpointsV2Enabled() = true; want false (1.1 is a v1 sub-mode)") + } +} + +func TestParseCheckpointsVersion_OneDotOne(t *testing.T) { + t.Parallel() + cases := []struct { + name string + input any + wantMajor int + wantOK bool + }{ + {"float 1.1", 1.1, 1, true}, + {"string 1.1", "1.1", 1, true}, + {"float 1.0 still major 1", 1.0, 1, true}, + {"float 2.0 still major 2", 2.0, 2, true}, + {"string 2", "2", 2, true}, + {"string 1", "1", 1, true}, + {"int 1", 1, 1, true}, + {"int 2", 2, 2, true}, + {"unsupported float 1.5", 1.5, 1, false}, + {"unsupported string 3", "3", 1, false}, + {"unsupported bool", true, 1, false}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + gotMajor, gotOK := parseCheckpointsVersion(tc.input) + if gotMajor != tc.wantMajor || gotOK != tc.wantOK { + t.Fatalf("parseCheckpointsVersion(%#v) = (%d, %v); want (%d, %v)", + tc.input, gotMajor, gotOK, tc.wantMajor, tc.wantOK) + } + }) + } +} + func TestWarnIfCheckpointsV2Disallowed(t *testing.T) { tests := []struct { name string diff --git a/cmd/entire/cli/setup.go b/cmd/entire/cli/setup.go index 15532dd7a0..a9971bbcec 100644 --- a/cmd/entire/cli/setup.go +++ b/cmd/entire/cli/setup.go @@ -6,12 +6,14 @@ import ( "fmt" "io" "os" + "os/exec" "path/filepath" "strings" "github.com/entireio/cli/cmd/entire/cli/agent" "github.com/entireio/cli/cmd/entire/cli/agent/external" "github.com/entireio/cli/cmd/entire/cli/agent/types" + "github.com/entireio/cli/cmd/entire/cli/gitremote" "github.com/entireio/cli/cmd/entire/cli/interactive" "github.com/entireio/cli/cmd/entire/cli/logging" "github.com/entireio/cli/cmd/entire/cli/paths" @@ -868,6 +870,16 @@ for you and (optionally) create a matching GitHub repository via the gh CLI.`, if !usedSetupFlow { fmt.Fprintln(w, "Entire is already enabled.") } + // Re-running `entire enable` on an already-enabled repo is + // the documented opt-in path for 1.1 (edit settings, re-run + // enable). installMetadataRefspec is idempotent, so calling + // it here makes that flow actually install the fetch + // refspec instead of silently no-opping. + if settings.UsesCustomMetadataRef(ctx) { + if err := installMetadataRefspec(ctx); err != nil { + fmt.Fprintf(w, "Warning: failed to install fetch refspec for %s: %v\n", paths.MetadataRefName, err) + } + } printEnabledStatus(ctx, w) return nil } @@ -1116,6 +1128,33 @@ func runEnable(ctx context.Context, w io.Writer, useProjectSettings bool) error fmt.Fprintln(w, "Entire is now enabled.") printEnabledStatus(ctx, w) + + if s.UsesCustomMetadataRef() { + if err := installMetadataRefspec(ctx); err != nil { + fmt.Fprintf(w, "Warning: failed to install fetch refspec for %s: %v\n", paths.MetadataRefName, err) + } + } + return nil +} + +// installMetadataRefspec appends a fetch refspec on origin so subsequent +// `git fetch` calls pull refs/entire/checkpoints/v1.1 into the non-clobbering +// remote-tracking ref. Idempotent. No-op when origin is absent or the +// refspec is already present. +func installMetadataRefspec(ctx context.Context) error { + if _, err := gitremote.GetRemoteURL(ctx, "origin"); err != nil { + return nil //nolint:nilerr // no origin configured is not an error for this idempotent helper + } + refspec := "+" + paths.MetadataRefName + ":" + paths.MetadataTrackingRefName + out, _ := exec.CommandContext(ctx, "git", "config", "--get-all", "remote.origin.fetch").Output() //nolint:errcheck // missing config key is normal; treated as "no refspec set yet" + for _, line := range strings.Split(string(out), "\n") { + if strings.TrimSpace(line) == refspec { + return nil + } + } + if err := exec.CommandContext(ctx, "git", "config", "--add", "remote.origin.fetch", refspec).Run(); err != nil { + return fmt.Errorf("git config --add remote.origin.fetch %s: %w", refspec, err) + } return nil } diff --git a/cmd/entire/cli/setup_test.go b/cmd/entire/cli/setup_test.go index 01270ccc76..97af0f2272 100644 --- a/cmd/entire/cli/setup_test.go +++ b/cmd/entire/cli/setup_test.go @@ -17,6 +17,7 @@ import ( "github.com/entireio/cli/cmd/entire/cli/agent/external" _ "github.com/entireio/cli/cmd/entire/cli/agent/geminicli" "github.com/entireio/cli/cmd/entire/cli/agent/types" + "github.com/entireio/cli/cmd/entire/cli/execx" "github.com/entireio/cli/cmd/entire/cli/paths" "github.com/entireio/cli/cmd/entire/cli/session" "github.com/entireio/cli/cmd/entire/cli/settings" @@ -3089,3 +3090,75 @@ func TestConfigureCmd_FreshRepo_PointsAtEnable(t *testing.T) { t.Errorf("expected hint pointing at 'entire enable', got stderr: %s", stderr.String()) } } + +func TestInstallMetadataRefspec_NoOrigin_NoOp(t *testing.T) { + dir := t.TempDir() + testutil.InitRepo(t, dir) + t.Chdir(dir) + if err := installMetadataRefspec(context.Background()); err != nil { + t.Fatalf("installMetadataRefspec: %v", err) + } + out, _ := execx.NonInteractive(context.Background(), "git", "config", "--get-all", "remote.origin.fetch").Output() //nolint:errcheck // missing config key is normal + if strings.Contains(string(out), paths.MetadataRefName) { + t.Fatalf("refspec should not be installed when origin is absent") + } +} + +func TestInstallMetadataRefspec_AddsIdempotently(t *testing.T) { + dir := t.TempDir() + testutil.InitRepo(t, dir) + t.Chdir(dir) + if out, err := execx.NonInteractive(context.Background(), "git", "remote", "add", "origin", "https://example.invalid/repo.git").CombinedOutput(); err != nil { + t.Fatalf("git remote add: %v: %s", err, out) + } + for range 2 { + if err := installMetadataRefspec(context.Background()); err != nil { + t.Fatalf("installMetadataRefspec: %v", err) + } + } + out, _ := execx.NonInteractive(context.Background(), "git", "config", "--get-all", "remote.origin.fetch").Output() //nolint:errcheck // missing config key is normal + refspec := "+" + paths.MetadataRefName + ":" + paths.MetadataTrackingRefName + if got := strings.Count(string(out), refspec); got != 1 { + t.Fatalf("refspec count = %d; want 1\noutput:\n%s", got, out) + } +} + +func TestRunEnable_On1_1Repo_InstallsRefspec(t *testing.T) { + dir := t.TempDir() + testutil.InitRepo(t, dir) + t.Chdir(dir) + if out, err := execx.NonInteractive(context.Background(), "git", "remote", "add", "origin", "https://example.invalid/repo.git").CombinedOutput(); err != nil { + t.Fatalf("git remote add: %v: %s", err, out) + } + testutil.WriteFile(t, dir, ".entire/settings.json", `{"strategy_options":{"checkpoints_version":"1.1"}}`) + + var buf bytes.Buffer + if err := runEnable(context.Background(), &buf, true); err != nil { + t.Fatalf("runEnable: %v", err) + } + + out, _ := execx.NonInteractive(context.Background(), "git", "config", "--get-all", "remote.origin.fetch").Output() //nolint:errcheck // missing config key is normal + refspec := "+" + paths.MetadataRefName + ":" + paths.MetadataTrackingRefName + if !strings.Contains(string(out), refspec) { + t.Fatalf("refspec not installed; remote.origin.fetch=%s", string(out)) + } +} + +func TestRunEnable_OnV1Repo_DoesNotTouchRefspec(t *testing.T) { + dir := t.TempDir() + testutil.InitRepo(t, dir) + t.Chdir(dir) + if out, err := execx.NonInteractive(context.Background(), "git", "remote", "add", "origin", "https://example.invalid/repo.git").CombinedOutput(); err != nil { + t.Fatalf("git remote add: %v: %s", err, out) + } + testutil.WriteFile(t, dir, ".entire/settings.json", `{"strategy_options":{"checkpoints_version":1}}`) + + var buf bytes.Buffer + if err := runEnable(context.Background(), &buf, true); err != nil { + t.Fatalf("runEnable: %v", err) + } + out, _ := execx.NonInteractive(context.Background(), "git", "config", "--get-all", "remote.origin.fetch").Output() //nolint:errcheck // missing config key is normal + if strings.Contains(string(out), paths.MetadataRefName) { + t.Fatalf("v1 repo should not get the 1.1 refspec; got %s", string(out)) + } +} diff --git a/cmd/entire/cli/strategy/checkpoint_remote.go b/cmd/entire/cli/strategy/checkpoint_remote.go index a433153652..ddfbe82483 100644 --- a/cmd/entire/cli/strategy/checkpoint_remote.go +++ b/cmd/entire/cli/strategy/checkpoint_remote.go @@ -7,6 +7,7 @@ import ( "strings" "time" + "github.com/entireio/cli/cmd/entire/cli/checkpoint" "github.com/entireio/cli/cmd/entire/cli/checkpoint/remote" "github.com/entireio/cli/cmd/entire/cli/logging" "github.com/entireio/cli/cmd/entire/cli/paths" @@ -95,22 +96,25 @@ func resolvePushSettings(ctx context.Context, pushRemoteName string) pushSetting return ps } -// FetchMetadataBranch fetches the metadata branch from the checkpoint remote URL -// and updates the local branch. Unlike fetchMetadataBranchIfMissing, this always -// fetches regardless of whether the branch exists locally (for resume scenarios -// where the local branch may be stale). +// FetchMetadataBranch fetches the metadata ref from the checkpoint remote URL +// and updates the local ref. Unlike fetchMetadataBranchIfMissing, this always +// fetches regardless of whether the ref exists locally (for resume scenarios +// where the local ref may be stale). +// +// Honors the 1.1 custom-ref namespace when configured. // // The fetch is unfiltered (NoFilter: true) because resume needs blob content // (transcripts, metadata JSON) — not just tree objects. func FetchMetadataBranch(ctx context.Context, remoteURL string) error { - branchName := paths.MetadataBranchName - tmpRef := FetchTmpRefPrefix + branchName - srcRef := "refs/heads/" + branchName + localRef := checkpoint.MetadataRef(ctx) + refDisplay := checkpoint.RefDisplayName(localRef) + tmpRef := FetchTmpRefPrefix + refDisplay + srcRef := string(localRef) if err := fetchURLIntoTmpRef(ctx, remoteURL, srcRef, tmpRef, "metadata branch", true); err != nil { return err } - return PromoteTmpRefSafely(ctx, plumbing.ReferenceName(tmpRef), plumbing.NewBranchReferenceName(branchName), branchName) + return PromoteTmpRefSafely(ctx, plumbing.ReferenceName(tmpRef), localRef, refDisplay) } // FetchV2MainFromURL fetches the v2 /main ref from a remote URL and advances @@ -169,10 +173,10 @@ func fetchMetadataBranchIfMissing(ctx context.Context, remoteURL string) error { } defer repo.Close() - // Check if branch already exists locally - if so, nothing to do - branchRef := plumbing.NewBranchReferenceName(paths.MetadataBranchName) + // Check if ref already exists locally - if so, nothing to do + branchRef := checkpoint.MetadataRef(ctx) if _, err := repo.Reference(branchRef, true); err == nil { - return nil // Branch exists locally, skip fetch + return nil // Ref exists locally, skip fetch } // Branch doesn't exist locally - try to fetch it from the URL. diff --git a/cmd/entire/cli/strategy/cleanup.go b/cmd/entire/cli/strategy/cleanup.go index 446d1bee3a..e09e53aed4 100644 --- a/cmd/entire/cli/strategy/cleanup.go +++ b/cmd/entire/cli/strategy/cleanup.go @@ -62,7 +62,9 @@ var shadowBranchPattern = regexp.MustCompile(`^entire/[0-9a-fA-F]{7,}(-[0-9a-fA- // IsShadowBranch returns true if the branch name matches the shadow branch pattern. // Shadow branches have the format "entire/-" where the // commit hash is at least 7 hex characters and worktree hash is 6 hex characters. -// The "entire/checkpoints/v1" branch is NOT a shadow branch. +// The "entire/checkpoints/v1" branch (legacy v1 metadata) is NOT a shadow branch. +// On 1.1 repos the metadata lives at the custom ref refs/entire/checkpoints/v1.1 +// (outside refs/heads/), so it never enters this comparison at all. func IsShadowBranch(branchName string) bool { // Explicitly exclude metadata and trails branches if branchName == paths.MetadataBranchName || branchName == paths.TrailsBranchName { @@ -252,11 +254,11 @@ func DeleteOrphanedCheckpoints(ctx context.Context, checkpointIDs []string) (del } defer repo.Close() - // Get sessions branch - refName := plumbing.NewBranchReferenceName(paths.MetadataBranchName) + // Get metadata ref (legacy branch or 1.1 custom ref) + refName := checkpoint.MetadataRef(ctx) ref, err := repo.Reference(refName, true) if err != nil { - return nil, nil, fmt.Errorf("sessions branch not found: %w", err) + return nil, nil, fmt.Errorf("metadata ref %q not found: %w", checkpoint.RefDisplayName(refName), err) } parentCommit, err := repo.CommitObject(ref.Hash()) diff --git a/cmd/entire/cli/strategy/common.go b/cmd/entire/cli/strategy/common.go index d6e098b8db..e5a370f71f 100644 --- a/cmd/entire/cli/strategy/common.go +++ b/cmd/entire/cli/strategy/common.go @@ -77,7 +77,7 @@ func EnsureSetup(ctx context.Context) error { if err := vercelconfig.InitSettings(ctx); err != nil { return fmt.Errorf("failed to initialize vercel settings: %w", err) } - if err := EnsureMetadataBranch(repo); err != nil { + if err := EnsureMetadataBranch(ctx, repo); err != nil { return fmt.Errorf("failed to ensure metadata branch: %w", err) } @@ -460,41 +460,48 @@ func resolveAgentType(ctxAgentType types.AgentType, state *SessionState) types.A return ctxAgentType } -// EnsureMetadataBranch creates or updates the local entire/checkpoints/v1 branch. -// If the remote-tracking branch (origin/entire/checkpoints/v1) exists and the local -// branch is missing or empty, creates/updates the local branch from it. +// EnsureMetadataBranch creates or updates the local metadata ref. +// On legacy v1 repos this is the branch refs/heads/entire/checkpoints/v1; +// on 1.1 repos it is the custom ref refs/entire/checkpoints/v1.1, resolved +// via checkpoint.MetadataRef. If the remote-tracking ref exists and the +// local ref is missing or empty, creates/updates the local ref from it. // Otherwise creates an empty orphan. -func EnsureMetadataBranch(repo *git.Repository) error { - refName := plumbing.NewBranchReferenceName(paths.MetadataBranchName) - - // Check if remote-tracking branch exists (e.g., after clone/fetch) - remoteRefName := plumbing.NewRemoteReferenceName("origin", paths.MetadataBranchName) +// +// On 1.1 repos the custom ref starts empty; prior history on the legacy +// branch is NOT automatically imported. A future migration command can +// handle that. +func EnsureMetadataBranch(ctx context.Context, repo *git.Repository) error { + refName := checkpoint.MetadataRef(ctx) + refDisplay := checkpoint.RefDisplayName(refName) + + // Check if remote-tracking ref exists (e.g., after clone/fetch) + remoteRefName := checkpoint.MetadataTrackingRef(ctx) remoteRef, remoteErr := repo.Reference(remoteRefName, true) if remoteErr != nil && !errors.Is(remoteErr, plumbing.ErrReferenceNotFound) { - return fmt.Errorf("failed to check remote metadata branch: %w", remoteErr) + return fmt.Errorf("failed to check remote metadata ref: %w", remoteErr) } - // Check if local branch already exists + // Check if local ref already exists localRef, err := repo.Reference(refName, true) if err == nil { if remoteErr == nil && localRef.Hash() != remoteRef.Hash() { // Local and remote exist but differ — determine relationship isEmpty, checkErr := isEmptyMetadataBranch(repo, localRef) if checkErr != nil { - return fmt.Errorf("failed to check metadata branch contents: %w", checkErr) + return fmt.Errorf("failed to check metadata ref contents: %w", checkErr) } if isEmpty { // Empty orphan — just point to remote ref := plumbing.NewHashReference(refName, remoteRef.Hash()) if setErr := repo.Storer.SetReference(ref); setErr != nil { - return fmt.Errorf("failed to update metadata branch from remote: %w", setErr) + return fmt.Errorf("failed to update metadata ref from remote: %w", setErr) } - fmt.Fprintf(os.Stderr, "[entire] Updated local branch '%s' from origin\n", paths.MetadataBranchName) + fmt.Fprintf(os.Stderr, "[entire] Updated local ref '%s' from origin\n", refDisplay) } else { // Local has real data and differs from remote — if disconnected // (no common ancestor), reconciliation happens at pre-push time // or via 'entire doctor'. Read paths warn but do not auto-fix. - logging.Debug(context.Background(), "metadata branch differs from remote, reconciliation deferred to read/write time", + logging.Debug(ctx, "metadata ref differs from remote, reconciliation deferred to read/write time", "local_hash", localRef.Hash().String()[:7], "remote_hash", remoteRef.Hash().String()[:7], ) @@ -503,16 +510,16 @@ func EnsureMetadataBranch(repo *git.Repository) error { return nil } if !errors.Is(err, plumbing.ErrReferenceNotFound) { - return fmt.Errorf("failed to check metadata branch: %w", err) + return fmt.Errorf("failed to check metadata ref: %w", err) } - // Local branch doesn't exist — create from remote if available + // Local ref doesn't exist — create from remote if available if remoteErr == nil { ref := plumbing.NewHashReference(refName, remoteRef.Hash()) if err := repo.Storer.SetReference(ref); err != nil { - return fmt.Errorf("failed to create metadata branch from remote: %w", err) + return fmt.Errorf("failed to create metadata ref from remote: %w", err) } - fmt.Fprintf(os.Stderr, "✓ Created local branch '%s' from origin\n", paths.MetadataBranchName) + fmt.Fprintf(os.Stderr, "✓ Created local ref '%s' from origin\n", refDisplay) return nil } @@ -571,7 +578,7 @@ func EnsureMetadataBranch(repo *git.Repository) error { return fmt.Errorf("failed to create metadata branch: %w", err) } - fmt.Fprintf(os.Stderr, " ✓ Created orphan branch %s for session metadata\n", paths.MetadataBranchName) + fmt.Fprintf(os.Stderr, " ✓ Created orphan ref %s for session metadata\n", refDisplay) return nil } @@ -768,9 +775,11 @@ func decodeCheckpointInfo( return &metadata, nil } -// GetMetadataBranchTree returns the tree object for the entire/checkpoints/v1 branch. -func GetMetadataBranchTree(repo *git.Repository) (*object.Tree, error) { - refName := plumbing.NewBranchReferenceName(paths.MetadataBranchName) +// GetMetadataBranchTree returns the tree object for the v1 metadata ref. +// Resolves to the legacy branch ref or the 1.1 custom ref via +// checkpoint.MetadataRef. +func GetMetadataBranchTree(ctx context.Context, repo *git.Repository) (*object.Tree, error) { + refName := checkpoint.MetadataRef(ctx) ref, err := repo.Reference(refName, true) if err != nil { return nil, fmt.Errorf("failed to get metadata branch reference: %w", err) @@ -998,9 +1007,10 @@ func ReadAllSessionPromptsFromTree(tree *object.Tree, checkpointPath string, ses return prompts } -// GetRemoteMetadataBranchTree returns the tree object for origin/entire/checkpoints/v1. -func GetRemoteMetadataBranchTree(repo *git.Repository) (*object.Tree, error) { - refName := plumbing.NewRemoteReferenceName("origin", paths.MetadataBranchName) +// GetRemoteMetadataBranchTree returns the tree object for the v1 metadata +// tracking ref (the remote-tracking counterpart of MetadataRef). +func GetRemoteMetadataBranchTree(ctx context.Context, repo *git.Repository) (*object.Tree, error) { + refName := checkpoint.MetadataTrackingRef(ctx) ref, err := repo.Reference(refName, true) if err != nil { return nil, fmt.Errorf("failed to get remote metadata branch reference: %w", err) diff --git a/cmd/entire/cli/strategy/common_test.go b/cmd/entire/cli/strategy/common_test.go index 02971b69b9..cb8889958d 100644 --- a/cmd/entire/cli/strategy/common_test.go +++ b/cmd/entire/cli/strategy/common_test.go @@ -978,7 +978,7 @@ func TestEnsureMetadataBranch(t *testing.T) { t.Fatalf("failed to open repo: %v", err) } - if err := EnsureMetadataBranch(repo); err != nil { + if err := EnsureMetadataBranch(context.Background(), repo); err != nil { t.Fatalf("EnsureMetadataBranch() failed: %v", err) } @@ -1042,7 +1042,7 @@ func TestEnsureMetadataBranch(t *testing.T) { t.Fatalf("failed to set ref: %v", err) } - if err := EnsureMetadataBranch(repo); err != nil { + if err := EnsureMetadataBranch(context.Background(), repo); err != nil { t.Fatalf("EnsureMetadataBranch() failed: %v", err) } @@ -1064,7 +1064,7 @@ func TestEnsureMetadataBranch(t *testing.T) { if err != nil { t.Fatalf("failed to open repo: %v", err) } - if err := EnsureMetadataBranch(repo); err != nil { + if err := EnsureMetadataBranch(context.Background(), repo); err != nil { t.Fatalf("EnsureMetadataBranch() failed: %v", err) } @@ -1108,7 +1108,7 @@ func TestEnsureMetadataBranch_WritesVercelConfigWhenEnabled(t *testing.T) { t.Fatalf("InitSettings() failed: %v", err) } - if err := EnsureMetadataBranch(repo); err != nil { + if err := EnsureMetadataBranch(context.Background(), repo); err != nil { t.Fatalf("EnsureMetadataBranch() failed: %v", err) } @@ -1198,7 +1198,7 @@ func TestEnsureMetadataBranch_DisconnectedBranchesNotReconciledInEnable(t *testi t.Fatalf("local branch not found: %v", err) } - if err := EnsureMetadataBranch(repo); err != nil { + if err := EnsureMetadataBranch(context.Background(), repo); err != nil { t.Fatalf("EnsureMetadataBranch() failed: %v", err) } @@ -1225,7 +1225,7 @@ func TestEnsureMetadataBranch_DoesNotFastForwardWhenBehind(t *testing.T) { if err != nil { t.Fatalf("failed to open repo: %v", err) } - if err := EnsureMetadataBranch(repo); err != nil { + if err := EnsureMetadataBranch(context.Background(), repo); err != nil { t.Fatalf("first EnsureMetadataBranch() failed: %v", err) } @@ -1265,7 +1265,7 @@ func TestEnsureMetadataBranch_DoesNotFastForwardWhenBehind(t *testing.T) { t.Fatalf("failed to reopen repo: %v", err) } - if err := EnsureMetadataBranch(repo); err != nil { + if err := EnsureMetadataBranch(context.Background(), repo); err != nil { t.Fatalf("second EnsureMetadataBranch() failed: %v", err) } diff --git a/cmd/entire/cli/strategy/manual_commit_logs.go b/cmd/entire/cli/strategy/manual_commit_logs.go index e75ee510fb..61f39e90de 100644 --- a/cmd/entire/cli/strategy/manual_commit_logs.go +++ b/cmd/entire/cli/strategy/manual_commit_logs.go @@ -5,6 +5,7 @@ import ( "fmt" "strings" + "github.com/entireio/cli/cmd/entire/cli/checkpoint" "github.com/entireio/cli/cmd/entire/cli/paths" "github.com/entireio/cli/cmd/entire/cli/trailers" @@ -65,17 +66,8 @@ func (s *ManualCommitStrategy) GetSessionInfo(ctx context.Context) (*SessionInfo return info, nil } -// GetMetadataRef returns a reference to the metadata for the given checkpoint. -// For manual-commit strategy, returns the sharded path on entire/checkpoints/v1 branch. -func (s *ManualCommitStrategy) GetMetadataRef(_ context.Context, checkpoint Checkpoint) string { - if checkpoint.CheckpointID.IsEmpty() { - return "" - } - return paths.MetadataBranchName + ":" + checkpoint.CheckpointID.Path() -} - // GetSessionMetadataRef returns a reference to the most recent metadata commit for a session. -// For manual-commit strategy, metadata lives on the entire/checkpoints/v1 branch. +// For manual-commit strategy, metadata lives on the v1 metadata ref (legacy branch or 1.1 custom ref). func (s *ManualCommitStrategy) GetSessionMetadataRef(ctx context.Context, _ string) string { repo, err := OpenRepository(ctx) if err != nil { @@ -83,16 +75,14 @@ func (s *ManualCommitStrategy) GetSessionMetadataRef(ctx context.Context, _ stri } defer repo.Close() - // Get the sessions branch - refName := plumbing.NewBranchReferenceName(paths.MetadataBranchName) + refName := checkpoint.MetadataRef(ctx) ref, err := repo.Reference(refName, true) if err != nil { return "" } - // The tip of entire/checkpoints/v1 contains all condensed sessions - // Return a reference to it (sessionID is not used as all sessions are on the same branch) - return trailers.FormatSourceRef(paths.MetadataBranchName, ref.Hash().String()) + display := checkpoint.RefDisplayName(refName) + return trailers.FormatSourceRef(display, ref.Hash().String()) } // GetCheckpointLog returns the session transcript for a specific checkpoint. diff --git a/cmd/entire/cli/strategy/manual_commit_push.go b/cmd/entire/cli/strategy/manual_commit_push.go index e430deac08..b86f0a6d6d 100644 --- a/cmd/entire/cli/strategy/manual_commit_push.go +++ b/cmd/entire/cli/strategy/manual_commit_push.go @@ -3,13 +3,13 @@ package strategy import ( "context" - "github.com/entireio/cli/cmd/entire/cli/paths" + "github.com/entireio/cli/cmd/entire/cli/checkpoint" "github.com/entireio/cli/cmd/entire/cli/settings" "github.com/entireio/cli/perf" ) // PrePush is called by the git pre-push hook before pushing to a remote. -// It pushes the entire/checkpoints/v1 branch alongside the user's push. +// It pushes the checkpoint metadata ref alongside the user's push. // Legacy checkpoints v2 settings are ignored and warn before falling back to v1. // // If a checkpoint_remote is configured in settings, checkpoint branches/refs @@ -35,8 +35,14 @@ func (s *ManualCommitStrategy) PrePush(ctx context.Context, remote string) error // Thread the span's context into the push so the network push and any // fetch+rebase recovery nest beneath it as child steps in the perf trace. + // Use ps.remote (the user's actual push remote, e.g. "upstream") for the + // tracking ref, not pushTarget() which can be the checkpoint_remote URL — + // the tracking ref must be the local mirror of the remote being pushed + // to, not the URL push target. pushCtx, pushCheckpointsSpan := perf.Start(ctx, "push_checkpoints_branch") - err := pushBranchIfNeeded(pushCtx, ps.pushTarget(), paths.MetadataBranchName) + err := pushRefIfNeeded(pushCtx, ps.pushTarget(), + checkpoint.MetadataRef(ctx), + checkpoint.MetadataTrackingRefForRemote(ctx, ps.remote)) pushCheckpointsSpan.RecordError(err) pushCheckpointsSpan.End() diff --git a/cmd/entire/cli/strategy/manual_commit_rewind.go b/cmd/entire/cli/strategy/manual_commit_rewind.go index 150b95bcb6..b2c4f187f6 100644 --- a/cmd/entire/cli/strategy/manual_commit_rewind.go +++ b/cmd/entire/cli/strategy/manual_commit_rewind.go @@ -161,7 +161,7 @@ func (s *ManualCommitStrategy) GetLogsOnlyRewindPoints(ctx context.Context, limi } // Get metadata branch tree for reading session prompts (best-effort, ignore errors) - metadataTree, _ := GetMetadataBranchTree(repo) //nolint:errcheck // Best-effort for session prompts + metadataTree, _ := GetMetadataBranchTree(ctx, repo) //nolint:errcheck // Best-effort for session prompts head, err := repo.Head() if err != nil { diff --git a/cmd/entire/cli/strategy/metadata_reconcile.go b/cmd/entire/cli/strategy/metadata_reconcile.go index 8f4879571f..b5c2a9e444 100644 --- a/cmd/entire/cli/strategy/metadata_reconcile.go +++ b/cmd/entire/cli/strategy/metadata_reconcile.go @@ -31,7 +31,7 @@ var disconnectedOnce sync.Once //nolint:gochecknoglobals // intentional per-proc // and the provided fetched or remote-tracking ref exist but share no common // ancestor. func IsMetadataDisconnected(ctx context.Context, repo *git.Repository, remoteRefName plumbing.ReferenceName) (bool, error) { - refName := plumbing.NewBranchReferenceName(paths.MetadataBranchName) + refName := checkpoint.MetadataRef(ctx) localRef, err := repo.Reference(refName, true) if errors.Is(err, plumbing.ErrReferenceNotFound) { return false, nil @@ -77,7 +77,7 @@ func WarnIfMetadataDisconnected() { return } defer repo.Close() - disconnected, err := IsMetadataDisconnected(ctx, repo, plumbing.NewRemoteReferenceName("origin", paths.MetadataBranchName)) + disconnected, err := IsMetadataDisconnected(ctx, repo, checkpoint.MetadataTrackingRef(ctx)) if err != nil { logging.Debug(ctx, "metadata disconnection check failed", slog.String("error", err.Error())) @@ -109,7 +109,7 @@ func ReconcileDisconnectedMetadataBranch( remoteRefName plumbing.ReferenceName, w io.Writer, ) error { - refName := plumbing.NewBranchReferenceName(paths.MetadataBranchName) + refName := checkpoint.MetadataRef(ctx) // Check local branch localRef, err := repo.Reference(refName, true) diff --git a/cmd/entire/cli/strategy/metadata_reconcile_test.go b/cmd/entire/cli/strategy/metadata_reconcile_test.go index 97420615bc..d1f88006ca 100644 --- a/cmd/entire/cli/strategy/metadata_reconcile_test.go +++ b/cmd/entire/cli/strategy/metadata_reconcile_test.go @@ -97,7 +97,7 @@ func TestReconcileDisconnected_SameHash(t *testing.T) { } // Create local branch from remote (same hash) - if err := EnsureMetadataBranch(repo); err != nil { + if err := EnsureMetadataBranch(context.Background(), repo); err != nil { t.Fatalf("EnsureMetadataBranch failed: %v", err) } @@ -119,7 +119,7 @@ func TestReconcileDisconnected_SharedAncestry(t *testing.T) { } // Create local branch from remote (shared base) - if err := EnsureMetadataBranch(repo); err != nil { + if err := EnsureMetadataBranch(context.Background(), repo); err != nil { t.Fatalf("EnsureMetadataBranch failed: %v", err) } @@ -461,7 +461,7 @@ func TestIsMetadataDisconnected_SameHash(t *testing.T) { t.Fatalf("failed to open repo: %v", err) } - if err := EnsureMetadataBranch(repo); err != nil { + if err := EnsureMetadataBranch(context.Background(), repo); err != nil { t.Fatalf("EnsureMetadataBranch failed: %v", err) } @@ -485,7 +485,7 @@ func TestIsMetadataDisconnected_SharedAncestry(t *testing.T) { t.Fatalf("failed to open repo: %v", err) } - if err := EnsureMetadataBranch(repo); err != nil { + if err := EnsureMetadataBranch(context.Background(), repo); err != nil { t.Fatalf("EnsureMetadataBranch failed: %v", err) } diff --git a/cmd/entire/cli/strategy/push_common.go b/cmd/entire/cli/strategy/push_common.go index 42908e8003..eb8e5353cf 100644 --- a/cmd/entire/cli/strategy/push_common.go +++ b/cmd/entire/cli/strategy/push_common.go @@ -12,6 +12,7 @@ import ( "sync" "time" + "github.com/entireio/cli/cmd/entire/cli/checkpoint" "github.com/entireio/cli/cmd/entire/cli/checkpoint/remote" "github.com/entireio/cli/cmd/entire/cli/logging" "github.com/entireio/cli/cmd/entire/cli/settings" @@ -22,49 +23,55 @@ import ( "github.com/go-git/go-git/v6/plumbing/object" ) -// pushBranchIfNeeded pushes a branch to the given target if it has unpushed changes. -// The target can be a remote name (e.g., "origin") or a URL for direct push. -// When pushing to a URL, the "has unpushed" optimization is skipped since there are -// no remote tracking refs — git itself handles the no-op case. +// pushRefIfNeeded pushes localRef to the given target if it differs from +// trackingRef. The target can be a remote name (e.g., "origin") or a URL. +// +// localRef is the full local ref name (e.g., refs/heads/entire/checkpoints/v1 +// or refs/entire/checkpoints/v1.1). trackingRef is the local remote-tracking +// counterpart. The push refspec sent on the wire is ":". +// +// When pushing to a URL, the "has unpushed" optimization is skipped since +// there is no remote tracking ref — git itself handles the no-op case. // Does not check any settings — callers are responsible for gating. -func pushBranchIfNeeded(ctx context.Context, target, branchName string) error { +func pushRefIfNeeded(ctx context.Context, target string, localRef, trackingRef plumbing.ReferenceName) error { repo, err := OpenRepository(ctx) if err != nil { return nil } defer repo.Close() - // Check if branch exists locally - branchRef := plumbing.NewBranchReferenceName(branchName) - localRef, err := repo.Reference(branchRef, true) + local, err := repo.Reference(localRef, true) if err != nil { - // No branch, nothing to push + // No local ref, nothing to push return nil } - // Only check remote tracking refs when target is a remote name (not a URL). - // URLs don't have tracking refs, so we always attempt the push and let git handle it. - if !remote.IsURL(target) && !hasUnpushedSessionsCommon(repo, target, localRef.Hash(), branchName) { + // Only check tracking ref when target is a remote name (not a URL). + // URLs don't update tracking refs, so we always attempt the push. + if !remote.IsURL(target) && !hasUnpushedSessionsCommon(repo, local.Hash(), trackingRef) { return nil } - return doPushBranch(ctx, target, branchName) + return doPushRef(ctx, target, localRef, trackingRef) } -// hasUnpushedSessionsCommon checks if the local branch differs from the remote. -// Returns true if there's any difference that needs syncing (local ahead, remote ahead, or diverged). -func hasUnpushedSessionsCommon(repo *git.Repository, remoteName string, localHash plumbing.Hash, branchName string) bool { - // Check for remote tracking ref: refs/remotes// - remoteRefName := plumbing.NewRemoteReferenceName(remoteName, branchName) - remoteRef, err := repo.Reference(remoteRefName, true) +// hasUnpushedSessionsCommon reports whether the local hash differs from the +// tracking ref. Returns true (push needed) when the tracking ref does not +// exist or when the hashes differ. +func hasUnpushedSessionsCommon(repo *git.Repository, localHash plumbing.Hash, trackingRef plumbing.ReferenceName) bool { + ref, err := repo.Reference(trackingRef, true) if err != nil { - // Remote branch doesn't exist yet - we have content to push + // Tracking ref doesn't exist yet — we have content to push return true } + return localHash != ref.Hash() +} - // If local and remote point to same commit, nothing to sync - // This is the only case where we skip - any difference needs handling - return localHash != remoteRef.Hash() +// refDisplayName is a package-local alias for checkpoint.RefDisplayName so +// the existing call sites in this file stay short. The shared +// implementation lives in checkpoint/metadata_ref.go. +func refDisplayName(ref plumbing.ReferenceName) string { + return checkpoint.RefDisplayName(ref) } func displayPushTarget(target string) string { @@ -79,19 +86,21 @@ func displayPushTarget(target string) string { // can shrink it. var checkpointPushBudget = 2 * time.Minute -// doPushBranch pushes the given branch to the target with fetch+merge recovery. +// doPushRef pushes localRef to the target with fetch+merge recovery. // The target can be a remote name or a URL. -func doPushBranch(ctx context.Context, target, branchName string) error { +func doPushRef(ctx context.Context, target string, localRef, trackingRef plumbing.ReferenceName) error { ctx, cancel := context.WithTimeout(ctx, checkpointPushBudget) defer cancel() displayTarget := displayPushTarget(target) + refDisplay := refDisplayName(localRef) + pushRefSpec := string(localRef) + ":" + string(localRef) - fmt.Fprintf(os.Stderr, "[entire] Pushing %s to %s...", branchName, displayTarget) + fmt.Fprintf(os.Stderr, "[entire] Pushing %s to %s...", refDisplay, displayTarget) stop := startProgressDots(os.Stderr) // Try pushing first - result, err := tryPushSessionsCommon(ctx, target, branchName) + result, err := tryPushSessionsCommon(ctx, target, pushRefSpec) if err == nil { finishPush(ctx, stop, result, target) return nil @@ -101,35 +110,35 @@ func doPushBranch(ctx context.Context, target, branchName string) error { // Protected refs cannot be fixed by syncing and retrying. var protectedErr *protectedRefError if errors.As(err, &protectedErr) { - printProtectedRefBlock(os.Stderr, branchName, target) + printProtectedRefBlock(os.Stderr, refDisplay, target) return nil } // Push failed - likely non-fast-forward. Try to fetch and rebase. // Spanned (with the network fetch as a child) so the trace distinguishes // "the raw push is slow" from "we keep hitting contention and re-syncing". - fmt.Fprintf(os.Stderr, "[entire] Syncing %s with remote...", branchName) + fmt.Fprintf(os.Stderr, "[entire] Syncing %s with remote...", refDisplay) stop = startProgressDots(os.Stderr) frCtx, fetchRebaseSpan := perf.Start(ctx, "fetch_and_rebase") - syncErr := fetchAndRebaseSessionsCommon(frCtx, target, branchName) + syncErr := fetchAndRebaseSessionsCommon(frCtx, target, localRef, trackingRef) fetchRebaseSpan.RecordError(syncErr) fetchRebaseSpan.End() if syncErr != nil { stop("") - fmt.Fprintf(os.Stderr, "[entire] Warning: couldn't sync %s: %v\n", branchName, syncErr) + fmt.Fprintf(os.Stderr, "[entire] Warning: couldn't sync %s: %v\n", refDisplay, syncErr) printCheckpointRemoteHint(target) return nil // Don't fail the main push } stop(" done") // Try pushing again after rebase - fmt.Fprintf(os.Stderr, "[entire] Pushing %s to %s...", branchName, displayTarget) + fmt.Fprintf(os.Stderr, "[entire] Pushing %s to %s...", refDisplay, displayTarget) stop = startProgressDots(os.Stderr) - if result, err := tryPushSessionsCommon(ctx, target, branchName); err != nil { + if result, err := tryPushSessionsCommon(ctx, target, pushRefSpec); err != nil { stop("") - fmt.Fprintf(os.Stderr, "[entire] Warning: failed to push %s after sync: %v\n", branchName, err) + fmt.Fprintf(os.Stderr, "[entire] Warning: failed to push %s after sync: %v\n", refDisplay, err) printCheckpointRemoteHint(target) } else { finishPush(ctx, stop, result, target) @@ -222,16 +231,19 @@ func finishPush(ctx context.Context, stop func(string), result pushResult, targe } } -// tryPushSessionsCommon attempts to push the sessions branch. No timeout of its -// own — runs under doPushBranch's shared budget. -func tryPushSessionsCommon(ctx context.Context, remoteName, branchName string) (pushResult, error) { +// tryPushSessionsCommon attempts to push an explicit refspec. +// pushRefSpec is either a bare branch name (legacy, expanded by git to +// refs/heads/:refs/heads/) or a full ":" +// refspec for custom refs outside refs/heads/. No timeout of its own — +// runs under doPushRef's shared budget. +func tryPushSessionsCommon(ctx context.Context, remoteName, pushRefSpec string) (pushResult, error) { // Span the actual `git push` subprocess: on a slow remote (e.g. a custom // git transport) this is typically where pre-push time is spent. Called once // per push attempt, so a retry after fetch+rebase shows up as a second // git_push step (git_push~1) in the trace. A rejected first push records an // error flag, which signals the recovery path was taken. _, pushSpan := perf.Start(ctx, "git_push") - result, err := remote.Push(ctx, remoteName, branchName) + result, err := remote.Push(ctx, remoteName, pushRefSpec) pushSpan.RecordError(err) pushSpan.End() @@ -321,30 +333,32 @@ func printProtectedRefBlock(w io.Writer, ref, target string) { fmt.Fprintln(w, banner) } -// fetchAndRebaseSessionsCommon fetches remote sessions and rebases local commits -// on top of the remote tip. Since checkpoint shards use unique paths, rebases -// always apply cleanly. -// The target can be a remote name or a URL. -func fetchAndRebaseSessionsCommon(ctx context.Context, target, branchName string) error { - // No timeout: runs under doPushBranch's shared budget. +// fetchAndRebaseSessionsCommon fetches the remote counterpart of localRef +// and rebases local-only commits onto the remote tip. Since checkpoint +// shards use unique paths, rebases always apply cleanly. +// +// localRef is the full local ref name. trackingRef is the local +// remote-tracking ref to update on fetch. The target can be a remote name +// or a URL (URLs use a temp fetched ref to avoid clobbering tracking). +// No timeout of its own — runs under doPushRef's shared budget. +func fetchAndRebaseSessionsCommon(ctx context.Context, target string, localRef, trackingRef plumbing.ReferenceName) error { fetchTarget, err := remote.ResolveFetchTarget(ctx, target) if err != nil { return fmt.Errorf("resolve fetch target: %w", err) } // Determine fetch refspec. When the resolved fetch target is a URL, use a - // temp ref; when it's still a remote name, use the standard remote-tracking - // ref. + // temp ref; when it's still a remote name, use the configured tracking ref. var fetchedRefName plumbing.ReferenceName var refSpec string usedTempRef := remote.IsURL(fetchTarget) if usedTempRef { - tmpRef := "refs/entire-fetch-tmp/" + branchName - refSpec = fmt.Sprintf("+refs/heads/%s:%s", branchName, tmpRef) + tmpRef := "refs/entire-fetch-tmp/" + refDisplayName(localRef) + refSpec = fmt.Sprintf("+%s:%s", string(localRef), tmpRef) fetchedRefName = plumbing.ReferenceName(tmpRef) } else { - refSpec = fmt.Sprintf("+refs/heads/%s:refs/remotes/%s/%s", branchName, target, branchName) - fetchedRefName = plumbing.NewRemoteReferenceName(target, branchName) + refSpec = fmt.Sprintf("+%s:%s", string(localRef), string(trackingRef)) + fetchedRefName = trackingRef } // Use git CLI for fetch (go-git's fetch can be tricky with auth). @@ -384,20 +398,20 @@ func fetchAndRebaseSessionsCommon(ctx context.Context, target, branchName string return fmt.Errorf("metadata reconciliation failed: %w", reconcileErr) } - // Get local branch (re-read after potential reconciliation update) - localRef, err := repo.Reference(plumbing.NewBranchReferenceName(branchName), true) + // Get local ref (re-read after potential reconciliation update) + local, err := repo.Reference(localRef, true) if err != nil { return fmt.Errorf("failed to get local ref: %w", err) } - // Get fetched ref (remote-tracking or temp ref, updated by the fetch above) + // Get fetched ref (tracking or temp ref, updated by the fetch above) remoteRef, err := repo.Reference(fetchedRefName, true) if err != nil { return fmt.Errorf("failed to get remote ref: %w", err) } // If local is already at or behind remote, fast-forward - if localRef.Hash() == remoteRef.Hash() { + if local.Hash() == remoteRef.Hash() { return nil } @@ -406,16 +420,16 @@ func fetchAndRebaseSessionsCommon(ctx context.Context, target, branchName string if err != nil { return fmt.Errorf("failed to get repo path: %w", err) } - mergeBase, err := getMergeBase(ctx, repoPath, localRef.Hash().String(), remoteRef.Hash().String()) + mergeBase, err := getMergeBase(ctx, repoPath, local.Hash().String(), remoteRef.Hash().String()) if err != nil { return fmt.Errorf("failed to find merge base: %w", err) } // If local is ancestor of remote (merge base == local), fast-forward to remote - if mergeBase == localRef.Hash() { - ref := plumbing.NewHashReference(plumbing.NewBranchReferenceName(branchName), remoteRef.Hash()) + if mergeBase == local.Hash() { + ref := plumbing.NewHashReference(localRef, remoteRef.Hash()) if err := repo.Storer.SetReference(ref); err != nil { - return fmt.Errorf("failed to fast-forward branch ref: %w", err) + return fmt.Errorf("failed to fast-forward local ref: %w", err) } if usedTempRef { _ = repo.Storer.RemoveReference(fetchedRefName) //nolint:errcheck // cleanup is best-effort @@ -425,18 +439,18 @@ func fetchAndRebaseSessionsCommon(ctx context.Context, target, branchName string // Collect commits reachable from local but not from remote and cherry-pick // them onto the remote tip. This preserves local-only commits even when the - // local metadata branch already contains old merge commits, while avoiding + // local metadata ref already contains old merge commits, while avoiding // replaying shared ancestors older than the true merge-base. - localCommits, err := collectCommitsSince(ctx, repo, repoPath, localRef.Hash(), remoteRef.Hash()) + localCommits, err := collectCommitsSince(ctx, repo, repoPath, local.Hash(), remoteRef.Hash()) if err != nil { return fmt.Errorf("failed to collect local commits: %w", err) } if len(localCommits) == 0 { // No local-only commits — just point to remote - ref := plumbing.NewHashReference(plumbing.NewBranchReferenceName(branchName), remoteRef.Hash()) + ref := plumbing.NewHashReference(localRef, remoteRef.Hash()) if err := repo.Storer.SetReference(ref); err != nil { - return fmt.Errorf("failed to update branch ref: %w", err) + return fmt.Errorf("failed to update local ref: %w", err) } if usedTempRef { _ = repo.Storer.RemoveReference(fetchedRefName) //nolint:errcheck // cleanup is best-effort @@ -454,10 +468,10 @@ func fetchAndRebaseSessionsCommon(ctx context.Context, target, branchName string return fmt.Errorf("failed to rebase local commits onto remote: %w", err) } - // Update branch ref - newRef := plumbing.NewHashReference(plumbing.NewBranchReferenceName(branchName), newTip) + // Update local ref + newRef := plumbing.NewHashReference(localRef, newTip) if err := repo.Storer.SetReference(newRef); err != nil { - return fmt.Errorf("failed to update branch ref: %w", err) + return fmt.Errorf("failed to update local ref: %w", err) } // Clean up temp ref if we used one (best-effort, not critical if it fails) diff --git a/cmd/entire/cli/strategy/push_common_budget_unix_test.go b/cmd/entire/cli/strategy/push_common_budget_unix_test.go index 238b44e03b..b28e29412b 100644 --- a/cmd/entire/cli/strategy/push_common_budget_unix_test.go +++ b/cmd/entire/cli/strategy/push_common_budget_unix_test.go @@ -9,7 +9,7 @@ import ( "testing" "time" - "github.com/entireio/cli/cmd/entire/cli/paths" + "github.com/entireio/cli/cmd/entire/cli/checkpoint" "github.com/stretchr/testify/require" ) @@ -18,7 +18,7 @@ import ( // budget (~2x). A hanging GIT_SSH_COMMAND blocks until the shared budget cuts it off. // // Not parallel: uses t.Setenv and overrides checkpointPushBudget. -func TestDoPushBranch_SharedBudget_BoundsTotalWallClock(t *testing.T) { +func TestDoPushRef_SharedBudget_BoundsTotalWallClock(t *testing.T) { const budget = 2 * time.Second restoreBudget := checkpointPushBudget checkpointPushBudget = budget @@ -40,15 +40,16 @@ func TestDoPushBranch_SharedBudget_BoundsTotalWallClock(t *testing.T) { // ssh:// so git invokes GIT_SSH_COMMAND for the transport. const target = "ssh://git@localhost/checkpoints.git" + ctx := context.Background() start := time.Now() - err := doPushBranch(context.Background(), target, paths.MetadataBranchName) + err := doPushRef(ctx, target, checkpoint.MetadataRef(ctx), checkpoint.MetadataTrackingRef(ctx)) elapsed := time.Since(start) - require.NoError(t, err, "doPushBranch degrades gracefully on a stuck transport") + require.NoError(t, err, "doPushRef degrades gracefully on a stuck transport") // Upper bound: one shared budget; per-attempt regression would land at ~2x. require.Less(t, elapsed, 5*time.Second, - "doPushBranch should return at ~budget, not stack multiple full timeouts; took %s", elapsed) + "doPushRef should return at ~budget, not stack multiple full timeouts; took %s", elapsed) // Lower bound: confirm the push hung and was cut off by the budget, not failing // instantly (which would make the upper bound meaningless). require.GreaterOrEqual(t, elapsed, budget/2, diff --git a/cmd/entire/cli/strategy/push_common_test.go b/cmd/entire/cli/strategy/push_common_test.go index f962768cc1..62c7ed9319 100644 --- a/cmd/entire/cli/strategy/push_common_test.go +++ b/cmd/entire/cli/strategy/push_common_test.go @@ -51,7 +51,7 @@ func TestHasUnpushedSessionsCommon(t *testing.T) { t.Run("no remote tracking ref exists", func(t *testing.T) { t.Parallel() repo, headHash := setupRepo(t) - assert.True(t, hasUnpushedSessionsCommon(repo, "origin", headHash, branchName)) + assert.True(t, hasUnpushedSessionsCommon(repo, headHash, plumbing.NewRemoteReferenceName("origin", branchName))) }) t.Run("local and remote same hash", func(t *testing.T) { @@ -64,7 +64,7 @@ func TestHasUnpushedSessionsCommon(t *testing.T) { ) require.NoError(t, repo.Storer.SetReference(remoteRef)) - assert.False(t, hasUnpushedSessionsCommon(repo, "origin", headHash, branchName)) + assert.False(t, hasUnpushedSessionsCommon(repo, headHash, plumbing.NewRemoteReferenceName("origin", branchName))) }) t.Run("local differs from remote", func(t *testing.T) { @@ -72,7 +72,7 @@ func TestHasUnpushedSessionsCommon(t *testing.T) { repo, _ := setupRepo(t) differentHash := plumbing.NewHash("aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa") - assert.True(t, hasUnpushedSessionsCommon(repo, "origin", differentHash, branchName)) + assert.True(t, hasUnpushedSessionsCommon(repo, differentHash, plumbing.NewRemoteReferenceName("origin", branchName))) }) } @@ -117,7 +117,7 @@ func TestDoPushBranch_UnreachableTarget_ReturnsNil(t *testing.T) { // 2. Try to fetch+merge (fails — can't fetch from non-existent path) // 3. Log warning and return nil (graceful degradation) nonExistentPath := filepath.Join(t.TempDir(), "does-not-exist") - err := doPushBranch(ctx, nonExistentPath, paths.MetadataBranchName) + err := doPushRef(ctx, nonExistentPath, plumbing.NewBranchReferenceName(paths.MetadataBranchName), plumbing.NewRemoteReferenceName("origin", paths.MetadataBranchName)) assert.NoError(t, err, "doPushBranch should return nil when target is unreachable (graceful degradation)") } @@ -140,7 +140,7 @@ func TestPushBranchIfNeeded_UnreachableTarget_ReturnsNil(t *testing.T) { // which finds no remote tracking ref -> returns true (has unpushed) // 4. Call doPushBranch which fails gracefully nonExistentPath := filepath.Join(t.TempDir(), "does-not-exist") - err := pushBranchIfNeeded(ctx, nonExistentPath, paths.MetadataBranchName) + err := pushRefIfNeeded(ctx, nonExistentPath, plumbing.NewBranchReferenceName(paths.MetadataBranchName), plumbing.NewRemoteReferenceName("origin", paths.MetadataBranchName)) assert.NoError(t, err, "pushBranchIfNeeded should return nil when target is unreachable") } @@ -198,7 +198,7 @@ func TestPushBranchIfNeeded_LocalBareRepo_PushesSuccessfully(t *testing.T) { t.Chdir(tmpDir) // Push using pushBranchIfNeeded with the bare repo path as target. - err := pushBranchIfNeeded(ctx, bareDir, paths.MetadataBranchName) + err := pushRefIfNeeded(ctx, bareDir, plumbing.NewBranchReferenceName(paths.MetadataBranchName), plumbing.NewRemoteReferenceName("origin", paths.MetadataBranchName)) require.NoError(t, err, "pushBranchIfNeeded should succeed with a local bare repo target") // Verify the branch arrived on the bare repo. @@ -300,7 +300,7 @@ func TestFetchAndRebase_DivergedBranches(t *testing.T) { // 5. Run fetchAndRebaseSessionsCommon on clone A (diverged: local has bb, remote has cc) t.Chdir(cloneA) - err := fetchAndRebaseSessionsCommon(ctx, "origin", branchName) + err := fetchAndRebaseSessionsCommon(ctx, "origin", plumbing.NewBranchReferenceName(branchName), plumbing.NewRemoteReferenceName("origin", branchName)) require.NoError(t, err) // 6. Verify results @@ -409,7 +409,7 @@ func TestFetchAndRebase_SharedCloneLocalCommitInAlternate(t *testing.T) { gitRun(remoteWorkDir, "push", "origin", branchName) t.Chdir(cloneDir) - err := fetchAndRebaseSessionsCommon(ctx, "origin", branchName) + err := fetchAndRebaseSessionsCommon(ctx, "origin", plumbing.NewBranchReferenceName(branchName), plumbing.NewRemoteReferenceName("origin", branchName)) require.NoError(t, err) treePaths := gitRun(cloneDir, "ls-tree", "-r", "--name-only", branchName) @@ -482,7 +482,7 @@ func TestFetchAndRebase_LocalBehind(t *testing.T) { // Clone is now behind — fetchAndRebase should fast-forward t.Chdir(cloneDir) - err := fetchAndRebaseSessionsCommon(ctx, "origin", branchName) + err := fetchAndRebaseSessionsCommon(ctx, "origin", plumbing.NewBranchReferenceName(branchName), plumbing.NewRemoteReferenceName("origin", branchName)) require.NoError(t, err) // Verify local now matches remote @@ -595,7 +595,7 @@ func TestFetchAndRebase_MergeBaseOnSecondParent_DoesNotReplayAncestors(t *testin // Rebase local metadata branch onto the updated remote tip. t.Chdir(cloneLocal) - err := fetchAndRebaseSessionsCommon(ctx, "origin", branchName) + err := fetchAndRebaseSessionsCommon(ctx, "origin", plumbing.NewBranchReferenceName(branchName), plumbing.NewRemoteReferenceName("origin", branchName)) require.NoError(t, err) repo, err := git.PlainOpen(cloneLocal) @@ -723,7 +723,7 @@ func TestFetchAndRebase_DoesNotResurrectRemoteOnlyCheckpointFromMerge(t *testing t.Chdir(cloneLocal) - err := fetchAndRebaseSessionsCommon(ctx, "origin", branchName) + err := fetchAndRebaseSessionsCommon(ctx, "origin", plumbing.NewBranchReferenceName(branchName), plumbing.NewRemoteReferenceName("origin", branchName)) require.NoError(t, err) repo, err := git.PlainOpen(cloneLocal) @@ -822,7 +822,7 @@ func TestFetchAndRebase_NonOriginRemote_ReconcilesFetchedRef(t *testing.T) { t.Chdir(cloneDir) - err = fetchAndRebaseSessionsCommon(ctx, "backup", branchName) + err = fetchAndRebaseSessionsCommon(ctx, "backup", plumbing.NewBranchReferenceName(branchName), plumbing.NewRemoteReferenceName("backup", branchName)) require.NoError(t, err) repo, err = git.PlainOpen(cloneDir) @@ -920,7 +920,7 @@ func TestFetchAndRebase_URLTarget_ReconcilesFetchedTempRef(t *testing.T) { t.Chdir(cloneDir) - err = fetchAndRebaseSessionsCommon(ctx, "file://"+bareDir, branchName) + err = fetchAndRebaseSessionsCommon(ctx, "file://"+bareDir, plumbing.NewBranchReferenceName(branchName), plumbing.NewRemoteReferenceName("origin", branchName)) require.NoError(t, err) repo, err = git.PlainOpen(cloneDir) @@ -1024,7 +1024,7 @@ func TestFetchAndRebase_FlaggedOriginTarget_UsesTempRef(t *testing.T) { t.Chdir(cloneDir) - err = fetchAndRebaseSessionsCommon(ctx, "origin", branchName) + err = fetchAndRebaseSessionsCommon(ctx, "origin", plumbing.NewBranchReferenceName(branchName), plumbing.NewRemoteReferenceName("origin", branchName)) require.NoError(t, err) repo, err = git.PlainOpen(cloneDir) @@ -1390,7 +1390,7 @@ func TestDoPushBranch_AlreadyUpToDate(t *testing.T) { t.Chdir(workDir) restore := captureStderr(t) - err := doPushBranch(context.Background(), bareDir, paths.MetadataBranchName) + err := doPushRef(context.Background(), bareDir, plumbing.NewBranchReferenceName(paths.MetadataBranchName), plumbing.NewRemoteReferenceName("origin", paths.MetadataBranchName)) output := restore() require.NoError(t, err) @@ -1416,7 +1416,7 @@ func TestDoPushBranch_NewContent_SaysDone(t *testing.T) { t.Chdir(workDir) restore := captureStderr(t) - err = doPushBranch(context.Background(), bareDir, paths.MetadataBranchName) + err = doPushRef(context.Background(), bareDir, plumbing.NewBranchReferenceName(paths.MetadataBranchName), plumbing.NewRemoteReferenceName("origin", paths.MetadataBranchName)) output := restore() require.NoError(t, err) diff --git a/cmd/entire/cli/strategy/session.go b/cmd/entire/cli/strategy/session.go index 984290457c..af7c19ca0f 100644 --- a/cmd/entire/cli/strategy/session.go +++ b/cmd/entire/cli/strategy/session.go @@ -103,7 +103,7 @@ func ListSessions(ctx context.Context) ([]Session, error) { }) } else { // Get description from the checkpoint tree - description := getDescriptionForCheckpoint(repo, cp.CheckpointID) + description := getDescriptionForCheckpoint(ctx, repo, cp.CheckpointID) sessionMap[sessionID] = &Session{ ID: sessionID, @@ -185,8 +185,8 @@ func GetSession(ctx context.Context, sessionID string) (*Session, error) { // getDescriptionForCheckpoint reads the description for a checkpoint from committed checkpoint storage. // It reads from the latest session subdirectory in the new storage format. -func getDescriptionForCheckpoint(repo *git.Repository, checkpointID id.CheckpointID) string { - tree, err := GetMetadataBranchTree(repo) +func getDescriptionForCheckpoint(ctx context.Context, repo *git.Repository, checkpointID id.CheckpointID) string { + tree, err := GetMetadataBranchTree(ctx, repo) if err == nil { description, readErr := readDescriptionForCheckpointFromTree(tree, checkpointID) if readErr == nil { diff --git a/cmd/entire/cli/strategy/session_test.go b/cmd/entire/cli/strategy/session_test.go index 82a54f33a1..4151f1cef4 100644 --- a/cmd/entire/cli/strategy/session_test.go +++ b/cmd/entire/cli/strategy/session_test.go @@ -309,7 +309,7 @@ func TestGetDescriptionForCheckpointFallsForwardToV2WhenV1MissesCheckpoint(t *te Prompts: []string{expectedDesc}, }) - if got := getDescriptionForCheckpoint(repo, targetCheckpointID); got != expectedDesc { + if got := getDescriptionForCheckpoint(context.Background(), repo, targetCheckpointID); got != expectedDesc { t.Errorf("getDescriptionForCheckpoint() = %q, want %q", got, expectedDesc) } }