diff --git a/cmd/entire/cli/doctor.go b/cmd/entire/cli/doctor.go index 2c2a988e5..2d6a9be13 100644 --- a/cmd/entire/cli/doctor.go +++ b/cmd/entire/cli/doctor.go @@ -9,6 +9,7 @@ import ( "github.com/charmbracelet/huh" "github.com/entireio/cli/cmd/entire/cli/checkpoint" + "github.com/entireio/cli/cmd/entire/cli/paths" "github.com/entireio/cli/cmd/entire/cli/session" "github.com/entireio/cli/cmd/entire/cli/strategy" @@ -319,7 +320,8 @@ func checkDisconnectedMetadata(cmd *cobra.Command, force bool) error { } ctx := cmd.Context() - disconnected, err := strategy.IsMetadataDisconnected(ctx, repo) + originRef := plumbing.NewRemoteReferenceName("origin", paths.MetadataBranchName) + disconnected, err := strategy.IsMetadataDisconnected(ctx, repo, originRef) if err != nil { return fmt.Errorf("could not check metadata branch state: %w", err) } @@ -357,7 +359,7 @@ func checkDisconnectedMetadata(cmd *cobra.Command, force bool) error { } } - if fixErr := strategy.ReconcileDisconnectedMetadataBranch(ctx, repo, cmd.ErrOrStderr()); fixErr != nil { + if fixErr := strategy.ReconcileDisconnectedMetadataBranch(ctx, repo, originRef, cmd.ErrOrStderr()); fixErr != nil { return fmt.Errorf("failed to reconcile metadata branches: %w", fixErr) } diff --git a/cmd/entire/cli/fetch.go b/cmd/entire/cli/fetch.go new file mode 100644 index 000000000..a0a8935d9 --- /dev/null +++ b/cmd/entire/cli/fetch.go @@ -0,0 +1,211 @@ +package cli + +import ( + "context" + "errors" + "fmt" + "io" + "log/slog" + "os" + "os/exec" + "strings" + + "github.com/entireio/cli/cmd/entire/cli/logging" + "github.com/entireio/cli/cmd/entire/cli/paths" + "github.com/entireio/cli/cmd/entire/cli/strategy" + "github.com/spf13/cobra" +) + +// Git remote protocol identifiers used for auth detection. +const ( + fetchProtocolSSH = "ssh" + fetchProtocolHTTPS = "https" +) + +func newFetchCmd() *cobra.Command { + cmd := &cobra.Command{ + Use: "fetch", + Hidden: true, + Short: "Fetch the remote checkpoint tip", + RunE: func(cmd *cobra.Command, _ []string) error { + ctx := cmd.Context() + + if _, err := paths.WorktreeRoot(ctx); err != nil { + return errors.New("not a git repository") + } + + logging.SetLogLevelGetter(GetLogLevel) + if err := logging.Init(ctx, ""); err == nil { + defer logging.Close() + } + + return runFetch(ctx, cmd.OutOrStdout(), cmd.ErrOrStderr()) + }, + } + + return cmd +} + +func runFetch(ctx context.Context, w, errW io.Writer) error { + // Resolve the checkpoint remote URL from settings. + checkpointURL, configured, err := strategy.ResolveCheckpointRemoteURL(ctx) + if err != nil { + return fmt.Errorf("failed to resolve checkpoint remote: %w", err) + } + + // Determine the fetch target: checkpoint URL if configured, otherwise "origin". + target := "origin" + if configured && checkpointURL != "" { + target = checkpointURL + } + + protocol := resolveProtocolForTarget(ctx, target) + authMethod := detectAuthMethod(ctx, protocol) + remoteInfo := resolveRemoteInfo(ctx, target) + + logging.Debug(ctx, "fetch: auth detection", + slog.String("auth", authMethod), + slog.String("domain", remoteInfo.domain), + slog.String("repo", remoteInfo.ownerRepo), + slog.String("protocol", protocol), + ) + + if isDebugMode() { + fmt.Fprintf(errW, "[entire] fetch: remote=%s/%s protocol=%s auth=%s\n", + remoteInfo.domain, remoteInfo.ownerRepo, protocol, authMethod) + } + + // Fetch the metadata branch. + branchName := paths.MetadataBranchName + logging.Debug(ctx, "fetch: fetching checkpoint tip", + slog.String("branch", branchName), + slog.String("domain", remoteInfo.domain), + slog.String("repo", remoteInfo.ownerRepo), + slog.String("protocol", protocol), + ) + + if err := strategy.FetchMetadataBranch(ctx, target); err != nil { + fmt.Fprintf(errW, "[entire] Failed to fetch %s: %v\n", branchName, err) + return NewSilentError(err) + } + + fmt.Fprintf(w, "[entire] Fetched %s\n", branchName) + return nil +} + +// detectAuthMethod determines how git will authenticate for the given target. +// Returns a human-readable description for debug logging. +func detectAuthMethod(ctx context.Context, protocol string) string { + // Check if ENTIRE_CHECKPOINT_TOKEN overrides auth. + if token := strings.TrimSpace(os.Getenv(strategy.CheckpointTokenEnvVar)); token != "" { + if protocol == fetchProtocolSSH { + return "ENTIRE_CHECKPOINT_TOKEN set (ignored: remote uses SSH)" + } + return "ENTIRE_CHECKPOINT_TOKEN" + } + + switch protocol { + case fetchProtocolSSH: + if hasSSHAgent() { + return "SSH agent" + } + return "SSH (no agent detected)" + case fetchProtocolHTTPS: + if helper := gitCredentialHelper(ctx); helper != "" { + return fmt.Sprintf("Git credential helper (%s)", helper) + } + return "no auth detected" + default: + return "no auth detected" + } +} + +// resolveProtocolForTarget returns "ssh", "https", or "" for the given target. +func resolveProtocolForTarget(ctx context.Context, target string) string { + if isFetchURL(target) { + if strings.HasPrefix(target, "https://") { + return fetchProtocolHTTPS + } + if strings.Contains(target, "@") { + return fetchProtocolSSH + } + return "" + } + // Remote name — resolve URL. + rawURL, err := getRemoteURLForFetch(ctx, target) + if err != nil { + return "" + } + if strings.HasPrefix(rawURL, "https://") || strings.HasPrefix(rawURL, "http://") { + return fetchProtocolHTTPS + } + // SCP-style SSH: git@host:path or contains "@" + if strings.Contains(rawURL, "@") { + return fetchProtocolSSH + } + return "" +} + +// hasSSHAgent checks whether an SSH agent is available via SSH_AUTH_SOCK. +func hasSSHAgent() bool { + return os.Getenv("SSH_AUTH_SOCK") != "" +} + +// gitCredentialHelper returns the configured git credential helper, if any. +func gitCredentialHelper(ctx context.Context) string { + cmd := exec.CommandContext(ctx, "git", "config", "credential.helper") + output, err := cmd.Output() + if err != nil { + return "" + } + return strings.TrimSpace(string(output)) +} + +// getRemoteURLForFetch resolves a remote name to its URL. +func getRemoteURLForFetch(ctx context.Context, remoteName string) (string, error) { + cmd := exec.CommandContext(ctx, "git", "remote", "get-url", remoteName) + output, err := cmd.Output() + if err != nil { + return "", fmt.Errorf("remote %q not found", remoteName) + } + return strings.TrimSpace(string(output)), nil +} + +// fetchRemoteInfo holds parsed remote components for logging. +type fetchRemoteInfo struct { + domain string // e.g., "github.com" + ownerRepo string // e.g., "org/my-repo" +} + +// resolveRemoteInfo parses the target into domain and owner/repo for display. +// If the target is a remote name, resolves it to a URL first. +func resolveRemoteInfo(ctx context.Context, target string) fetchRemoteInfo { + rawURL := target + if !isFetchURL(target) { + var err error + rawURL, err = getRemoteURLForFetch(ctx, target) + if err != nil { + return fetchRemoteInfo{domain: target} + } + } + + host, owner, repo, err := strategy.ParseRemoteURL(rawURL) + if err != nil { + return fetchRemoteInfo{domain: target} + } + return fetchRemoteInfo{domain: host, ownerRepo: owner + "/" + repo} +} + +// isDebugMode returns true if debug-level logging is enabled. +func isDebugMode() bool { + level := strings.ToUpper(os.Getenv(logging.LogLevelEnvVar)) + if level != "" { + return level == "DEBUG" + } + return strings.EqualFold(GetLogLevel(), "DEBUG") +} + +// isFetchURL returns true if the target looks like a URL rather than a git remote name. +func isFetchURL(target string) bool { + return strings.Contains(target, "://") || strings.Contains(target, "@") +} diff --git a/cmd/entire/cli/fetch_test.go b/cmd/entire/cli/fetch_test.go new file mode 100644 index 000000000..e8775c3e9 --- /dev/null +++ b/cmd/entire/cli/fetch_test.go @@ -0,0 +1,98 @@ +package cli + +import ( + "context" + "testing" + + "github.com/stretchr/testify/assert" +) + +func TestDetectAuthMethod_TokenOverride(t *testing.T) { + t.Setenv("ENTIRE_CHECKPOINT_TOKEN", "ghp_abc123") + method := detectAuthMethod(context.Background(), fetchProtocolHTTPS) + assert.Equal(t, "ENTIRE_CHECKPOINT_TOKEN", method) +} + +func TestDetectAuthMethod_TokenIgnoredForSSH(t *testing.T) { + t.Setenv("ENTIRE_CHECKPOINT_TOKEN", "ghp_abc123") + method := detectAuthMethod(context.Background(), fetchProtocolSSH) + assert.Equal(t, "ENTIRE_CHECKPOINT_TOKEN set (ignored: remote uses SSH)", method) +} + +func TestDetectAuthMethod_SSHWithAgent(t *testing.T) { + t.Setenv("ENTIRE_CHECKPOINT_TOKEN", "") + t.Setenv("SSH_AUTH_SOCK", "/tmp/ssh-agent.sock") + method := detectAuthMethod(context.Background(), fetchProtocolSSH) + assert.Equal(t, "SSH agent", method) +} + +func TestDetectAuthMethod_SSHNoAgent(t *testing.T) { + t.Setenv("ENTIRE_CHECKPOINT_TOKEN", "") + t.Setenv("SSH_AUTH_SOCK", "") + method := detectAuthMethod(context.Background(), fetchProtocolSSH) + assert.Equal(t, "SSH (no agent detected)", method) +} + +func TestResolveProtocolForTarget_HTTPS(t *testing.T) { + t.Parallel() + + protocol := resolveProtocolForTarget(context.Background(), "https://github.com/org/repo.git") + assert.Equal(t, fetchProtocolHTTPS, protocol) +} + +func TestResolveProtocolForTarget_SSH(t *testing.T) { + t.Parallel() + + protocol := resolveProtocolForTarget(context.Background(), "git@github.com:org/repo.git") + assert.Equal(t, fetchProtocolSSH, protocol) +} + +func TestResolveProtocolForTarget_UnknownRemoteName(t *testing.T) { + t.Parallel() + + // A non-existent remote name should return empty protocol. + protocol := resolveProtocolForTarget(context.Background(), "nonexistent-remote") + assert.Empty(t, protocol) +} + +func TestResolveRemoteInfo_HTTPS(t *testing.T) { + t.Parallel() + + info := resolveRemoteInfo(context.Background(), "https://github.com/myorg/my-repo.git") + assert.Equal(t, "github.com", info.domain) + assert.Equal(t, "myorg/my-repo", info.ownerRepo) +} + +func TestResolveRemoteInfo_SSH(t *testing.T) { + t.Parallel() + + info := resolveRemoteInfo(context.Background(), "git@github.com:myorg/my-repo.git") + assert.Equal(t, "github.com", info.domain) + assert.Equal(t, "myorg/my-repo", info.ownerRepo) +} + +func TestResolveRemoteInfo_UnresolvableRemoteName(t *testing.T) { + t.Parallel() + + info := resolveRemoteInfo(context.Background(), "nonexistent-remote") + assert.Equal(t, "nonexistent-remote", info.domain) + assert.Empty(t, info.ownerRepo) +} + +func TestIsDebugMode(t *testing.T) { + t.Setenv("ENTIRE_LOG_LEVEL", "DEBUG") + assert.True(t, isDebugMode()) +} + +func TestIsDebugMode_NotDebug(t *testing.T) { + t.Setenv("ENTIRE_LOG_LEVEL", "INFO") + assert.False(t, isDebugMode()) +} + +func TestIsFetchURL(t *testing.T) { + t.Parallel() + + assert.True(t, isFetchURL("https://github.com/org/repo.git")) + assert.True(t, isFetchURL("git@github.com:org/repo.git")) + assert.False(t, isFetchURL("origin")) +} diff --git a/cmd/entire/cli/git_operations.go b/cmd/entire/cli/git_operations.go index 3be6a85f7..3fd2d28f2 100644 --- a/cmd/entire/cli/git_operations.go +++ b/cmd/entire/cli/git_operations.go @@ -370,44 +370,14 @@ func FetchAndCheckoutRemoteBranch(ctx context.Context, branchName string) error return CheckoutBranch(ctx, branchName) } -// FetchMetadataBranch fetches the entire/checkpoints/v1 branch from origin and creates/updates the local branch. -// This is used when the metadata branch exists on remote but not locally. +// FetchMetadataBranch fetches the entire/checkpoints/v1 branch from origin and advances the +// local branch only on a fast-forward (or when the branch is missing). // Uses git CLI instead of go-git for fetch because go-git doesn't use credential helpers, // which breaks HTTPS URLs that require authentication. func FetchMetadataBranch(ctx context.Context) error { - branchName := paths.MetadataBranchName - - // Use git CLI for fetch (go-git's fetch can be tricky with auth) - ctx, cancel := context.WithTimeout(ctx, 2*time.Minute) - defer cancel() - - refSpec := fmt.Sprintf("+refs/heads/%s:refs/remotes/origin/%s", branchName, branchName) - - fetchCmd := strategy.CheckpointGitCommand(ctx, "origin", "fetch", "origin", refSpec) - if output, err := fetchCmd.CombinedOutput(); err != nil { - if ctx.Err() == context.DeadlineExceeded { - return errors.New("fetch timed out after 2 minutes") - } - return fmt.Errorf("failed to fetch %s from origin: %s: %w", branchName, strings.TrimSpace(string(output)), err) + if err := strategy.FetchMetadataBranch(ctx, "origin"); err != nil { + return fmt.Errorf("fetch metadata branch from origin: %w", err) } - - repo, err := openRepository(ctx) - if err != nil { - return fmt.Errorf("failed to open repository: %w", err) - } - - // Get the remote branch reference - remoteRef, err := repo.Reference(plumbing.NewRemoteReferenceName("origin", branchName), true) - if err != nil { - return fmt.Errorf("branch '%s' not found on origin: %w", branchName, err) - } - - // Create or update local branch pointing to the same commit - localRef := plumbing.NewHashReference(plumbing.NewBranchReferenceName(branchName), remoteRef.Hash()) - if err := repo.Storer.SetReference(localRef); err != nil { - return fmt.Errorf("failed to create local %s branch: %w", branchName, err) - } - return nil } @@ -444,10 +414,8 @@ func FetchMetadataTreeOnly(ctx context.Context) error { return fmt.Errorf("branch '%s' not found on origin: %w", branchName, err) } - // Create or update local branch pointing to the same commit - localRef := plumbing.NewHashReference(plumbing.NewBranchReferenceName(branchName), remoteRef.Hash()) - if err := repo.Storer.SetReference(localRef); err != nil { - return fmt.Errorf("failed to create local %s branch: %w", branchName, err) + if err := strategy.MaybeFastForwardLocalMetadataBranch(ctx, repo, remoteRef.Hash()); err != nil { + return fmt.Errorf("failed to update local %s branch: %w", branchName, err) } return nil diff --git a/cmd/entire/cli/resume.go b/cmd/entire/cli/resume.go index fcc6781ce..e429f7910 100644 --- a/cmd/entire/cli/resume.go +++ b/cmd/entire/cli/resume.go @@ -438,10 +438,15 @@ func getMetadataTree(ctx context.Context) (*object.Tree, *git.Repository, error) // Try checkpoint_remote if configured. Checkpoints may live in a separate repo, // so this avoids a potentially unnecessary full origin fetch. if fetchErr := FetchMetadataFromCheckpointRemote(ctx); fetchErr == nil { + checkpointURL, hasURL, urlErr := strategy.ResolveCheckpointRemoteURL(ctx) freshRepo, freshErr := openRepository(ctx) if freshErr == nil { logRefHash(freshRepo, "checkpoint-remote") metadataTree, treeErr := strategy.GetMetadataBranchTree(freshRepo) + if treeErr != nil && urlErr == nil && hasURL && checkpointURL != "" { + fetchRef := strategy.MetadataBranchFetchRef(checkpointURL) + metadataTree, treeErr = strategy.GetMetadataBranchTreeAtRef(freshRepo, fetchRef) + } if treeErr == nil { logging.Debug(logCtx, "metadata tree obtained via checkpoint remote fetch", slog.String("tree_hash", metadataTree.Hash.String()), @@ -464,6 +469,10 @@ func getMetadataTree(ctx context.Context) (*object.Tree, *git.Repository, error) if repoErr == nil { logRefHash(freshRepo, "full-fetch") metadataTree, treeErr := strategy.GetMetadataBranchTree(freshRepo) + if treeErr != nil { + fetchRef := strategy.MetadataBranchFetchRef("origin") + metadataTree, treeErr = strategy.GetMetadataBranchTreeAtRef(freshRepo, fetchRef) + } if treeErr == nil { logging.Debug(logCtx, "metadata tree obtained via full fetch", slog.String("tree_hash", metadataTree.Hash.String()), @@ -732,17 +741,46 @@ func checkRemoteMetadata(ctx context.Context, w, errW io.Writer, checkpointID id logging.Debug(logCtx, "checkpoint remote: open repository failed after fetch", slog.String("error", freshErr.Error()), ) - } else if metadataTree, treeErr := strategy.GetMetadataBranchTree(freshRepo); treeErr != nil { - logging.Debug(logCtx, "checkpoint remote: fetch succeeded but tree read failed", - slog.String("error", treeErr.Error()), - ) - } else if metadata, err := tryReadCheckpointFromTree(ctx, metadataTree, freshRepo, checkpointID); err != nil { - logging.Debug(logCtx, "checkpoint remote: tree read succeeded but checkpoint metadata read failed", - slog.String("checkpoint_id", checkpointID.String()), - slog.String("error", err.Error()), - ) } else { - return resumeSession(ctx, w, errW, metadata, false) + fetchRef := strategy.MetadataBranchFetchRef(checkpointURL) + var metadataTrees []*object.Tree + seen := make(map[plumbing.Hash]struct{}) + addTree := func(tr *object.Tree) { + if _, ok := seen[tr.Hash]; ok { + return + } + seen[tr.Hash] = struct{}{} + metadataTrees = append(metadataTrees, tr) + } + if localTree, err := strategy.GetMetadataBranchTree(freshRepo); err == nil { + addTree(localTree) + } + if fetchedTree, err := strategy.GetMetadataBranchTreeAtRef(freshRepo, fetchRef); err == nil { + addTree(fetchedTree) + } + if len(metadataTrees) == 0 { + logging.Debug(logCtx, "checkpoint remote: fetch succeeded but tree read failed", + slog.String("error", "could not read local or fetched metadata ref"), + ) + } else { + var lastErr error + for _, metadataTree := range metadataTrees { + metadata, err := tryReadCheckpointFromTree(ctx, metadataTree, freshRepo, checkpointID) + if err != nil { + lastErr = err + continue + } + return resumeSession(ctx, w, errW, metadata, false) + } + msg := "checkpoint not found in fetched trees" + if lastErr != nil { + msg = lastErr.Error() + } + logging.Debug(logCtx, "checkpoint remote: checkpoint metadata read failed", + slog.String("checkpoint_id", checkpointID.String()), + slog.String("error", msg), + ) + } } } else { logging.Debug(logCtx, "checkpoint remote fetch failed", diff --git a/cmd/entire/cli/root.go b/cmd/entire/cli/root.go index abdda9958..da92bb220 100644 --- a/cmd/entire/cli/root.go +++ b/cmd/entire/cli/root.go @@ -100,6 +100,7 @@ func NewRootCmd() *cobra.Command { cmd.AddCommand(newSendAnalyticsCmd()) cmd.AddCommand(newAttachCmd()) cmd.AddCommand(newCurlBashPostInstallCmd()) + cmd.AddCommand(newFetchCmd()) cmd.SetVersionTemplate(versionString()) diff --git a/cmd/entire/cli/strategy/checkpoint_remote.go b/cmd/entire/cli/strategy/checkpoint_remote.go index 6202d4160..eeeb4456a 100644 --- a/cmd/entire/cli/strategy/checkpoint_remote.go +++ b/cmd/entire/cli/strategy/checkpoint_remote.go @@ -7,6 +7,7 @@ import ( "net/url" "os" "os/exec" + "path/filepath" "strings" "time" @@ -20,6 +21,9 @@ import ( // checkpointRemoteFetchTimeout is the timeout for fetching branches from the checkpoint URL. const checkpointRemoteFetchTimeout = 30 * time.Second +// checkpointMetadataBranchFetchTimeout is the timeout for fetching the metadata branch (full objects). +const checkpointMetadataBranchFetchTimeout = 2 * time.Minute + // Git remote protocol identifiers. const ( protocolSSH = "ssh" @@ -147,6 +151,16 @@ func ResolveRemoteRepo(ctx context.Context, remoteName string) (host, owner, rep return info.host, info.owner, info.repo, nil } +// ParseRemoteURL parses a git remote URL (SSH or HTTPS) into host, owner, and repo. +// For example, "git@github.com:org/my-repo.git" returns ("github.com", "org", "my-repo", nil). +func ParseRemoteURL(rawURL string) (host, owner, repo string, err error) { + info, err := parseGitRemoteURL(rawURL) + if err != nil { + return "", "", "", err + } + return info.host, info.owner, info.repo, nil +} + // gitRemoteInfo holds parsed components of a git remote URL. type gitRemoteInfo struct { protocol string // "ssh" or "https" @@ -168,7 +182,7 @@ func parseGitRemoteURL(rawURL string) (*gitRemoteInfo, error) { // Split on the first ":" parts := strings.SplitN(rawURL, ":", 2) if len(parts) != 2 { - return nil, fmt.Errorf("invalid SSH URL: %s", redactURL(rawURL)) + return nil, fmt.Errorf("invalid SSH URL: %s", RedactURL(rawURL)) } hostPart := parts[0] // e.g., "git@github.com" pathPart := parts[1] // e.g., "org/repo.git" @@ -189,12 +203,12 @@ func parseGitRemoteURL(rawURL string) (*gitRemoteInfo, error) { // URL format: https://github.com/org/repo.git or ssh://git@github.com/org/repo.git u, err := url.Parse(rawURL) if err != nil { - return nil, fmt.Errorf("invalid URL: %s", redactURL(rawURL)) + return nil, fmt.Errorf("invalid URL: %s", RedactURL(rawURL)) } protocol := u.Scheme if protocol == "" { - return nil, fmt.Errorf("no protocol in URL: %s", redactURL(rawURL)) + return nil, fmt.Errorf("no protocol in URL: %s", RedactURL(rawURL)) } host := u.Hostname() @@ -230,8 +244,18 @@ func deriveCheckpointURL(pushRemoteURL string, config *settings.CheckpointRemote } // deriveCheckpointURLFromInfo constructs a checkpoint URL from already-parsed remote info. +// When ENTIRE_CHECKPOINT_TOKEN is set and the origin uses SSH, the URL is forced to HTTPS +// so the token can be used for authentication. func deriveCheckpointURLFromInfo(info *gitRemoteInfo, config *settings.CheckpointRemoteConfig) (string, error) { - switch info.protocol { + protocol := info.protocol + + // If a checkpoint token is available and the origin is SSH, upgrade to HTTPS + // so the token can actually be used for authentication. + if protocol == protocolSSH && hasCheckpointToken() { + protocol = protocolHTTPS + } + + switch protocol { case protocolSSH: // SCP format: git@host:owner/repo.git return fmt.Sprintf("git@%s:%s.git", info.host, config.Repo), nil @@ -262,9 +286,9 @@ func getRemoteURL(ctx context.Context, remoteName string) (string, error) { return strings.TrimSpace(string(output)), nil } -// redactURL removes credentials from a URL for safe logging. +// RedactURL removes credentials from a URL for safe logging. // Handles both HTTPS URLs with embedded credentials and general URLs. -func redactURL(rawURL string) string { +func RedactURL(rawURL string) string { u, err := url.Parse(rawURL) if err != nil { // For non-URL formats (SSH SCP), just return the host portion @@ -317,18 +341,40 @@ func ResolveCheckpointRemoteURL(ctx context.Context) (string, bool, error) { return checkpointURL, true, nil } -// 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). -func FetchMetadataBranch(ctx context.Context, remoteURL string) error { +// metadataBranchFetchRefSpec returns the refspec and destination ref for FetchMetadataBranch. +// URL targets and absolute filesystem paths (e.g. tests) fetch into refs/entire-fetch-tmp/…; +// named remotes use refs/remotes//… so origin matches normal git layouts. +func metadataBranchFetchRefSpecAndDest(target string) (refSpec string, destRef plumbing.ReferenceName) { branchName := paths.MetadataBranchName + if isURL(target) || filepath.IsAbs(target) { + destRef = plumbing.ReferenceName("refs/entire-fetch-tmp/" + branchName) + refSpec = fmt.Sprintf("+refs/heads/%s:%s", branchName, destRef) + return refSpec, destRef + } + destRef = plumbing.NewRemoteReferenceName(target, branchName) + refSpec = fmt.Sprintf("+refs/heads/%s:%s", branchName, destRef.String()) + return refSpec, destRef +} - fetchCtx, cancel := context.WithTimeout(ctx, checkpointRemoteFetchTimeout) +// MetadataBranchFetchRef returns the reference updated by FetchMetadataBranch for target +// (remote name, repo URL, or absolute path to a repo). After a fetch, this ref points at +// the remote tip even when refs/heads/entire/checkpoints/v1 was not advanced +// (non-fast-forward). +func MetadataBranchFetchRef(target string) plumbing.ReferenceName { + _, dest := metadataBranchFetchRefSpecAndDest(target) + return dest +} + +// FetchMetadataBranch fetches the metadata branch from the checkpoint remote URL +// and advances the local branch only on a fast-forward (or when the branch is missing). +// Unlike fetchMetadataBranchIfMissing, this always fetches regardless of whether the branch +// exists locally. The fetch destination ref (MetadataBranchFetchRef) always receives the +// remote tip so callers can read new commits when the local branch could not be advanced. +func FetchMetadataBranch(ctx context.Context, remoteURL string) error { + fetchCtx, cancel := context.WithTimeout(ctx, checkpointMetadataBranchFetchTimeout) defer cancel() - tmpRef := "refs/entire-fetch-tmp/" + branchName - refSpec := fmt.Sprintf("+refs/heads/%s:%s", branchName, tmpRef) + refSpec, destRef := metadataBranchFetchRefSpecAndDest(remoteURL) fetchCmd := CheckpointGitCommand(fetchCtx, remoteURL, "fetch", "--no-tags", remoteURL, refSpec) // Merge GIT_TERMINAL_PROMPT=0 into whatever env CheckpointGitCommand set. // If the token was injected, cmd.Env is already populated; otherwise use os.Environ(). @@ -339,7 +385,7 @@ func FetchMetadataBranch(ctx context.Context, remoteURL string) error { if output, err := fetchCmd.CombinedOutput(); err != nil { // Include redacted output for diagnostics without leaking credentials. // Git stderr may echo the URL with embedded credentials, so replace it. - redactedURL := redactURL(remoteURL) + redactedURL := RedactURL(remoteURL) msg := strings.TrimSpace(strings.ReplaceAll(string(output), remoteURL, redactedURL)) if msg != "" { return fmt.Errorf("fetch from %s failed: %s: %w", redactedURL, msg, err) @@ -352,19 +398,15 @@ func FetchMetadataBranch(ctx context.Context, remoteURL string) error { return fmt.Errorf("failed to open repository: %w", err) } - fetchedRef, err := repo.Reference(plumbing.ReferenceName(tmpRef), true) + fetchedRef, err := repo.Reference(destRef, true) if err != nil { return fmt.Errorf("branch not found after fetch: %w", err) } - branchRef := plumbing.NewBranchReferenceName(branchName) - newRef := plumbing.NewHashReference(branchRef, fetchedRef.Hash()) - if err := repo.Storer.SetReference(newRef); err != nil { - return fmt.Errorf("failed to create local branch from fetched ref: %w", err) + if err := MaybeFastForwardLocalMetadataBranch(ctx, repo, fetchedRef.Hash()); err != nil { + return fmt.Errorf("failed to update local metadata branch: %w", err) } - _ = repo.Storer.RemoveReference(plumbing.ReferenceName(tmpRef)) //nolint:errcheck // cleanup is best-effort - return nil } @@ -381,7 +423,7 @@ func FetchV2MainFromURL(ctx context.Context, remoteURL string) error { } fetchCmd.Env = append(fetchCmd.Env, "GIT_TERMINAL_PROMPT=0") if output, err := fetchCmd.CombinedOutput(); err != nil { - redactedURL := redactURL(remoteURL) + redactedURL := RedactURL(remoteURL) msg := strings.TrimSpace(strings.ReplaceAll(string(output), remoteURL, redactedURL)) if msg != "" { return fmt.Errorf("fetch v2 /main from %s failed: %s: %w", redactedURL, msg, err) diff --git a/cmd/entire/cli/strategy/checkpoint_remote_test.go b/cmd/entire/cli/strategy/checkpoint_remote_test.go index db4284047..dec151844 100644 --- a/cmd/entire/cli/strategy/checkpoint_remote_test.go +++ b/cmd/entire/cli/strategy/checkpoint_remote_test.go @@ -13,6 +13,8 @@ import ( "github.com/entireio/cli/cmd/entire/cli/settings" "github.com/entireio/cli/cmd/entire/cli/testutil" + "github.com/go-git/go-git/v6" + "github.com/go-git/go-git/v6/plumbing" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) @@ -137,6 +139,39 @@ func TestDeriveCheckpointURL(t *testing.T) { } } +func TestDeriveCheckpointURL_SSHUpgradedToHTTPS_WhenTokenSet(t *testing.T) { + t.Setenv(CheckpointTokenEnvVar, "ghp_test123") + + config := &settings.CheckpointRemoteConfig{Provider: "github", Repo: "org/checkpoints"} + + // SSH origin should produce HTTPS checkpoint URL when token is set. + got, err := deriveCheckpointURL("git@github.com:org/main-repo.git", config) + require.NoError(t, err) + assert.Equal(t, "https://github.com/org/checkpoints.git", got) +} + +func TestDeriveCheckpointURL_HTTPSUnchanged_WhenTokenSet(t *testing.T) { + t.Setenv(CheckpointTokenEnvVar, "ghp_test123") + + config := &settings.CheckpointRemoteConfig{Provider: "github", Repo: "org/checkpoints"} + + // HTTPS origin stays HTTPS. + got, err := deriveCheckpointURL("https://github.com/org/main-repo.git", config) + require.NoError(t, err) + assert.Equal(t, "https://github.com/org/checkpoints.git", got) +} + +func TestDeriveCheckpointURL_SSHPreserved_WhenNoToken(t *testing.T) { + t.Setenv(CheckpointTokenEnvVar, "") + + config := &settings.CheckpointRemoteConfig{Provider: "github", Repo: "org/checkpoints"} + + // SSH origin stays SSH when no token is set. + got, err := deriveCheckpointURL("git@github.com:org/main-repo.git", config) + require.NoError(t, err) + assert.Equal(t, "git@github.com:org/checkpoints.git", got) +} + func TestExtractOwnerFromRemoteURL(t *testing.T) { t.Parallel() @@ -186,7 +221,7 @@ func TestRedactURL(t *testing.T) { for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { t.Parallel() - assert.Equal(t, tt.want, redactURL(tt.url)) + assert.Equal(t, tt.want, RedactURL(tt.url)) }) } } @@ -711,8 +746,11 @@ func TestFetchMetadataBranch_FetchesAndCreatesLocalBranch(t *testing.T) { // Branch should now exist assert.True(t, testutil.BranchExists(t, localDir, "entire/checkpoints/v1")) - // Temp ref should be cleaned up - assert.False(t, testutil.BranchExists(t, localDir, "refs/entire-fetch-tmp/entire/checkpoints/v1")) + // Fetch ref is retained so the remote tip stays addressable without clobbering the local branch. + repo, err := git.PlainOpen(localDir) + require.NoError(t, err) + _, err = repo.Reference(MetadataBranchFetchRef(remoteDir), true) + require.NoError(t, err) } // Not parallel: uses t.Chdir() @@ -804,6 +842,103 @@ func TestFetchMetadataBranch_UpdatesExistingLocalBranch(t *testing.T) { assert.NotEqual(t, hash1, hash2, "FetchMetadataBranch should update existing local branch to new remote tip") } +// Not parallel: uses t.Chdir() +func TestFetchMetadataBranch_LocalAheadDoesNotResetBranch(t *testing.T) { + ctx := context.Background() + + remoteDir := t.TempDir() + testutil.InitRepo(t, remoteDir) + testutil.WriteFile(t, remoteDir, "f.txt", "init") + testutil.GitAdd(t, remoteDir, "f.txt") + testutil.GitCommit(t, remoteDir, "init") + + branchCmd := exec.CommandContext(ctx, "git", "rev-parse", "--abbrev-ref", "HEAD") + branchCmd.Dir = remoteDir + branchCmd.Env = testutil.GitIsolatedEnv() + branchOut, err := branchCmd.Output() + require.NoError(t, err) + defaultBranch := strings.TrimSpace(string(branchOut)) + + cmd := exec.CommandContext(ctx, "git", "checkout", "--orphan", "entire/checkpoints/v1") + cmd.Dir = remoteDir + cmd.Env = testutil.GitIsolatedEnv() + require.NoError(t, cmd.Run()) + + cmd = exec.CommandContext(ctx, "git", "rm", "-rf", ".") + cmd.Dir = remoteDir + cmd.Env = testutil.GitIsolatedEnv() + require.NoError(t, cmd.Run()) + + testutil.WriteFile(t, remoteDir, "metadata.json", `{"version": 1}`) + testutil.GitAdd(t, remoteDir, "metadata.json") + cmd = exec.CommandContext(ctx, "git", "-c", "commit.gpgsign=false", "commit", "-m", "v1 remote") + cmd.Dir = remoteDir + cmd.Env = testutil.GitIsolatedEnv() + require.NoError(t, cmd.Run()) + + cmd = exec.CommandContext(ctx, "git", "checkout", defaultBranch) + cmd.Dir = remoteDir + cmd.Env = testutil.GitIsolatedEnv() + require.NoError(t, cmd.Run()) + + localDir := t.TempDir() + testutil.InitRepo(t, localDir) + testutil.WriteFile(t, localDir, "f.txt", "init") + testutil.GitAdd(t, localDir, "f.txt") + testutil.GitCommit(t, localDir, "init") + t.Chdir(localDir) + + require.NoError(t, FetchMetadataBranch(ctx, remoteDir)) + + // Local-only commit on metadata branch (ahead of remote). + cmd = exec.CommandContext(ctx, "git", "checkout", paths.MetadataBranchName) + cmd.Dir = localDir + cmd.Env = testutil.GitIsolatedEnv() + require.NoError(t, cmd.Run()) + testutil.WriteFile(t, localDir, "local-only.txt", "x") + testutil.GitAdd(t, localDir, "local-only.txt") + cmd = exec.CommandContext(ctx, "git", "-c", "commit.gpgsign=false", "commit", "-m", "local only") + cmd.Dir = localDir + cmd.Env = testutil.GitIsolatedEnv() + require.NoError(t, cmd.Run()) + + hashLocalOnly := testutil.GetHeadHash(t, localDir) + + cmd = exec.CommandContext(ctx, "git", "checkout", defaultBranch) + cmd.Dir = localDir + cmd.Env = testutil.GitIsolatedEnv() + require.NoError(t, cmd.Run()) + + // Advance remote metadata branch. + cmd = exec.CommandContext(ctx, "git", "checkout", paths.MetadataBranchName) + cmd.Dir = remoteDir + cmd.Env = testutil.GitIsolatedEnv() + require.NoError(t, cmd.Run()) + testutil.WriteFile(t, remoteDir, "metadata.json", `{"version": 2}`) + testutil.GitAdd(t, remoteDir, "metadata.json") + cmd = exec.CommandContext(ctx, "git", "-c", "commit.gpgsign=false", "commit", "-m", "v2 remote") + cmd.Dir = remoteDir + cmd.Env = testutil.GitIsolatedEnv() + require.NoError(t, cmd.Run()) + cmd = exec.CommandContext(ctx, "git", "checkout", defaultBranch) + cmd.Dir = remoteDir + cmd.Env = testutil.GitIsolatedEnv() + require.NoError(t, cmd.Run()) + + require.NoError(t, FetchMetadataBranch(ctx, remoteDir)) + + repo, err := git.PlainOpen(localDir) + require.NoError(t, err) + localRef, err := repo.Reference(plumbing.NewBranchReferenceName(paths.MetadataBranchName), true) + require.NoError(t, err) + assert.Equal(t, plumbing.NewHash(hashLocalOnly), localRef.Hash(), + "local metadata branch should not move backward when ahead of remote") + + fetchRef, err := repo.Reference(MetadataBranchFetchRef(remoteDir), true) + require.NoError(t, err) + assert.NotEqual(t, localRef.Hash(), fetchRef.Hash(), "fetch ref should track remote tip") +} + // v2RefSeq is a counter to ensure each call to createV2MainRef produces a distinct commit. var v2RefSeq int diff --git a/cmd/entire/cli/strategy/checkpoint_token.go b/cmd/entire/cli/strategy/checkpoint_token.go index 3ce3b166c..e013b880d 100644 --- a/cmd/entire/cli/strategy/checkpoint_token.go +++ b/cmd/entire/cli/strategy/checkpoint_token.go @@ -66,6 +66,11 @@ func CheckpointGitCommand(ctx context.Context, target string, args ...string) *e } } +// hasCheckpointToken returns true if ENTIRE_CHECKPOINT_TOKEN is set to a non-empty value. +func hasCheckpointToken() bool { + return strings.TrimSpace(os.Getenv(CheckpointTokenEnvVar)) != "" +} + // appendCheckpointTokenEnv appends GIT_CONFIG_COUNT-based env vars to inject // an Authorization header into git HTTP requests. The token is sent as a Basic // credential with the format "x-access-token:" (base64-encoded), which diff --git a/cmd/entire/cli/strategy/common.go b/cmd/entire/cli/strategy/common.go index 72900240c..5418d218f 100644 --- a/cmd/entire/cli/strategy/common.go +++ b/cmd/entire/cli/strategy/common.go @@ -117,6 +117,34 @@ func IsAncestorOf(ctx context.Context, repo *git.Repository, commit, target plum return found } +// MaybeFastForwardLocalMetadataBranch updates refs/heads/entire/checkpoints/v1 to remoteTip +// when that branch is missing or remoteTip is an ancestor of (or equal to) the current tip +// (a fast-forward). If the current tip is not an ancestor of remoteTip, the branch is left +// unchanged so local-only metadata commits are not discarded. +func MaybeFastForwardLocalMetadataBranch(ctx context.Context, repo *git.Repository, remoteTip plumbing.Hash) error { + branchRef := plumbing.NewBranchReferenceName(paths.MetadataBranchName) + localRef, err := repo.Reference(branchRef, true) + if errors.Is(err, plumbing.ErrReferenceNotFound) { + if setErr := repo.Storer.SetReference(plumbing.NewHashReference(branchRef, remoteTip)); setErr != nil { + return fmt.Errorf("create local metadata branch: %w", setErr) + } + return nil + } + if err != nil { + return fmt.Errorf("failed to read local metadata branch: %w", err) + } + if localRef.Hash() == remoteTip { + return nil + } + if IsAncestorOf(ctx, repo, localRef.Hash(), remoteTip) { + if setErr := repo.Storer.SetReference(plumbing.NewHashReference(branchRef, remoteTip)); setErr != nil { + return fmt.Errorf("fast-forward local metadata branch: %w", setErr) + } + return nil + } + return nil +} + // ListCheckpoints returns all checkpoints from the entire/checkpoints/v1 branch. // Scans sharded paths: // directories containing metadata.json. func ListCheckpoints(ctx context.Context) ([]CheckpointInfo, error) { @@ -627,6 +655,25 @@ func GetMetadataBranchTree(repo *git.Repository) (*object.Tree, error) { return tree, nil } +// GetMetadataBranchTreeAtRef returns the tree for the commit at refName (a metadata branch ref). +func GetMetadataBranchTreeAtRef(repo *git.Repository, refName plumbing.ReferenceName) (*object.Tree, error) { + ref, err := repo.Reference(refName, true) + if err != nil { + return nil, fmt.Errorf("failed to get metadata ref %s: %w", refName, err) + } + + commit, err := repo.CommitObject(ref.Hash()) + if err != nil { + return nil, fmt.Errorf("failed to get metadata branch commit: %w", err) + } + + tree, err := commit.Tree() + if err != nil { + return nil, fmt.Errorf("failed to get metadata branch tree: %w", err) + } + return tree, nil +} + // ExtractFirstPrompt extracts and truncates the first meaningful prompt from prompt content. // Prompts are separated by "\n\n---\n\n". Skips empty prompts and separator-only content. // Returns empty string if no valid prompt is found. diff --git a/cmd/entire/cli/strategy/metadata_reconcile.go b/cmd/entire/cli/strategy/metadata_reconcile.go index 52b0f9fc4..1c95515ab 100644 --- a/cmd/entire/cli/strategy/metadata_reconcile.go +++ b/cmd/entire/cli/strategy/metadata_reconcile.go @@ -28,7 +28,10 @@ var disconnectedOnce sync.Once //nolint:gochecknoglobals // intentional per-proc // Returns (false, nil) if either branch is missing, they point to the same hash, // or they share a common ancestor (normal divergence handled by push merge). // Returns (true, nil) only when both exist and are truly disconnected. -func IsMetadataDisconnected(ctx context.Context, repo *git.Repository) (bool, error) { +// +// remoteRefName is the reference to compare against (e.g., refs/remotes/origin/ +// or refs/entire-fetch-tmp/ when fetching from a checkpoint URL). +func IsMetadataDisconnected(ctx context.Context, repo *git.Repository, remoteRefName plumbing.ReferenceName) (bool, error) { refName := plumbing.NewBranchReferenceName(paths.MetadataBranchName) localRef, err := repo.Reference(refName, true) if errors.Is(err, plumbing.ErrReferenceNotFound) { @@ -38,7 +41,6 @@ func IsMetadataDisconnected(ctx context.Context, repo *git.Repository) (bool, er return false, fmt.Errorf("failed to check local metadata branch: %w", err) } - remoteRefName := plumbing.NewRemoteReferenceName("origin", paths.MetadataBranchName) remoteRef, err := repo.Reference(remoteRefName, true) if errors.Is(err, plumbing.ErrReferenceNotFound) { return false, nil @@ -75,7 +77,7 @@ func WarnIfMetadataDisconnected() { slog.String("error", err.Error())) return } - disconnected, err := IsMetadataDisconnected(ctx, repo) + disconnected, err := IsMetadataDisconnected(ctx, repo, plumbing.NewRemoteReferenceName("origin", paths.MetadataBranchName)) if err != nil { logging.Debug(ctx, "metadata disconnection check failed", slog.String("error", err.Error())) @@ -94,13 +96,16 @@ func WarnIfMetadataDisconnected() { // only happens due to the empty-orphan bug. Diverged (shared ancestor) is normal // and handled by the push path's tree merge. // +// remoteRefName is the reference to compare against (e.g., refs/remotes/origin/ +// or refs/entire-fetch-tmp/ when fetching from a checkpoint URL). +// // Repair strategy: cherry-pick local commits onto remote tip, preserving all data. // Checkpoint shards use unique paths (//), so cherry-picks always // apply cleanly. // // Progress messages are written to w (typically os.Stderr for hooks or // cmd.ErrOrStderr() for commands). -func ReconcileDisconnectedMetadataBranch(ctx context.Context, repo *git.Repository, w io.Writer) error { +func ReconcileDisconnectedMetadataBranch(ctx context.Context, repo *git.Repository, remoteRefName plumbing.ReferenceName, w io.Writer) error { refName := plumbing.NewBranchReferenceName(paths.MetadataBranchName) // Check local branch @@ -112,8 +117,7 @@ func ReconcileDisconnectedMetadataBranch(ctx context.Context, repo *git.Reposito return fmt.Errorf("failed to check local metadata branch: %w", err) } - // Check remote-tracking branch - remoteRefName := plumbing.NewRemoteReferenceName("origin", paths.MetadataBranchName) + // Check remote/fetched branch remoteRef, err := repo.Reference(remoteRefName, true) if errors.Is(err, plumbing.ErrReferenceNotFound) { return nil // No remote branch — nothing to reconcile diff --git a/cmd/entire/cli/strategy/metadata_reconcile_test.go b/cmd/entire/cli/strategy/metadata_reconcile_test.go index 0f08d1057..c2b3bad6d 100644 --- a/cmd/entire/cli/strategy/metadata_reconcile_test.go +++ b/cmd/entire/cli/strategy/metadata_reconcile_test.go @@ -58,7 +58,7 @@ func TestReconcileDisconnected_NoRemote(t *testing.T) { } // Should be a no-op (no remote) - if err := ReconcileDisconnectedMetadataBranch(context.Background(), repo, io.Discard); err != nil { + if err := ReconcileDisconnectedMetadataBranch(context.Background(), repo, originMetadataRef(), io.Discard); err != nil { t.Fatalf("unexpected error: %v", err) } } @@ -76,7 +76,7 @@ func TestReconcileDisconnected_NoLocal(t *testing.T) { } // No local branch → no-op - if err := ReconcileDisconnectedMetadataBranch(context.Background(), repo, io.Discard); err != nil { + if err := ReconcileDisconnectedMetadataBranch(context.Background(), repo, originMetadataRef(), io.Discard); err != nil { t.Fatalf("unexpected error: %v", err) } } @@ -98,7 +98,7 @@ func TestReconcileDisconnected_SameHash(t *testing.T) { } // Same hash → no-op - if err := ReconcileDisconnectedMetadataBranch(context.Background(), repo, io.Discard); err != nil { + if err := ReconcileDisconnectedMetadataBranch(context.Background(), repo, originMetadataRef(), io.Discard); err != nil { t.Fatalf("unexpected error: %v", err) } } @@ -139,7 +139,7 @@ func TestReconcileDisconnected_SharedAncestry(t *testing.T) { } // Shared ancestry → no-op - if err := ReconcileDisconnectedMetadataBranch(context.Background(), repo, io.Discard); err != nil { + if err := ReconcileDisconnectedMetadataBranch(context.Background(), repo, originMetadataRef(), io.Discard); err != nil { t.Fatalf("unexpected error: %v", err) } } @@ -186,7 +186,7 @@ func TestReconcileDisconnected_Disconnected(t *testing.T) { } // Run reconciliation - if err := ReconcileDisconnectedMetadataBranch(context.Background(), repo, io.Discard); err != nil { + if err := ReconcileDisconnectedMetadataBranch(context.Background(), repo, originMetadataRef(), io.Discard); err != nil { t.Fatalf("ReconcileDisconnectedMetadataBranch() failed: %v", err) } @@ -297,7 +297,7 @@ func TestReconcileDisconnected_MultipleLocalCheckpoints(t *testing.T) { } // Run reconciliation - if err := ReconcileDisconnectedMetadataBranch(context.Background(), repo, io.Discard); err != nil { + if err := ReconcileDisconnectedMetadataBranch(context.Background(), repo, originMetadataRef(), io.Discard); err != nil { t.Fatalf("ReconcileDisconnectedMetadataBranch() failed: %v", err) } @@ -416,7 +416,7 @@ func TestIsMetadataDisconnected_NoRemote(t *testing.T) { t.Fatalf("failed to open repo: %v", err) } - disconnected, err := IsMetadataDisconnected(context.Background(), repo) + disconnected, err := IsMetadataDisconnected(context.Background(), repo, originMetadataRef()) if err != nil { t.Fatalf("unexpected error: %v", err) } @@ -437,7 +437,7 @@ func TestIsMetadataDisconnected_NoLocal(t *testing.T) { } // No local branch → false - disconnected, err := IsMetadataDisconnected(context.Background(), repo) + disconnected, err := IsMetadataDisconnected(context.Background(), repo, originMetadataRef()) if err != nil { t.Fatalf("unexpected error: %v", err) } @@ -461,7 +461,7 @@ func TestIsMetadataDisconnected_SameHash(t *testing.T) { t.Fatalf("EnsureMetadataBranch failed: %v", err) } - disconnected, err := IsMetadataDisconnected(context.Background(), repo) + disconnected, err := IsMetadataDisconnected(context.Background(), repo, originMetadataRef()) if err != nil { t.Fatalf("unexpected error: %v", err) } @@ -503,7 +503,7 @@ func TestIsMetadataDisconnected_SharedAncestry(t *testing.T) { t.Fatalf("failed to re-open repo: %v", err) } - disconnected, err := IsMetadataDisconnected(context.Background(), repo) + disconnected, err := IsMetadataDisconnected(context.Background(), repo, originMetadataRef()) if err != nil { t.Fatalf("unexpected error: %v", err) } @@ -538,7 +538,7 @@ func TestIsMetadataDisconnected_Disconnected(t *testing.T) { t.Fatalf("failed to open repo: %v", err) } - disconnected, err := IsMetadataDisconnected(context.Background(), repo) + disconnected, err := IsMetadataDisconnected(context.Background(), repo, originMetadataRef()) if err != nil { t.Fatalf("unexpected error: %v", err) } @@ -593,7 +593,7 @@ func TestReconcileDisconnected_ModifiedEntries(t *testing.T) { t.Fatalf("failed to open repo: %v", err) } - if err := ReconcileDisconnectedMetadataBranch(context.Background(), repo, io.Discard); err != nil { + if err := ReconcileDisconnectedMetadataBranch(context.Background(), repo, originMetadataRef(), io.Discard); err != nil { t.Fatalf("ReconcileDisconnectedMetadataBranch() failed: %v", err) } @@ -693,7 +693,7 @@ func TestReconcileDisconnected_AllEmptyOrphans(t *testing.T) { remoteRef, err := repo.Reference(remoteRefName, true) require.NoError(t, err) - err = ReconcileDisconnectedMetadataBranch(context.Background(), repo, io.Discard) + err = ReconcileDisconnectedMetadataBranch(context.Background(), repo, originMetadataRef(), io.Discard) require.NoError(t, err) // Local branch should now point to the remote tip (reset, not cherry-picked) @@ -743,7 +743,7 @@ func TestReconcileDisconnected_CherryPickDeletion(t *testing.T) { repo, err := git.PlainOpen(cloneDir) require.NoError(t, err) - err = ReconcileDisconnectedMetadataBranch(context.Background(), repo, io.Discard) + err = ReconcileDisconnectedMetadataBranch(context.Background(), repo, originMetadataRef(), io.Discard) require.NoError(t, err) // Verify merged tree: should have remote data + first local checkpoint, @@ -766,3 +766,65 @@ func TestReconcileDisconnected_CherryPickDeletion(t *testing.T) { // Second local checkpoint should be deleted assert.NotContains(t, entries, "cd/ef01234567/metadata.json", "deleted checkpoint should not be present") } + +// TestReconcileDisconnected_TempRef verifies that reconciliation works when the +// remote data is stored under a temp ref (refs/entire-fetch-tmp/) instead of the +// standard remote-tracking ref (refs/remotes/origin/). This is the code path used +// when pushing to a checkpoint URL with ENTIRE_CHECKPOINT_TOKEN. +func TestReconcileDisconnected_TempRef(t *testing.T) { + t.Parallel() + + bareDir := initBareWithMetadataBranch(t) + cloneDir, run := cloneWithConfig(t, bareDir) + + // Create a disconnected local metadata branch (simulating the empty-orphan bug) + run("checkout", "--orphan", "temp-orphan") + run("rm", "-rf", ".") + localDir := filepath.Join(cloneDir, "ab", "cdef012345") + require.NoError(t, os.MkdirAll(localDir, 0o755)) + require.NoError(t, os.WriteFile(filepath.Join(localDir, "metadata.json"), []byte(`{"checkpoint_id":"abcdef012345"}`), 0o644)) + run("add", ".") + run("commit", "-m", "Checkpoint: abcdef012345") + run("branch", "-f", paths.MetadataBranchName, "temp-orphan") + run("checkout", "main") + + repo, err := git.PlainOpen(cloneDir) + require.NoError(t, err) + + // Copy the remote-tracking ref to a temp ref (simulating what fetchAndMergeSessionsCommon + // does for URL targets). The temp ref holds the same commit as origin's remote-tracking ref. + remoteRefName := plumbing.NewRemoteReferenceName("origin", paths.MetadataBranchName) + remoteRef, err := repo.Reference(remoteRefName, true) + require.NoError(t, err) + + tmpRefName := plumbing.ReferenceName("refs/entire-fetch-tmp/" + paths.MetadataBranchName) + tmpRef := plumbing.NewHashReference(tmpRefName, remoteRef.Hash()) + require.NoError(t, repo.Storer.SetReference(tmpRef)) + + // Reconcile against the temp ref — this should fix the disconnection + err = ReconcileDisconnectedMetadataBranch(context.Background(), repo, tmpRefName, io.Discard) + require.NoError(t, err) + + // Verify result: local branch should now be parented off the remote (temp ref) tip + newRef, err := repo.Reference(plumbing.NewBranchReferenceName(paths.MetadataBranchName), true) + require.NoError(t, err) + tipCommit, err := repo.CommitObject(newRef.Hash()) + require.NoError(t, err) + + require.Len(t, tipCommit.ParentHashes, 1, "expected linear history") + assert.Equal(t, remoteRef.Hash(), tipCommit.ParentHashes[0], + "cherry-picked commit should be parented off temp ref (remote) tip") + + // Verify merged tree contains both local and remote data + tree, err := tipCommit.Tree() + require.NoError(t, err) + entries := make(map[string]object.TreeEntry) + require.NoError(t, checkpoint.FlattenTree(repo, tree, "", entries)) + assert.Contains(t, entries, "metadata.json", "remote data should be preserved") + assert.Contains(t, entries, "ab/cdef012345/metadata.json", "local data should be preserved") +} + +// originMetadataRef returns the standard remote-tracking ref for the metadata branch. +func originMetadataRef() plumbing.ReferenceName { + return plumbing.NewRemoteReferenceName("origin", paths.MetadataBranchName) +} diff --git a/cmd/entire/cli/strategy/push_common.go b/cmd/entire/cli/strategy/push_common.go index 2a595756f..cb480ca34 100644 --- a/cmd/entire/cli/strategy/push_common.go +++ b/cmd/entire/cli/strategy/push_common.go @@ -10,6 +10,7 @@ import ( "time" "github.com/entireio/cli/cmd/entire/cli/checkpoint" + "github.com/entireio/cli/cmd/entire/cli/paths" "github.com/go-git/go-git/v6" "github.com/go-git/go-git/v6/plumbing" @@ -172,8 +173,12 @@ func fetchAndMergeSessionsCommon(ctx context.Context, target, branchName string) // this cherry-picks local commits onto remote tip, updating the local ref. // If reconciliation fails, abort — proceeding to tree merge on disconnected // branches would silently combine unrelated histories. - if reconcileErr := ReconcileDisconnectedMetadataBranch(ctx, repo, os.Stderr); reconcileErr != nil { - return fmt.Errorf("metadata reconciliation failed: %w", reconcileErr) + // Only run for the metadata branch — reconciliation is specific to that branch's + // orphan-detection logic and would be incorrect for other branches. + if branchName == paths.MetadataBranchName { + if reconcileErr := ReconcileDisconnectedMetadataBranch(ctx, repo, fetchedRefName, os.Stderr); reconcileErr != nil { + return fmt.Errorf("metadata reconciliation failed: %w", reconcileErr) + } } // Get local branch (re-read after potential reconciliation update)