diff --git a/api/checkpoint/metadata.go b/api/checkpoint/metadata.go index 3f6a7ec2c2..5d361e3de4 100644 --- a/api/checkpoint/metadata.go +++ b/api/checkpoint/metadata.go @@ -151,6 +151,14 @@ type WriteOptions struct { // CheckpointSummary.CommitSHA point back here. CommitSHA string + // LinkedCommits links this checkpoint to existing commits without a + // trailer, as `entire session attach --commit` writes when the commit + // cannot be amended. Unlike the import anchor (CommitSHA) it is an + // attributing link: readers treat each entry exactly like a commit + // carrying this checkpoint's Entire-Checkpoint trailer. A rewrite of the + // same checkpoint keeps the existing entries and adds new ones. + LinkedCommits []LinkedCommit + // Transcript is the session transcript content (full.jsonl). // Must be pre-redacted (via redact.JSONLBytes or redact.AlreadyRedacted for trusted sources). Transcript redact.RedactedBytes @@ -355,6 +363,10 @@ type CheckpointInfo struct { // CheckpointID is the stable 12-hex-char identifier CheckpointID id.CheckpointID + // LinkedCommits are the checkpoint's trailer-less commit links; see + // WriteOptions.LinkedCommits. + LinkedCommits []LinkedCommit + // SessionID is the session identifier (most recent session for multi-session checkpoints) SessionID string @@ -591,6 +603,8 @@ type CheckpointSummary struct { FilesTouched []string `json:"files_touched"` Sessions []SessionFilePaths `json:"sessions"` TokenUsage *types.TokenUsage `json:"token_usage,omitempty"` + // LinkedCommits: attributing trailer-less links; see WriteOptions.LinkedCommits. + LinkedCommits []LinkedCommit `json:"linked_commits,omitempty"` // LegacyCombinedAttribution carries the combined_attribution older CLIs // wrote into the root summary through rewrites, byte for byte; see @@ -651,3 +665,16 @@ type CodeLearning struct { EndLine int `json:"end_line,omitempty"` // End line for ranges (optional) Finding string `json:"finding"` // What was learned } + +// LinkedCommit is one trailer-less link from a checkpoint to a commit; see +// WriteOptions.LinkedCommits. The server links it only after verifying the +// commit, so Repo is a hint for finding which code repository holds SHA when +// one checkpoint store serves several. +type LinkedCommit struct { + // SHA is the full lowercase commit hash. + SHA string `json:"sha"` + // Repo is the code repository as // (e.g. + // gh/entireio/cli), resolved from the git remote that holds the commit. + // Empty when it could not be resolved. + Repo string `json:"repo,omitempty"` +} diff --git a/cmd/entire/cli/agent_help_cmd.go b/cmd/entire/cli/agent_help_cmd.go index cc779b3720..20a6c04355 100644 --- a/cmd/entire/cli/agent_help_cmd.go +++ b/cmd/entire/cli/agent_help_cmd.go @@ -191,6 +191,14 @@ var agentHelpClassification = map[string]agentHelpFacts{ // true for both audiences (e.g. "these endpoints are internal and can change") // belongs in Long instead, where both see it. var agentHelpGuidance = map[string]string{ + "session attach": "Commits made during a session are linked to it automatically. Use this\n" + + "only when one was missed: `entire session attach --commit `\n" + + "(default HEAD). Run it without --force first: it prints what it would do\n" + + "(rewrite unpushed commits to add the trailer, or record a link to a pushed\n" + + "commit and push the session transcript) and exits non-zero without changing\n" + + "anything. Show the user that output and ask them; pass --force only after\n" + + "they agree to that exact change, never pre-emptively. A link to a pushed\n" + + "commit counts only when its author attaches it.", "api": "LAST RESORT. Right in two cases: you are developing against Entire's own\n" + "APIs and want a raw response, or no first-class command covers your need.\n" + "Otherwise prefer the command built for the job (checkpoint, session, trail,\n" + diff --git a/cmd/entire/cli/attach.go b/cmd/entire/cli/attach.go index df0e21158c..f0ce5b0b15 100644 --- a/cmd/entire/cli/attach.go +++ b/cmd/entire/cli/attach.go @@ -1,12 +1,15 @@ package cli import ( + "bytes" "context" "errors" "fmt" "io" "log/slog" + "os" "os/exec" + "slices" "strings" "time" @@ -15,7 +18,8 @@ import ( "github.com/entireio/cli/cmd/entire/cli/agent/types" cpkg "github.com/entireio/cli/cmd/entire/cli/checkpoint" "github.com/entireio/cli/cmd/entire/cli/checkpoint/id" - "github.com/entireio/cli/cmd/entire/cli/interactive" + "github.com/entireio/cli/cmd/entire/cli/checkpoint/remote" + "github.com/entireio/cli/cmd/entire/cli/gitremote" "github.com/entireio/cli/cmd/entire/cli/logging" "github.com/entireio/cli/cmd/entire/cli/paths" cliReview "github.com/entireio/cli/cmd/entire/cli/review" @@ -28,17 +32,15 @@ import ( "github.com/entireio/cli/perf" "github.com/entireio/cli/redact" - "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" ) -// attachOptions carries optional flags for runAttach. Force is the original -// flag; Review opts the attach into recording the session as an -// agent_review in the checkpoint metadata. +// attachOptions carries optional flags for runAttach. Review opts the attach +// into recording the session as an agent_review in the checkpoint metadata. type attachOptions struct { - Force bool // Review, when true, tags the attached session as a review. Skills are // resolved inside runAttach after the real agent is known (via session // state or transcript auto-detection), not at the cobra layer — the @@ -53,6 +55,13 @@ type attachOptions struct { // transcript's first user prompt. Set from a pending-review marker when // `entire session attach --review` adopts the prompt the user was asked to run. ReviewPromptOverride string + // Commit, when set, names the commit to link instead of HEAD. How the link + // is written follows from the commit, never from a flag: see + // attachLinkMode. + Commit string + // Force skips the confirmation, after the user has agreed to what attach + // printed. Without it and without a terminal, attach changes nothing. + Force bool } // committedRefs resolves the committed metadata topology. @@ -77,6 +86,7 @@ func openAttachStore(ctx context.Context, repo *git.Repository, refs cpkg.Persis func newAttachCmd() *cobra.Command { var ( force bool + commitFlag string agentFlag string reviewFlag bool skillsFlag []string @@ -87,11 +97,31 @@ func newAttachCmd() *cobra.Command { Long: `Attach an existing agent session that wasn't captured by hooks. This creates a checkpoint from the session's transcript and links it to the -last commit. Use this when hooks failed to fire or weren't installed when -the session started, or to attach a research session. - -If the last commit already has a checkpoint, the session is added to it. -Otherwise a new checkpoint is created. +last commit, or to the commit named by --commit. Use this when hooks failed +to fire or weren't installed when the session started, or to attach a +research session. + +How the session is linked follows from two facts about the commit — does it +already have a checkpoint, and is it pushed — the same for HEAD and --commit: + - It already has a checkpoint: the session is added to it. Git history is + not changed. + - It is already pushed: the commit is left unchanged and the link is + recorded in a new checkpoint, which is pushed right away. The link counts + once the commit's author attaches it, and names that exact commit: after + a rebase or amend, attach the session to the new commit. + - It isn't pushed: the Entire-Checkpoint trailer is added to it, which + rewrites it and any commits after it on the current branch (their content + is unchanged). A trailer survives a later rebase. Merges after the commit, + or a rebase or merge in progress, are refused. If a remote can't be + reached to confirm the commit isn't pushed, attach refuses rather than + risk rewriting a shared commit. + +attach prints what it is about to do and asks before doing it. Without a +terminal (an agent or a script) it changes nothing and exits non-zero; show +the user what it printed, and once they agree, rerun with --force. + +A session can be attached to more than one commit. Each checkpoint records the +turns since the session's previous checkpoint. Use --review to tag the attached session as an agent review. The first user prompt in the transcript is recorded as the review prompt. @@ -115,6 +145,7 @@ the transcript and prints the detected agent name.`, // and so auto-detection can find transcripts from external agents. external.DiscoverAndRegister(cmd.Context()) opts := attachOptions{ + Commit: commitFlag, Force: force, Review: reviewFlag, ReviewSkillsOverride: skillsFlag, @@ -149,7 +180,8 @@ the transcript and prints the detected agent name.`, return err }, } - cmd.Flags().BoolVarP(&force, "force", "f", false, "Skip confirmation and amend the last commit with the checkpoint trailer (best-effort; if the amend fails the checkpoint is still created and the trailer is printed for manual paste)") + cmd.Flags().StringVar(&commitFlag, "commit", "", "Link the session to this commit (hash, ref, or HEAD~n) instead of the last one") + cmd.Flags().BoolVarP(&force, "force", "f", false, "Skip the confirmation. Only after the user has agreed to what attach describes") cmd.Flags().StringVarP(&agentFlag, "agent", "a", string(agent.DefaultAgentName), "Agent that created the session (see 'entire agent list' for registered agents, including external)") cmd.Flags().BoolVar(&reviewFlag, "review", false, "Tag the attached session as an agent review") cmd.Flags().StringSliceVar(&skillsFlag, "skills", nil, "Optional: declare which review skills were run in this session. Only used with --review") @@ -237,25 +269,13 @@ func runAttach(ctx context.Context, w, errW io.Writer, sessionID string, agentNa if err != nil { return err } - - // If session already has a checkpoint, just offer to link it. - if existingState != nil && !existingState.LastCheckpointID.IsEmpty() { - // Review-upgrade isn't supported yet: the existing checkpoint's - // metadata tree would need to be rewritten with Kind/ReviewSkills/ - // ReviewPrompt set, and a new commit pushed onto entire/checkpoints/v1. - // Error out with a concrete message rather than silently linking the - // checkpoint without the review metadata. - if opts.Review { - return fmt.Errorf( - "session %s already has checkpoint %s; rewriting an existing checkpoint as a review is not supported yet", - sessionID, existingState.LastCheckpointID.String(), - ) - } - cpID := existingState.LastCheckpointID.String() - fmt.Fprintf(w, "Session %s already has checkpoint %s\n", sessionID, cpID) - amendOrPrintTrailer(logCtx, w, errW, headCommit, cpID, opts.Force) - return nil + // Decide how to link before writing anything, so a commit that can't be + // linked is refused with nothing left behind. + plan, err := planAttachLink(ctx, repo, headCommit, opts) + if err != nil { + return err } + target := plan.target // Resolve agent and transcript path. ag, found, err := resolveAgentAndTranscript(logCtx, w, sessionID, agentName, existingState) @@ -275,20 +295,16 @@ func runAttach(ctx context.Context, w, errW io.Writer, sessionID string, agentNa return fmt.Errorf("failed to read transcript: %w", err) } - meta := extractTranscriptMetadataForAgent(ag, transcriptPath, transcriptData) - warnEmptyTranscriptMetadata(errW, ag.Name(), meta, opts) - - // Determine checkpoint ID: reuse from HEAD if one exists, otherwise generate new. - checkpointID, isExistingCheckpoint := resolveCheckpointID(ctx, headCommit) + // A session can be attached to several commits. Each checkpoint holds the + // turns since the session's previous one, as hook-made checkpoints do. + window := attachTranscriptWindowFor(errW, ag, transcriptPath, transcriptData, existingState) + meta := extractTranscriptMetadataForAgent(ag, transcriptPath, transcriptData, window.start) - // If HEAD references an existing checkpoint, make sure we have it locally - // before writing — otherwise we'd create a fresh session 0 under the same - // ID and overwrite the original on push. refs := opts.committedRefs(ctx) - refreshedRepo, err := ensureCheckpointAvailable(ctx, logCtx, repo, refs, checkpointID, isExistingCheckpoint) - if refreshedRepo != nil && refreshedRepo != repo { + cp, err := resolveAttachCheckpoint(ctx, logCtx, repo, refs, plan, sessionID, opts) + if cp.repo != nil && cp.repo != repo { oldRepo := repo - repo = refreshedRepo + repo = cp.repo if closeErr := oldRepo.Close(); closeErr != nil { logging.Warn(logCtx, "failed to close stale repository handle after checkpoint refresh", slog.String("error", closeErr.Error())) @@ -297,31 +313,51 @@ func runAttach(ctx context.Context, w, errW io.Writer, sessionID string, agentNa if err != nil { return err } - - store, err := openAttachStore(ctx, repo, refs) - if err != nil { - return err + if cp.holdsSession { + fmt.Fprintf(w, "Session %s is already in checkpoint %s on commit %s.\n", sessionID, cp.id, target.Hash.String()[:12]) + // An earlier attach may have written it but failed to push it; for a + // pushed commit, push it now — after the same confirmation as any + // other attach, since that sends the transcript. + if plan.remote == "" || checkpointOnRemote(ctx, plan.remote, cp.id) { + return nil + } + warning := []string{ + fmt.Sprintf("Checkpoint %s, which holds session %s, isn't on %s yet.", cp.id, sessionID, plan.remote), + "Pending checkpoints, including this one with the session transcript, are pushed to " + plan.remote + " now.", + } + if err := confirmAttach(w, errW, warning, opts.Force); err != nil { + if errors.Is(err, errAttachDeclined) { + return nil + } + return err + } + return redeliverAttachedCheckpoint(ctx, w, errW, plan, cp.id) + } + checkpointID, isExistingCheckpoint := cp.id, cp.existing + if window.start > 0 && meta.TurnCount == 0 { + fmt.Fprintf(errW, "warning: the session has no new turns since checkpoint %s.\n", window.since) + } else { + warnEmptyTranscriptMetadata(errW, ag.Name(), meta, opts) } - // Defense-in-depth guard: the earlier existingState.LastCheckpointID - // check only fires when the session's state file records its - // checkpoint. A session already stored in the HEAD checkpoint but - // whose state is missing/stale (state file deleted, never written, - // condensed without LastCheckpointID update, or pulled from a remote - // that wasn't reflected locally) would bypass that guard. - // findSessionIndex matches by SessionID — without this check, a - // review-attach on such a session silently overwrites the existing - // session's metadata in the checkpoint. - if opts.Review && isExistingCheckpoint { - exists, readErr := checkpointHasSessionMetadata(ctx, repo, refs, checkpointID, sessionID) - if readErr != nil { - return fmt.Errorf("failed to check checkpoint %s for session %s: %w", checkpointID.String(), sessionID, readErr) + if err := confirmAttach(w, errW, attachWarning(ctx, plan, sessionID, checkpointID, isExistingCheckpoint, window), opts.Force); err != nil { + if errors.Is(err, errAttachDeclined) { + return nil } - if exists { - return fmt.Errorf( - "session %s is already recorded in checkpoint %s; rewriting an existing checkpoint as a review is not supported yet", - sessionID, checkpointID.String(), - ) + return err + } + // The prompt can stay open for a while, and the commit may have been + // pushed meanwhile, from here or another clone. Ask the remotes again, + // directly, before rewriting it. + if plan.mode == attachAddTrailer { + holder, _, unreachable, err := remoteHoldingPushedCommit(ctx, target, plan.checkedRemotes) + switch { + case err != nil: + return fmt.Errorf("couldn't check again whether commit %s was pushed while attach was waiting, so it won't be rewritten; nothing was changed: %w", target.Hash.String()[:12], err) + case holder != "": + return fmt.Errorf("commit %s was pushed to %s while attach was waiting, so it won't be rewritten; nothing was changed, run attach again", target.Hash.String()[:12], holder) + case len(unreachable) > 0: + return fmt.Errorf("couldn't reach %s to check again whether commit %s was pushed while attach was waiting, so it won't be rewritten; nothing was changed", strings.Join(unreachable, ", "), target.Hash.String()[:12]) } } @@ -330,7 +366,11 @@ func runAttach(ctx context.Context, w, errW io.Writer, sessionID string, agentNa return fmt.Errorf("failed to get git author: %w", err) } - tokenUsage := agent.CalculateTokenUsage(logCtx, ag, transcriptData, 0, "") + tokenUsage := agent.CalculateTokenUsage(logCtx, ag, transcriptData, window.start, "") + sessionTokens := tokenUsage + if window.start > 0 { + sessionTokens = agent.CalculateTokenUsage(logCtx, ag, transcriptData, 0, "") + } // attach writes checkpoints and historically never configured // redaction; a scanner-config failure must fail the attach. @@ -345,36 +385,25 @@ func runAttach(ctx context.Context, w, errW io.Writer, sessionID string, agentNa return fmt.Errorf("failed to redact transcript: %w", redactErr) } - writeOpts := cpkg.WriteOptions{ - CheckpointID: checkpointID, - SessionID: sessionID, - Strategy: strategy.StrategyNameManualCommit, - Transcript: redactedTranscript, - Prompts: attachPrompts(meta), - CheckpointsCount: attachStepCount(meta.TurnCount), - AuthorName: author.Name, - AuthorEmail: author.Email, - Agent: ag.Type(), - Model: meta.Model, - TokenUsage: tokenUsage, - } - if opts.Review { - writeOpts.Kind = string(session.KindAgentReview) - writeOpts.ReviewSkills = reviewSkills - writeOpts.ReviewPrompt = reviewPromptForAttach(meta, opts) - writeOpts.HasReview = true - } + writeOpts := attachWriteOptions(ctx, plan, checkpointID, isExistingCheckpoint, sessionID, ag, redactedTranscript, meta, window, author, tokenUsage, opts, reviewSkills) // ReservedSession routes by the checkpoint ID's backend, as condensation // does: appending to an existing ULID checkpoint under the git-branch // primary must land in its ref, not on the v1 branch where reads never // look for a ULID. A freshly minted ID already matches the primary. - if err := store.Write(ctx, cpkg.ReservedSession(writeOpts)); err != nil { + if err := cp.store.Write(ctx, cpkg.ReservedSession(writeOpts)); err != nil { return fmt.Errorf("failed to write checkpoint: %w", err) } - // Create or update session state. - if err := saveAttachSessionState(logCtx, repo, existingState, sessionID, ag.Type(), transcriptPath, agentHome, checkpointID, meta, tokenUsage, opts, reviewSkills); err != nil { + fmt.Fprintf(w, "Attached session %s\n", sessionID) + printAttachFooter(w, meta, tokenUsage) + linkErr := finishAttachLink(ctx, w, errW, plan, checkpointID, isExistingCheckpoint) + + // Create or update session state, after any rewrite so a seeded base is + // the new HEAD. Seeding BaseCommit makes the session link future commits + // on HEAD; an attach to an older commit is about that commit only. + seedBase := target.Hash.Equal(headCommit.Hash) + if err := saveAttachSessionState(logCtx, repo, existingState, sessionID, ag.Type(), transcriptPath, agentHome, checkpointID, meta, sessionTokens, window.end, opts, reviewSkills, seedBase); err != nil { logging.Warn(logCtx, "failed to save session state", "error", err) } else if activeHome { // The active home was resolved from the user's environment, so it may @@ -383,34 +412,664 @@ func runAttach(ctx context.Context, w, errW io.Writer, sessionID string, agentNa logging.Warn(logCtx, "failed to record agent home", "error", err) } } + return linkErr +} - fmt.Fprintf(w, "Attached session %s\n", sessionID) - printAttachFooter(w, meta, tokenUsage) +// attachCheckpoint is the checkpoint an attach writes into. +type attachCheckpoint struct { + // repo replaces the caller's handle when fetching the checkpoint refreshed it. + repo *git.Repository + store cpkg.PersistentStore + id id.CheckpointID + existing bool + // holdsSession: the existing checkpoint already records this session. + holdsSession bool +} + +// resolveAttachCheckpoint picks the checkpoint for plan's commit: the one its +// trailer names, the one an earlier attach recorded a link to, or a new one. +// An existing checkpoint is fetched and verified first, so it is never rebuilt +// from scratch under its ID. Nothing is written. +func resolveAttachCheckpoint(ctx, logCtx context.Context, repo *git.Repository, refs cpkg.PersistentRefs, plan attachLinkPlan, sessionID string, opts attachOptions) (attachCheckpoint, error) { + checkpointID, isExistingCheckpoint := resolveCheckpointID(ctx, plan.target) + // A pushed commit an earlier attach already linked has no trailer to find + // its checkpoint by; its checkpoint names it instead. Join that one, as the + // trailer paths do, rather than start a second checkpoint for the commit. + // The lookup reads every copy, remote-tracking included, so a fresh clone + // joins the commit's checkpoint instead of starting a duplicate. + if plan.mode == attachRecordLink && !isExistingCheckpoint { + if readStore, openErr := openAttachStore(ctx, repo, refs); openErr == nil { + if linkedID, ok := checkpointLinkedTo(ctx, readStore, plan.target); ok { + checkpointID, isExistingCheckpoint = linkedID, true + } + } + } + cp := attachCheckpoint{id: checkpointID, existing: isExistingCheckpoint} + refreshedRepo, err := ensureCheckpointAvailable(ctx, logCtx, repo, refs, checkpointID, isExistingCheckpoint) + if refreshedRepo != nil { + cp.repo, repo = refreshedRepo, refreshedRepo + } + if err != nil { + return cp, err + } + if cp.store, err = openAttachStore(ctx, repo, refs); err != nil { + return cp, err + } + if !isExistingCheckpoint { + return cp, nil + } + exists, err := checkpointHasSessionMetadata(ctx, repo, refs, checkpointID, sessionID) + if err != nil { + return cp, fmt.Errorf("failed to check checkpoint %s for session %s: %w", checkpointID.String(), sessionID, err) + } + // Rewriting an existing checkpoint as a review isn't supported, and + // re-adding the same session would only overwrite its own entry. + if exists && opts.Review { + return cp, fmt.Errorf( + "session %s is already recorded in checkpoint %s; rewriting an existing checkpoint as a review is not supported yet", + sessionID, checkpointID.String(), + ) + } + cp.holdsSession = exists + return cp, nil +} + +// attachWriteOptions is the checkpoint write for one attach: the session's +// turns in window, and a recorded link when plan names a pushed commit. +func attachWriteOptions(ctx context.Context, plan attachLinkPlan, checkpointID id.CheckpointID, isExistingCheckpoint bool, sessionID string, ag agent.Agent, transcript redact.RedactedBytes, meta transcriptMetadata, window attachTranscriptWindow, author *GitAuthor, tokenUsage *agent.TokenUsage, opts attachOptions, reviewSkills []string) cpkg.WriteOptions { + writeOpts := cpkg.WriteOptions{ + CheckpointID: checkpointID, + SessionID: sessionID, + Strategy: strategy.StrategyNameManualCommit, + Transcript: transcript, + Prompts: attachPrompts(meta), + CheckpointsCount: attachStepCount(meta.TurnCount), + CheckpointTranscriptStart: window.start, + AuthorName: author.Name, + AuthorEmail: author.Email, + Agent: ag.Type(), + Model: meta.Model, + TokenUsage: tokenUsage, + } + if plan.mode == attachRecordLink && !isExistingCheckpoint { + writeOpts.LinkedCommits = []cpkg.LinkedCommit{{SHA: plan.target.Hash.String(), Repo: attachLinkRepo(ctx, plan.holder)}} + } + if opts.Review { + writeOpts.Kind = string(session.KindAgentReview) + writeOpts.ReviewSkills = reviewSkills + writeOpts.ReviewPrompt = reviewPromptForAttach(meta, opts) + writeOpts.HasReview = true + } + return writeOpts +} + +// attachTranscriptWindow is the part of a session's transcript one attach +// records, in the agent's own position metric. end is -1 when the agent can't +// report a position. +type attachTranscriptWindow struct { + start, end int + // since is the session's previous checkpoint when start > 0. + since id.CheckpointID +} + +// attachTranscriptWindowFor starts after the session's previous checkpoint, so +// attaching a session to a second commit records only its newer turns. A +// session with no state here (another machine, cleaned) starts from the top. +// A start past the transcript's end (rotated or rewritten) is reset to the top +// with a warning rather than recording nothing. +func attachTranscriptWindowFor(errW io.Writer, ag agent.Agent, transcriptPath string, transcriptData []byte, existingState *session.State) attachTranscriptWindow { + // Without the agent's own position, the end is the line count, the + // coordinate transcript.SliceFromLine slices the window by; an unknown end + // would leave the session's offset where it was, and the next attach would + // record these turns again. + window := attachTranscriptWindow{end: bytes.Count(transcriptData, []byte("\n"))} + if analyzer, ok := agent.AsTranscriptAnalyzer(ag); ok { + if pos, err := analyzer.GetTranscriptPosition(transcriptPath); err == nil { + window.end = pos + } + } + if existingState == nil || existingState.LastCheckpointID.IsEmpty() || existingState.CheckpointTranscriptStart <= 0 { + return window + } + if window.end >= 0 && existingState.CheckpointTranscriptStart > window.end { + fmt.Fprintf(errW, "warning: the transcript is shorter than when checkpoint %s was made, so the whole session is recorded again\n", existingState.LastCheckpointID) + return window + } + window.start = existingState.CheckpointTranscriptStart + window.since = existingState.LastCheckpointID + return window +} + +// attachWarning describes what attach is about to do, for confirmAttach. +func attachWarning(ctx context.Context, plan attachLinkPlan, sessionID string, checkpointID id.CheckpointID, isExistingCheckpoint bool, window attachTranscriptWindow) []string { + target := plan.target + var lines []string + switch { + case isExistingCheckpoint: + lines = append(lines, fmt.Sprintf("Adds session %s to checkpoint %s, which commit %s already has. Git history is not changed.", sessionID, checkpointID, describeCommit(target))) + if plan.mode == attachRecordLink { + lines = append(lines, "That checkpoint names the commit in a recorded link, not a trailer. The CLI can't verify who recorded it: anyone who can push checkpoints can name any commit.") + } + case plan.mode == attachRecordLink: + lines = append(lines, + fmt.Sprintf("Commit %s is already pushed to %s, so it won't be changed.", describeCommit(target), plan.holder), + fmt.Sprintf("A new checkpoint with session %s records a link to it instead. The link names this exact commit: if the commit is later rebased or amended, attach the session to the new one.", sessionID)) + if author, err := GetGitAuthor(ctx); err == nil && !strings.EqualFold(author.Email, target.Author.Email) { + lines = append(lines, fmt.Sprintf("The commit was authored by %s. A recorded link counts only when the commit's author attaches it.", target.Author.Email)) + } + default: + lines = append(lines, rewriteWarning(ctx, plan.rewrite, plan.checkedRemotes)...) + lines = append(lines, fmt.Sprintf("Session %s goes into a new checkpoint linked by that trailer.", sessionID)) + } + if plan.remote != "" { + lines = append(lines, "Pending checkpoints, including this one with the session transcript, are pushed to "+plan.remote+" now.") + } else { + lines = append(lines, "The checkpoint, including the session transcript, is pushed with your next git push.") + } + if window.start > 0 { + lines = append(lines, fmt.Sprintf("Only the turns since checkpoint %s are recorded.", window.since)) + } + return lines +} + +// finishAttachLink reports the checkpoint and completes its link to the target +// commit: adding the trailer to an unpushed commit, or reporting and pushing a +// recorded link. A checkpoint joined on a pushed commit is pushed too. +func finishAttachLink(ctx context.Context, w, errW io.Writer, plan attachLinkPlan, checkpointID id.CheckpointID, isExistingCheckpoint bool) error { if isExistingCheckpoint { fmt.Fprintf(w, " Added to existing checkpoint %s\n", checkpointID) + return redeliverAttachedCheckpoint(ctx, w, errW, plan, checkpointID) + } + fmt.Fprintf(w, " Created checkpoint %s\n", checkpointID) + if plan.mode == attachRecordLink { + return reportLinkedCommit(ctx, w, errW, plan, checkpointID) + } + if err := rewriteWithTrailer(ctx, w, plan.rewrite, checkpointID); err != nil { + return fmt.Errorf("checkpoint %s was created, but the trailer couldn't be added to commit %s (%w); add this line to its message to link it:\n\n Entire-Checkpoint: %s", + checkpointID, plan.target.Hash.String()[:12], err, checkpointID) + } + return nil +} + +// checkpointOnRemote reports whether remote's push target already has +// checkpointID as written locally. Anything it can't confirm reads as not +// there. +func checkpointOnRemote(ctx context.Context, remote string, checkpointID id.CheckpointID) bool { + target, disabled := strategy.CheckpointPushTarget(ctx, remote) + return !disabled && confirmCheckpointDelivered(ctx, target, checkpointID) == nil +} + +// redeliverAttachedCheckpoint pushes an existing checkpoint now when its +// commit is pushed: after a session joins it, or on a rerun, retrying a push +// an earlier attach couldn't complete. A recorded link exists only in the checkpoint, so +// failing to deliver it is an error; a trailer-linked one goes out with a +// later push, so that is only a warning. +func redeliverAttachedCheckpoint(ctx context.Context, w, errW io.Writer, plan attachLinkPlan, checkpointID id.CheckpointID) error { + if plan.remote == "" { + return nil + } + err := pushAttachedCheckpoint(ctx, w, plan.remote, checkpointID) + if err != nil && plan.mode != attachRecordLink { + fmt.Fprintf(errW, "warning: %v\n", err) return nil } + return err +} - fmt.Fprintf(w, " Created checkpoint %s\n", checkpointID) - amendOrPrintTrailer(logCtx, w, errW, headCommit, checkpointID.String(), opts.Force) +// attachLinkMode is how attach links a checkpoint to its target commit. It +// follows from two facts about the commit — does it already have a checkpoint, +// and is it pushed — never from a flag or from whether it is HEAD. +type attachLinkMode int + +const ( + // attachJoinExisting: the commit already carries an Entire-Checkpoint + // trailer; the session joins that checkpoint and history is unchanged. + attachJoinExisting attachLinkMode = iota + // attachAddTrailer: no remote branch holds the commit. The trailer is + // added, rewriting the commit and any after it: nobody else has them, and + // a trailer survives a later rebase, where a recorded link wouldn't. + attachAddTrailer + // attachRecordLink: a remote branch already holds the commit, so rewriting + // it would mean a force-push. The link is recorded in the checkpoint + // (LinkedCommits) and the commit is left alone. + attachRecordLink +) + +// attachLinkPlan is the commit an attach links to and how. +type attachLinkPlan struct { + target *object.Commit + mode attachLinkMode + // holder is a remote whose branches hold target, empty when none does (or, + // when joining, none could be reached). + holder string + // remote is where the checkpoint is pushed now: holder, or the branch's own + // remote when holder is an unrelated one. Empty when holder is. + remote string + // rewrite is target and the commits after it up to HEAD, oldest first + // (attachAddTrailer only). + rewrite []*object.Commit + // checkedRemotes are the remotes asked whether they hold target. + checkedRemotes []string +} + +// planAttachLink resolves the target (HEAD unless --commit names another) and +// decides how to link it. +func planAttachLink(ctx context.Context, repo *git.Repository, headCommit *object.Commit, opts attachOptions) (attachLinkPlan, error) { + target := headCommit + if opts.Commit != "" { + var err error + if target, err = resolveAttachCommit(repo, opts.Commit); err != nil { + return attachLinkPlan{}, err + } + } + remotes := attachRemotesToCheck(ctx) + if len(trailers.ParseAllCheckpoints(target.Message)) > 0 { + // Joining changes no history, so whether the commit is pushed only + // decides when the checkpoint goes out: now if it is, since there may be + // no later push of it, else with the next git push. A remote that can't + // be reached leaves it for that push. + holder, pushTo, _, err := remoteHoldingPushedCommit(ctx, target, remotes) + if err != nil { + return attachLinkPlan{}, err + } + return attachLinkPlan{target: target, mode: attachJoinExisting, remote: pushTo, holder: holder, checkedRemotes: remotes}, nil + } + holder, pushTo, unreachable, err := remoteHoldingPushedCommit(ctx, target, remotes) + if err != nil { + return attachLinkPlan{}, err + } + if holder != "" { + return attachLinkPlan{target: target, mode: attachRecordLink, remote: pushTo, holder: holder, checkedRemotes: remotes}, nil + } + if len(unreachable) > 0 { + // Absence from a remote we couldn't reach proves nothing; rewriting a + // commit someone already pushed is what this rule exists to prevent. + return attachLinkPlan{}, fmt.Errorf("couldn't confirm that commit %s isn't already pushed (could not reach %s), so it won't be rewritten; retry when the remote is reachable", + target.Hash.String()[:12], strings.Join(unreachable, ", ")) + } + chain, err := attachRewriteChain(ctx, repo, target, headCommit) + if err != nil { + return attachLinkPlan{}, err + } + return attachLinkPlan{target: target, mode: attachAddTrailer, rewrite: chain, checkedRemotes: remotes}, nil +} + +// remoteHoldingPushedCommit returns a remote whose branches contain target +// (holder) and the remote to push its checkpoint to, both "" when none does, +// plus the remotes it could not reach. Remote-tracking refs +// can be stale — someone may have pushed this commit from another clone — and +// rewriting a shared commit is what attach must avoid, so remotes are +// refreshed first, then asked directly: tracking refs may not cover every +// branch (single-branch clones, narrowed refspecs). +func remoteHoldingPushedCommit(ctx context.Context, target *object.Commit, remotes []string) (holder, pushTo string, unreachable []string, err error) { + fetchRemotesForAttach(ctx, remotes) + if holder, pushTo, err = remoteHoldingCommit(ctx, target, remotes); err != nil || holder != "" { + return holder, pushTo, nil, err + } + holder, unreachable = remoteContainingCommit(ctx, target, remotes) + return holder, holder, unreachable, nil +} + +// checkpointLinkedTo finds the most recent checkpoint whose recorded links +// name target, reading remote-discovered stubs for theirs. A listing failure +// finds none. +func checkpointLinkedTo(ctx context.Context, store cpkg.PersistentStore, target *object.Commit) (id.CheckpointID, bool) { + infos, err := store.List(ctx) + if err != nil { + logging.Debug(ctx, "attach: listing checkpoints for a recorded link failed", slog.String("error", err.Error())) + return id.EmptyCheckpointID, false + } + linked := cpkg.CheckpointsLinkedToWithStubs(ctx, store, infos, target.Hash.String(), target.Committer.When) + if len(linked) == 0 { + return id.EmptyCheckpointID, false + } + return linked[0], true +} +// attachFetchTimeout bounds the remote refresh attach does before deciding +// whether a commit is pushed. +const attachFetchTimeout = 30 * time.Second + +// attachRemotes lists the repository's remotes. +func attachRemotes(ctx context.Context) []string { + out, err := exec.CommandContext(ctx, "git", "remote").Output() + if err != nil { + return nil + } + return strings.Fields(string(out)) +} + +// attachRemotesToCheck lists the remotes a commit on the current branch would +// have been pushed to: the branch's upstream and push remotes, else origin, else +// every remote (detached HEAD, or no origin). Unrelated remotes, like an old +// fork's, are left alone, so one that is unreachable doesn't block attach. +func attachRemotesToCheck(ctx context.Context) []string { + all := attachRemotes(ctx) + var remotes []string + if branch, err := exec.CommandContext(ctx, "git", "symbolic-ref", "-q", "HEAD").Output(); err == nil { + out, err := exec.CommandContext(ctx, "git", "for-each-ref", "--format=%(upstream:remotename)%0a%(push:remotename)", "--", strings.TrimSpace(string(branch))).Output() + if err == nil { + for _, name := range strings.Fields(string(out)) { + if slices.Contains(all, name) && !slices.Contains(remotes, name) { + remotes = append(remotes, name) + } + } + } + } + switch { + case len(remotes) > 0: + return remotes + case slices.Contains(all, defaultMirrorRemote): + return []string{defaultMirrorRemote} + default: + return all + } +} + +// attachGitCommand runs git against a remote without credential prompts. SSH +// keeps the user's own configuration: attach is a foreground command, and +// without a terminal ssh can't prompt anyway. +func attachGitCommand(ctx context.Context, args ...string) *exec.Cmd { + cmd := exec.CommandContext(ctx, "git", args...) + cmd.Env = append(os.Environ(), "GIT_TERMINAL_PROMPT=0") + return cmd +} + +// fetchRemotesForAttach refreshes remotes' tracking refs, best-effort: a +// configured refspec can fail without the remote being unreachable, so +// reachability is decided by remoteContainingCommit instead. +func fetchRemotesForAttach(ctx context.Context, remotes []string) { + for _, remote := range remotes { + fetchCtx, cancel := context.WithTimeout(ctx, attachFetchTimeout) + _ = attachGitCommand(fetchCtx, "fetch", "--quiet", "--no-tags", "--", remote).Run() //nolint:errcheck // best-effort; see doc comment + cancel() + } +} + +// remoteContainingCommit asks each of remotes directly whether any of its +// branches contains target, and returns the first that does plus the remotes it +// could not reach. Branch tips come from ls-remote; when target is not a tip, +// the branches whose tips aren't already local are fetched without writing any +// ref, so a commit someone pushed and then built on is still found. +func remoteContainingCommit(ctx context.Context, target *object.Commit, remotes []string) (holder string, unreachable []string) { + sha := target.Hash.String() + for _, remote := range remotes { + branches, tips, err := remoteBranchTips(ctx, remote) + if err != nil { + unreachable = append(unreachable, remote) + continue + } + if slices.Contains(tips, sha) { + return remote, unreachable + } + if missing := branchesWithMissingTips(ctx, branches, tips); len(missing) > 0 { + // Fetch the branches by name (a source-only glob refspec is + // invalid), writing no ref, only to get their objects for the + // ancestry check: an empty --refmap stops git updating the + // remote-tracking refs the configured refspec maps them to. + fetchArgs := append([]string{"fetch", "--quiet", "--no-tags", "--no-write-fetch-head", "--refmap=", "--", remote}, missing...) + fetchCtx, cancel := context.WithTimeout(ctx, attachFetchTimeout) + err = attachGitCommand(fetchCtx, fetchArgs...).Run() + cancel() + if err != nil { + unreachable = append(unreachable, remote) + continue + } + } + reachable, err := reachableFromAny(ctx, target, untrackedTips(ctx, tips)) + if err != nil { + // Not knowing is not "pushed": recording a link to a commit the + // remote may not have would be wrong, and so would rewriting one it + // may have. Treat it like a remote that couldn't be reached. + unreachable = append(unreachable, remote) + continue + } + if reachable { + return remote, unreachable + } + } + return "", unreachable +} + +// untrackedTips drops the tips a local remote-tracking ref already points at: +// remoteHoldingCommit has checked those. +func untrackedTips(ctx context.Context, tips []string) []string { + out, err := exec.CommandContext(ctx, "git", "for-each-ref", "--format=%(objectname)", "refs/remotes").Output() + if err != nil { + return tips + } + tracked := strings.Fields(string(out)) + return slices.DeleteFunc(slices.Clone(tips), func(tip string) bool { return slices.Contains(tracked, tip) }) +} + +// reachableFromAny reports whether target is an ancestor of (or is) any of +// tips, in one walk: the commits reachable from tips but not from target's +// parents include target exactly when one of tips reaches it, and the walk +// stops at history older than target. A walk that fails or times out returns +// an error: the answer is unknown. +func reachableFromAny(ctx context.Context, target *object.Commit, tips []string) (bool, error) { + if len(tips) == 0 { + return false, nil + } + var in strings.Builder + for _, tip := range tips { + in.WriteString(tip + "\n") + } + for _, p := range target.ParentHashes { + in.WriteString("^" + p.String() + "\n") + } + walkCtx, cancel := context.WithTimeout(ctx, attachFetchTimeout) + defer cancel() + cmd := exec.CommandContext(walkCtx, "git", "rev-list", "--stdin") + cmd.Stdin = strings.NewReader(in.String()) + out, err := cmd.Output() + if err != nil { + return false, fmt.Errorf("walk history from the remote's branches: %w", err) + } + return slices.Contains(strings.Fields(string(out)), target.Hash.String()), nil +} + +// branchesWithMissingTips returns the branches whose tip commit isn't in the +// local object store. After a default refresh that is usually none, so nothing +// is fetched twice. +func branchesWithMissingTips(ctx context.Context, branches, tips []string) []string { + if len(tips) == 0 { + return nil + } + cmd := exec.CommandContext(ctx, "git", "cat-file", "--batch-check=%(objectname)") + cmd.Stdin = strings.NewReader(strings.Join(tips, "\n") + "\n") + out, err := cmd.Output() + if err != nil { + return branches + } + var missing []string + for i, line := range strings.Split(strings.TrimSpace(string(out)), "\n") { + if i < len(branches) && strings.HasSuffix(line, " missing") { + missing = append(missing, branches[i]) + } + } + return missing +} + +// remoteBranchTips lists remote's branches and the commit at each tip. +func remoteBranchTips(ctx context.Context, remote string) (branches, tips []string, err error) { + lsCtx, cancel := context.WithTimeout(ctx, attachFetchTimeout) + defer cancel() + out, err := attachGitCommand(lsCtx, "ls-remote", "--heads", "--", remote).Output() + if err != nil { + return nil, nil, fmt.Errorf("ls-remote %s: %w", remote, err) + } + for _, line := range strings.Split(string(out), "\n") { + if fields := strings.Fields(line); len(fields) == 2 && isRemoteBranchName(fields[1]) { + tips = append(tips, fields[0]) + branches = append(branches, fields[1]) + } + } + return branches, tips, nil +} + +// isRemoteBranchName reports whether name, as a remote advertised it, is a +// well-formed branch ref that is safe to fetch by name. The names become +// fetch refspecs, so a hostile remote's "refs/heads/a:refs/heads/main" would +// otherwise write a local ref; ":" and the other refspec characters are +// invalid in ref names, which Validate refuses. +func isRemoteBranchName(name string) bool { + return strings.HasPrefix(name, "refs/heads/") && plumbing.ReferenceName(name).Validate() == nil +} + +// remoteHoldingCommit returns a remote whose tracking branches contain target +// (holder), and the remote to push its checkpoint to; both are "" when none +// does. Any remote's tracking ref counts as proof the commit is shared — say +// upstream in a fork, where the fork hasn't caught up — so it is never +// rewritten. The checkpoint still goes to one of remotes, the branch's own, +// rather than to an unrelated remote that happens to hold the commit; the +// holder is preferred when it is one of them. +func remoteHoldingCommit(ctx context.Context, target *object.Commit, remotes []string) (holder, pushTo string, err error) { + out, err := exec.CommandContext(ctx, "git", "branch", "-r", "--contains", target.Hash.String(), "--format=%(refname)").Output() + if err != nil { + return "", "", fmt.Errorf("failed to check which remote branches contain %s: %w", target.Hash.String()[:12], err) + } + all := attachRemotes(ctx) + owners := map[string]bool{} + for _, ref := range strings.Fields(string(out)) { + if owner := trackingRefRemote(ref, all); owner != "" { + owners[owner] = true + } + } + holds := func(remote string) bool { return owners[remote] } + for _, remote := range remotes { + if holds(remote) { + return remote, remote, nil + } + } + for _, remote := range all { + if holds(remote) { + pushTo = remote + if len(remotes) > 0 { + pushTo = remotes[0] + } + return remote, pushTo, nil + } + } + return "", "", nil +} + +// trackingRefRemote returns which of remotes a remote-tracking ref belongs to: +// the longest name it starts with, so with remotes "a" and "a/b", +// refs/remotes/a/b/main is a/b's main, not a's b/main. "" when none. +func trackingRefRemote(ref string, remotes []string) string { + owner := "" + for _, remote := range remotes { + if strings.HasPrefix(ref, "refs/remotes/"+remote+"/") && len(remote) > len(owner) { + owner = remote + } + } + return owner +} + +// reportLinkedCommit tells the user the commit was linked in the checkpoint, +// warns when the link won't count because they didn't author the commit, and +// pushes the checkpoint. +func reportLinkedCommit(ctx context.Context, w, errW io.Writer, plan attachLinkPlan, checkpointID id.CheckpointID) error { + fmt.Fprintf(w, " Linked to commit %s in the checkpoint; the commit is unchanged (already pushed)\n", plan.target.Hash.String()[:12]) + // Finding 4 (rebase): a recorded link names this exact commit and does not + // follow it through a later rebase or amend, unlike a trailer. + fmt.Fprintf(errW, "Note: this link names commit %s. If the commit is later rebased or amended, attach the session to the new commit.\n", plan.target.Hash.String()[:12]) + if author, err := GetGitAuthor(ctx); err == nil && !strings.EqualFold(author.Email, plan.target.Author.Email) { + fmt.Fprintf(errW, "Note: commit %s was authored by %s. A link recorded in the checkpoint counts only when the commit's author attaches it.\n", + plan.target.Hash.String()[:12], plan.target.Author.Email) + } + return pushAttachedCheckpoint(ctx, w, plan.remote, checkpointID) +} + +// attachLinkRepo names the code repository for a link as +// //, from remote. Empty when it cannot be resolved; the +// server then finds the repository from the commit alone. +func attachLinkRepo(ctx context.Context, remote string) string { + if remote == "" { + return "" + } + forge, owner, repo, err := gitremote.ResolveRemoteRepo(ctx, remote) + if err != nil || forge == "" || owner == "" || repo == "" { + return "" + } + return forge + "/" + owner + "/" + repo +} + +// pushAttachedCheckpoint pushes the checkpoint metadata now and confirms it +// arrived. For a pushed commit the checkpoint is the only record of the link, +// and no later git push of that commit will carry it, so an undelivered push is +// an error rather than a note. Uses the pre-push path, which honors +// push_sessions, checkpoint_remote and the privacy filter; that path is +// fail-soft, so delivery is checked against the remote afterwards. +func pushAttachedCheckpoint(ctx context.Context, w io.Writer, remote string, checkpointID id.CheckpointID) error { + target, disabled := strategy.CheckpointPushTarget(ctx, remote) + if disabled { + return fmt.Errorf("checkpoint %s was written locally but not pushed: push_sessions is turned off in settings, so the link stays invisible to others until the checkpoint is pushed", checkpointID) + } + if err := strategy.NewManualCommitStrategy().PrePush(ctx, remote); err != nil { + return fmt.Errorf("checkpoint %s was written locally but could not be pushed to %s: %w", checkpointID, remote, err) + } + if err := confirmCheckpointDelivered(ctx, target, checkpointID); err != nil { + return fmt.Errorf("checkpoint %s was written locally but did not reach %s: %w", checkpointID, remote, err) + } + fmt.Fprintf(w, " Pushed checkpoint metadata to %s\n", remote) + return nil +} + +// confirmCheckpointDelivered checks that the ref holding checkpointID points at +// the same commit on target as locally. +func confirmCheckpointDelivered(ctx context.Context, target string, checkpointID id.CheckpointID) error { + cfg, err := settings.LoadCheckpointsConfig(ctx) + if err != nil { + return fmt.Errorf("resolve checkpoints config: %w", err) + } + // The checkpoint lives in the first of its storage refs that exists here: + // a hex checkpoint on the git-refs primary may be on its own ref or on the + // metadata branch. + var ref string + var local []byte + for _, candidate := range checkpointStorageRefsFor(cfg, checkpointID) { + if !isCheckpointRef(candidate) { + candidate = "refs/heads/" + candidate + } + if out, revErr := exec.CommandContext(ctx, "git", "rev-parse", "--verify", "--quiet", candidate).Output(); revErr == nil { + ref, local = candidate, out + break + } + } + if ref == "" { + return fmt.Errorf("checkpoint %s is in none of its storage refs locally", checkpointID) + } + out, err := remote.LsRemoteInDir(ctx, "", target, ref) + if err != nil { + return err //nolint:wrapcheck // already names ls-remote and the target + } + remoteHash := "" + if fields := strings.Fields(string(out)); len(fields) > 0 { + remoteHash = fields[0] + } + if remoteHash != strings.TrimSpace(string(local)) { + return fmt.Errorf("the remote's %s is not the checkpoint just written (push skipped or rejected)", ref) + } return nil } -// amendOrPrintTrailer amends HEAD with the checkpoint trailer (best-effort). -// If the amend fails, it logs the full error, prints a brief reason to stderr -// so the user knows the amend was attempted, and falls back to printing the -// trailer for manual paste. The recovery path is non-fatal: attach still -// succeeds. -func amendOrPrintTrailer(logCtx context.Context, w, errW io.Writer, headCommit *object.Commit, checkpointIDStr string, force bool) { - if err := promptAmendCommit(logCtx, w, headCommit, checkpointIDStr, force); err != nil { - logging.Warn(logCtx, "failed to amend commit", "error", err) - // promptAmendCommit wraps the full multi-line `git commit --amend` - // output into the error; keep the stderr note to the first line so it - // stays brief. The full error is preserved in the debug log above. - fmt.Fprintf(errW, "Could not amend the commit automatically (%s).\n", firstLine(err.Error())) - fmt.Fprintf(w, "\nCopy to your commit message to attach:\n\n Entire-Checkpoint: %s\n", checkpointIDStr) +// resolveAttachCommit resolves --commit to a commit in this repository, +// refusing an ambiguous short hash rather than picking one. +func resolveAttachCommit(repo *git.Repository, rev string) (*object.Commit, error) { + hash, matches, err := resolveCommitUnambiguous(repo, rev) + if errors.Is(err, errAmbiguousCommitPrefix) { + return nil, fmt.Errorf("--commit %q matches %d commits; use a longer hash", rev, len(matches)) + } + if err != nil { + return nil, fmt.Errorf("--commit %q does not name a commit in this repository: %w", rev, err) + } + commit, err := repo.CommitObject(hash) + if err != nil { + return nil, fmt.Errorf("--commit %q does not name a commit in this repository: %w", rev, err) } + return commit, nil } // warnEmptyTranscriptMetadata warns (without failing) when nothing parsed out @@ -501,7 +1160,7 @@ func getHeadCommit(repo *git.Repository) (*object.Commit, error) { return commit, nil } -// ensureCheckpointAvailable makes sure the checkpoint referenced by HEAD is +// ensureCheckpointAvailable makes sure the checkpoint referenced by the target commit is // present locally before the attach writes to it. Without this guard, attach // would create a fresh session 0 under the same ID and overwrite the original // session data on push. @@ -554,7 +1213,7 @@ func ensureCheckpointAvailable(ctx, logCtx context.Context, repo *git.Repository return repo, missingCheckpointError(logCtx, checkpointID) } -// refreshCheckpoint fetches the checkpoint referenced by HEAD from the remote and +// refreshCheckpoint fetches the checkpoint referenced by the target commit from the remote and // returns a freshly-opened repo so go-git sees the newly-fetched refs/packfiles. // The fetch follows where the checkpoint is stored: a ref-stored checkpoint // fetches just its ref, while a branch-stored one fetches the whole v1 metadata @@ -607,12 +1266,12 @@ func checkpointPresentLocally(ctx context.Context, repo *git.Repository, refs cp return summary != nil, nil } -// missingCheckpointError builds the refuse error shown when a HEAD-referenced +// missingCheckpointError builds the refuse error shown when a commit-referenced // checkpoint is still absent locally after a refresh attempt. The storage it // names and the fetch commands it suggests follow checkpointStorageRefs. func missingCheckpointError(ctx context.Context, checkpointID id.CheckpointID) error { return 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", + "checkpoint %s referenced by the commit 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(), describeCheckpointStorage(checkpointStorageRefs(ctx, checkpointID), "and"), strings.Join(suggestCheckpointStorageFetchCommands(ctx, checkpointID), "\n "), @@ -706,7 +1365,7 @@ func resolveCheckpointID(ctx context.Context, headCommit *object.Commit) (id.Che // If existingState is non-nil, it is updated in place (avoids a redundant disk load). // reviewSkills is the resolved skills list when opts.Review is true; ignored otherwise. // agentHome replaces State.AgentHome; "" clears it. -func saveAttachSessionState(ctx context.Context, repo *git.Repository, existingState *session.State, sessionID string, agentType types.AgentType, transcriptPath, agentHome string, checkpointID id.CheckpointID, meta transcriptMetadata, tokenUsage *agent.TokenUsage, opts attachOptions, reviewSkills []string) error { +func saveAttachSessionState(ctx context.Context, repo *git.Repository, existingState *session.State, sessionID string, agentType types.AgentType, transcriptPath, agentHome string, checkpointID id.CheckpointID, meta transcriptMetadata, tokenUsage *agent.TokenUsage, transcriptEnd int, opts attachOptions, reviewSkills []string, seedBase bool) error { stateStore, err := session.NewStateStore(ctx) if err != nil { return fmt.Errorf("failed to open session store: %w", err) @@ -714,6 +1373,11 @@ func saveAttachSessionState(ctx context.Context, repo *git.Repository, existingS now := time.Now() state := existingState + // A trailer rewrite remaps stored state to the new commits; start from + // that rather than the copy loaded before it. + if fresh, loadErr := stateStore.Load(ctx, sessionID); loadErr == nil && fresh != nil { + state = fresh + } if state == nil { state = &session.State{ SessionID: sessionID, @@ -723,7 +1387,7 @@ func saveAttachSessionState(ctx context.Context, repo *git.Repository, existingS // Populate BaseCommit from HEAD if not already set, so the session becomes // active and future commits in the same session receive Entire-Checkpoint trailers. - if state.BaseCommit == "" { + if seedBase && state.BaseCommit == "" { if head, headErr := repo.Head(); headErr == nil { state.BaseCommit = head.Hash().String() } @@ -737,8 +1401,14 @@ func saveAttachSessionState(ctx context.Context, repo *git.Repository, existingS state.LastCheckpointID = checkpointID // Only transition to Ended if the session is not already active — avoid // breaking an ongoing session whose BaseCommit has just been restored above. + // An ended session's next attach starts after these turns. A running + // session's offset belongs to its hooks: moving it would take turns from + // the checkpoint its next commit makes. if !state.Phase.IsActive() { state.Phase = session.PhaseEnded + if transcriptEnd >= 0 { + state.CheckpointTranscriptStart = transcriptEnd + } } state.LastInteractionTime = &now if meta.TurnCount > 0 { @@ -1045,56 +1715,3 @@ func detectAgentByTranscript(ctx context.Context, sessionID string, skip types.A } return nil, "", errors.New("transcript not found for any registered agent") } - -// promptAmendCommit shows the last commit and asks whether to amend it with the checkpoint trailer. -// When force is true, it amends without prompting. -func promptAmendCommit(ctx context.Context, w io.Writer, headCommit *object.Commit, checkpointIDStr string, force bool) error { - shortHash := headCommit.Hash.String()[:7] - subject := strings.SplitN(headCommit.Message, "\n", 2)[0] - - // Skip amending if this exact checkpoint ID is already in the commit. - for _, existing := range trailers.ParseAllCheckpoints(headCommit.Message) { - if existing.String() == checkpointIDStr { - fmt.Fprintf(w, "Commit %s already has Entire-Checkpoint: %s\n", shortHash, checkpointIDStr) - return nil - } - } - - fmt.Fprintf(w, "\nLast commit: %s %s\n", shortHash, subject) - - amend := true - if !force { - if !interactive.CanPromptInteractively() { - // Non-interactive: can't prompt, print trailer for manual use. - fmt.Fprintf(w, "\nCopy to your commit message to attach:\n\n Entire-Checkpoint: %s\n", checkpointIDStr) - return nil - } - form := NewAccessibleForm( - huh.NewGroup( - huh.NewConfirm(). - Title("Amend the last commit in this branch?"). - Affirmative("Y"). - Negative("n"). - Value(&amend), - ), - ) - if err := form.Run(); err != nil { - return fmt.Errorf("prompt failed: %w", err) - } - } - - if !amend { - fmt.Fprintf(w, "\nCopy to your commit message to attach:\n\n Entire-Checkpoint: %s\n", checkpointIDStr) - return nil - } - - newMessage := trailers.AppendCheckpointTrailer(headCommit.Message, checkpointIDStr) - - cmd := exec.CommandContext(ctx, "git", "commit", "--amend", "--only", "-m", newMessage) - if output, err := cmd.CombinedOutput(); err != nil { - return fmt.Errorf("failed to amend commit: %w\n%s", err, output) - } - - fmt.Fprintf(w, "Amended commit %s with Entire-Checkpoint: %s\n", shortHash, checkpointIDStr) - return nil -} diff --git a/cmd/entire/cli/attach_commit_test.go b/cmd/entire/cli/attach_commit_test.go new file mode 100644 index 0000000000..0b7f3c1f92 --- /dev/null +++ b/cmd/entire/cli/attach_commit_test.go @@ -0,0 +1,1022 @@ +package cli + +import ( + "bytes" + "context" + "io" + "os" + "path/filepath" + "strings" + "testing" + "time" + + "github.com/entireio/cli/cmd/entire/cli/agent" + cpkg "github.com/entireio/cli/cmd/entire/cli/checkpoint" + checkpointid "github.com/entireio/cli/cmd/entire/cli/checkpoint/id" + "github.com/entireio/cli/cmd/entire/cli/interactive" + "github.com/entireio/cli/cmd/entire/cli/session" + "github.com/entireio/cli/cmd/entire/cli/testutil" + "github.com/entireio/cli/cmd/entire/cli/trailers" + + "github.com/go-git/go-git/v6" + "github.com/go-git/go-git/v6/plumbing" + "github.com/go-git/go-git/v6/plumbing/object" +) + +const attachCommitTranscript = `{"type":"user","message":{"role":"user","content":"make the change"},"uuid":"u1"} +` + +// commitAt adds a commit on top of HEAD in the current repo and returns it. +func commitAt(t *testing.T, name string) *object.Commit { + t.Helper() + dir := mustGetwd(t) + testutil.WriteFile(t, dir, name, name) + testutil.GitAdd(t, dir, name) + testutil.GitCommit(t, dir, "add "+name) + return headCommitOf(t) +} + +func headCommitOf(t *testing.T) *object.Commit { + t.Helper() + repo, err := git.PlainOpen(mustGetwd(t)) + if err != nil { + t.Fatal(err) + } + ref, err := repo.Head() + if err != nil { + t.Fatal(err) + } + c, err := repo.CommitObject(ref.Hash()) + if err != nil { + t.Fatal(err) + } + return c +} + +func commitByHash(t *testing.T, hash plumbing.Hash) *object.Commit { + t.Helper() + repo, err := git.PlainOpen(mustGetwd(t)) + if err != nil { + t.Fatal(err) + } + c, err := repo.CommitObject(hash) + if err != nil { + t.Fatal(err) + } + return c +} + +func loadAttachState(t *testing.T, sessionID string) (*session.State, error) { + t.Helper() + store, err := session.NewStateStore(context.Background()) + if err != nil { + return nil, err + } + return store.Load(context.Background(), sessionID) +} + +func readSummary(t *testing.T, cpID string) *cpkg.CheckpointSummary { + t.Helper() + repo, err := git.PlainOpen(mustGetwd(t)) + if err != nil { + t.Fatal(err) + } + parsed, ok := trailers.ParseCheckpoint("Entire-Checkpoint: " + cpID) + if !ok { + t.Fatalf("bad checkpoint id %q", cpID) + } + summary, err := cpkg.NewGitStore(repo, cpkg.DefaultV1Refs()).Read(context.Background(), parsed) + if err != nil || summary == nil { + t.Fatalf("Read(%s) = %v, %v", cpID, summary, err) + } + return summary +} + +// pushToOrigin adds a bare "origin" and publishes HEAD to it, so remote +// branches contain every commit made so far. +func pushToOrigin(t *testing.T) string { + t.Helper() + dir := mustGetwd(t) + remote := t.TempDir() + testutil.RunGit(t, remote, "init", "--bare", "-q") + testutil.RunGit(t, dir, "remote", "add", "origin", remote) + testutil.RunGit(t, dir, "push", "-q", "origin", "HEAD:refs/heads/main") + testutil.RunGit(t, dir, "fetch", "-q", "origin") + return remote +} + +func attachHeadless(t *testing.T, sessionID string, opts attachOptions) (string, error) { + t.Helper() + setupClaudeTranscript(t, sessionID, attachCommitTranscript) + var out bytes.Buffer + opts.Force = true + err := runAttach(context.Background(), &out, &out, sessionID, agent.AgentNameClaudeCode, opts) + return out.String(), err +} + +// The common attach — hooks missed the commit just made — targets an unpushed +// HEAD. A trailer is the right link there: nobody else has the commit, and the +// trailer survives a later rebase. It is amended in without a prompt, so a +// headless attach no longer leaves the checkpoint unlinked. +func TestAttachCommit_UnpushedHeadIsAmended(t *testing.T) { + setupAttachTestRepo(t) + commitAt(t, "work.txt") + + out, err := attachHeadless(t, "attach-unpushed-head", attachOptions{}) + if err != nil { + t.Fatalf("runAttach: %v\n%s", err, out) + } + head := headCommitOf(t) + cpID, ok := trailers.ParseCheckpoint(head.Message) + if !ok { + t.Fatalf("unpushed HEAD was not amended with a trailer:\n%s", out) + } + if summary := readSummary(t, cpID.String()); len(summary.LinkedCommits) != 0 { + t.Errorf("LinkedCommits = %v: a trailer-linked checkpoint needs no anchor", summary.LinkedCommits) + } +} + +// A pushed HEAD can't be amended without a force-push, so the link is recorded +// in the checkpoint and the commit is left alone. +func TestAttachCommit_PushedHeadIsLinkedInTheCheckpoint(t *testing.T) { + setupAttachTestRepo(t) + head := commitAt(t, "work.txt") + remote := pushToOrigin(t) + + out, err := attachHeadless(t, "attach-pushed-head", attachOptions{}) + if err != nil { + t.Fatalf("runAttach: %v\n%s", err, out) + } + if got := headCommitOf(t); got.Hash != head.Hash || strings.Contains(got.Message, "Entire-Checkpoint") { + t.Fatalf("pushed HEAD was rewritten: %s %q", got.Hash, got.Message) + } + state, err := loadAttachState(t, "attach-pushed-head") + if err != nil || state == nil { + t.Fatalf("load state: %v, %v", state, err) + } + if summary := readSummary(t, state.LastCheckpointID.String()); len(summary.LinkedCommits) != 1 || summary.LinkedCommits[0].SHA != head.Hash.String() { + t.Fatalf("LinkedCommits = %v, want [%s]", summary.LinkedCommits, head.Hash) + } + if state.BaseCommit != head.Hash.String() { + t.Errorf("BaseCommit = %q, want HEAD so a still-running session keeps linking", state.BaseCommit) + } + if !strings.Contains(out, "Pushed checkpoint metadata to origin") { + t.Errorf("expected the checkpoint to be pushed, got:\n%s", out) + } + if refs := testutil.RunGit(t, remote, "for-each-ref", "--format=%(refname)"); !strings.Contains(refs, "entire/checkpoints") { + t.Errorf("remote has no checkpoint refs:\n%s", refs) + } +} + +// Under the git-refs primary the pushed-commit path pushes through the +// pre-push queue, and delivery is confirmed against the checkpoint's own ref. +// Not parallel: sets ENTIRE_CHECKPOINTS_PRIMARY. +func TestAttachCommit_PushedHeadIsDeliveredOnGitRefs(t *testing.T) { + t.Setenv("ENTIRE_CHECKPOINTS_PRIMARY", "git-refs") + setupAttachTestRepo(t) + head := commitAt(t, "work.txt") + remote := pushToOrigin(t) + + out, err := attachHeadless(t, "attach-pushed-head-refs", attachOptions{}) + if err != nil { + t.Fatalf("runAttach: %v\n%s", err, out) + } + if got := headCommitOf(t); got.Hash != head.Hash { + t.Fatalf("pushed HEAD was rewritten: %s", got.Hash) + } + state, err := loadAttachState(t, "attach-pushed-head-refs") + if err != nil || state == nil { + t.Fatalf("load state: %v, %v", state, err) + } + if !strings.Contains(out, "Pushed checkpoint metadata to origin") { + t.Fatalf("expected a confirmed push, got:\n%s", out) + } + cpID := state.LastCheckpointID.String() + local := strings.TrimSpace(testutil.RunGit(t, mustGetwd(t), "for-each-ref", "--format=%(refname) %(objectname)", "refs/entire")) + remoteRefs := testutil.RunGit(t, remote, "for-each-ref", "--format=%(refname) %(objectname)", "refs/entire") + found := false + for _, line := range strings.Split(local, "\n") { + if strings.Contains(line, cpID) { + found = true + if !strings.Contains(remoteRefs, line) { + t.Fatalf("checkpoint ref %q is not on the remote:\n%s", line, remoteRefs) + } + } + } + if !found { + t.Fatalf("no local ref names checkpoint %s:\n%s", cpID, local) + } +} + +// An older pushed commit is linked in the checkpoint, never rewritten. +func TestAttachCommit_PushedOlderCommitIsLinkedInTheCheckpoint(t *testing.T) { + setupAttachTestRepo(t) + target := commitAt(t, "work.txt") + head := commitAt(t, "later.txt") + pushToOrigin(t) + + out, err := attachHeadless(t, "attach-pushed-older", attachOptions{Commit: target.Hash.String()[:10]}) + if err != nil { + t.Fatalf("runAttach: %v\n%s", err, out) + } + if got := headCommitOf(t).Hash; got != head.Hash { + t.Fatalf("HEAD moved from %s to %s", head.Hash, got) + } + if msg := commitByHash(t, target.Hash).Message; strings.Contains(msg, "Entire-Checkpoint") { + t.Fatalf("target commit gained a trailer: %q", msg) + } + state, err := loadAttachState(t, "attach-pushed-older") + if err != nil || state == nil { + t.Fatalf("load state: %v, %v", state, err) + } + summary := readSummary(t, state.LastCheckpointID.String()) + if len(summary.LinkedCommits) != 1 || summary.LinkedCommits[0].SHA != target.Hash.String() { + t.Fatalf("LinkedCommits = %v, want [%s]", summary.LinkedCommits, target.Hash) + } + if state.BaseCommit != "" { + t.Errorf("BaseCommit = %q: an older commit must not make the session link future HEAD commits", state.BaseCommit) + } + if !strings.Contains(out, target.Hash.String()[:7]) { + t.Errorf("output should name the linked commit, got:\n%s", out) + } +} + +// An older unpushed commit is treated like HEAD: it gets the trailer, and the +// commits after it are replayed on top with the same trees, so the worktree is +// untouched and only SHAs change. +func TestAttachCommit_UnpushedOlderCommitGetsTheTrailer(t *testing.T) { + setupAttachTestRepo(t) + target := commitAt(t, "work.txt") + // A committer other than whoever runs attach, which the rewrite must keep. + dir := mustGetwd(t) + testutil.WriteFile(t, dir, "later.txt", "later.txt") + testutil.GitAdd(t, dir, "later.txt") + testutil.RunGit(t, dir, "-c", "user.name=Original Committer", "-c", "user.email=original@example.com", "commit", "-q", "-m", "add later.txt") + later := headCommitOf(t) + out, err := attachHeadless(t, "attach-unpushed-older", attachOptions{Commit: target.Hash.String()}) + if err != nil { + t.Fatalf("runAttach: %v\n%s", err, out) + } + head := headCommitOf(t) + if head.Hash == later.Hash || head.TreeHash != later.TreeHash || head.Message != later.Message { + t.Fatalf("HEAD should be the replayed later commit with the same tree and message: %s %q", head.Hash, head.Message) + } + if head.Author.String() != later.Author.String() || head.Committer.String() != later.Committer.String() || !head.Committer.When.Equal(later.Committer.When) { + t.Fatalf("replay changed author or committer: %v / %v, want %v / %v", head.Author, head.Committer, later.Author, later.Committer) + } + parent, err := head.Parent(0) + if err != nil { + t.Fatal(err) + } + cpID, ok := trailers.ParseCheckpoint(parent.Message) + if !ok || parent.TreeHash != target.TreeHash { + t.Fatalf("the target should be rewritten with the trailer and its tree: %q", parent.Message) + } + if _, ok := trailers.ParseCheckpoint(head.Message); ok { + t.Errorf("only the target should get the trailer: %q", head.Message) + } + if summary := readSummary(t, cpID.String()); len(summary.LinkedCommits) != 0 { + t.Errorf("LinkedCommits = %v: a trailer-linked checkpoint needs no anchor", summary.LinkedCommits) + } +} + +// A merge after the target can't be replayed by a message-only rewrite, so it +// is refused before anything is written. +func TestAttachCommit_RefusesToRewriteAcrossAMerge(t *testing.T) { + setupAttachTestRepo(t) + dir := mustGetwd(t) + target := commitAt(t, "work.txt") + testutil.RunGit(t, dir, "checkout", "-q", "-b", "side") + commitAt(t, "side.txt") + testutil.RunGit(t, dir, "checkout", "-q", "-") + commitAt(t, "main.txt") + testutil.RunGit(t, dir, "merge", "-q", "--no-edit", "side") + head := headCommitOf(t) + + out, err := attachHeadless(t, "attach-across-merge", attachOptions{Commit: target.Hash.String()}) + if err == nil || !strings.Contains(err.Error(), "is a merge") { + t.Fatalf("err = %v, want a refusal naming the merge\n%s", err, out) + } + if got := headCommitOf(t); got.Hash != head.Hash { + t.Fatalf("HEAD changed: %s", got.Hash) + } +} + +// A commit that already carries a checkpoint is already linked: the session +// joins that checkpoint and nothing else is written. +func TestAttachCommit_JoinsTheCommitsExistingCheckpoint(t *testing.T) { + setupAttachTestRepo(t) + if out, err := attachHeadless(t, "attach-join-first", attachOptions{}); err != nil { + t.Fatalf("first attach: %v\n%s", err, out) + } + target := headCommitOf(t) + cpID, ok := trailers.ParseCheckpoint(target.Message) + if !ok { + t.Fatalf("first attach left no trailer: %q", target.Message) + } + commitAt(t, "later.txt") + + if out, err := attachHeadless(t, "attach-join-second", attachOptions{Commit: target.Hash.String()}); err != nil { + t.Fatalf("second attach: %v\n%s", err, out) + } + summary := readSummary(t, cpID.String()) + if len(summary.Sessions) != 2 { + t.Fatalf("checkpoint has %d sessions, want 2", len(summary.Sessions)) + } + if len(summary.LinkedCommits) != 0 { + t.Errorf("LinkedCommits = %v: a trailer-linked checkpoint needs no anchor", summary.LinkedCommits) + } +} + +// A link recorded in the checkpoint only counts when the commit's author +// attaches it; say so up front rather than let it fail quietly on the server. +func TestAttachCommit_WarnsWhenNotTheCommitsAuthor(t *testing.T) { + setupAttachTestRepo(t) + dir := mustGetwd(t) + testutil.WriteFile(t, dir, "theirs.txt", "theirs") + testutil.GitAdd(t, dir, "theirs.txt") + testutil.RunGit(t, dir, "-c", "user.name=Someone Else", "-c", "user.email=someone@example.com", "commit", "-q", "-m", "their commit") + pushToOrigin(t) + + out, err := attachHeadless(t, "attach-not-author", attachOptions{}) + if err != nil { + t.Fatalf("runAttach: %v\n%s", err, out) + } + if !strings.Contains(out, "someone@example.com") { + t.Errorf("expected a warning naming the commit's author, got:\n%s", out) + } +} + +func TestAttachCommit_RejectsWhatIsNotACommit(t *testing.T) { + setupAttachTestRepo(t) + _, err := attachHeadless(t, "attach-bad-rev", attachOptions{Commit: "no-such-revision"}) + if err == nil || !strings.Contains(err.Error(), "no-such-revision") { + t.Fatalf("err = %v, want an error naming the revision", err) + } +} + +// explain finds a checkpoint linked without a trailer. +func TestAttachCommit_ExplainFindsTheLinkedCheckpoint(t *testing.T) { + setupAttachTestRepo(t) + target := commitAt(t, "work.txt") + commitAt(t, "later.txt") + pushToOrigin(t) + sessionID := "attach-commit-explain" + if out, err := attachHeadless(t, sessionID, attachOptions{Commit: target.Hash.String()}); err != nil { + t.Fatalf("runAttach: %v\n%s", err, out) + } + state, err := loadAttachState(t, sessionID) + if err != nil || state == nil { + t.Fatalf("load state: %v, %v", state, err) + } + + var explainOut, explainErr bytes.Buffer + if err := runExplainAuto(context.Background(), &explainOut, &explainErr, target.Hash.String(), true, false, false, false, false, false, false, 0); err != nil { + t.Fatalf("explain: %v\n%s", err, explainErr.String()) + } + got := explainOut.String() + if strings.Contains(got, "no Entire-Checkpoint trailer") || !strings.Contains(got, state.LastCheckpointID.String()) { + t.Fatalf("explain did not resolve the linked checkpoint %s:\n%s", state.LastCheckpointID, got) + } + // The linked commit has no trailer, but the checkpoint lists it. + if strings.Contains(got, "(none on this branch)") || !strings.Contains(got, target.Hash.String()[:7]) { + t.Errorf("explain should list the linked commit %s:\n%s", target.Hash.String()[:7], got) + } + // A recorded link can't be verified locally, so it is labelled as one. + if !strings.Contains(got, "(recorded link, unverified)") { + t.Errorf("the linked commit should be labelled as an unverified recorded link:\n%s", got) + } + if !strings.Contains(explainErr.String(), "can't verify who recorded the link") { + t.Errorf("explain should note the link is unverified:\n%s", explainErr.String()) + } +} + +func TestCheckpointsLinkedTo(t *testing.T) { + t.Parallel() + a, b := "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb" + infos := []cpkg.CheckpointInfo{ + {CheckpointID: "111111111111", LinkedCommits: []cpkg.LinkedCommit{{SHA: a}}}, + {CheckpointID: "222222222222"}, + {CheckpointID: "333333333333", LinkedCommits: []cpkg.LinkedCommit{{SHA: b}, {SHA: a}}}, + } + got := cpkg.CheckpointsLinkedTo(infos, a) + if len(got) != 2 || got[0] != "111111111111" || got[1] != "333333333333" { + t.Fatalf("CheckpointsLinkedTo(a) = %v", got) + } + if got := cpkg.CheckpointsLinkedTo(infos, "cccccccccccccccccccccccccccccccccccccccc"); len(got) != 0 { + t.Fatalf("CheckpointsLinkedTo(unlinked) = %v", got) + } +} + +type listingAttributionStub struct { + attributionCheckpointReaderStub + + infos []cpkg.CheckpointInfo + listErr error + lists int +} + +func (s *listingAttributionStub) List(context.Context) ([]cpkg.CheckpointInfo, error) { + s.lists++ + return s.infos, s.listErr +} + +// blame/why treat a commit linked by attach --commit like a trailer-linked one, +// listing the store once per run. +func TestAttributionResolver_LinkedCheckpoints(t *testing.T) { + t.Parallel() + sha := "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa" + store := &listingAttributionStub{infos: []cpkg.CheckpointInfo{ + {CheckpointID: "111111111111", LinkedCommits: []cpkg.LinkedCommit{{SHA: sha}}}, + }} + commit := &object.Commit{Hash: plumbing.NewHash(sha)} + r := &attributionResolver{ctx: context.Background(), store: store} + for range 2 { + if got := r.linkedCheckpoints(commit); len(got) != 1 || got[0] != "111111111111" { + t.Fatalf("linkedCheckpoints = %v", got) + } + } + if store.lists != 1 { + t.Fatalf("store listed %d times, want once per run", store.lists) + } + + plain := &attributionResolver{ctx: context.Background(), store: &attributionCheckpointReaderStub{}} + if got := plain.linkedCheckpoints(commit); len(got) != 0 { + t.Fatalf("a store that cannot list links nothing, got %v", got) + } + failing := &attributionResolver{ctx: context.Background(), store: &listingAttributionStub{listErr: context.Canceled}} + if got := failing.linkedCheckpoints(commit); len(got) != 0 { + t.Fatalf("a failed listing links nothing, got %v", got) + } +} + +type countingStubReader struct { + listingAttributionStub + + reads int +} + +func (s *countingStubReader) Read(context.Context, checkpointid.CheckpointID) (*cpkg.CheckpointSummary, error) { + s.reads++ + return &cpkg.CheckpointSummary{}, nil +} + +// Blame reads a remote-discovered stub for its links once per run, not once per +// trailer-less commit. +func TestAttributionResolver_ReadsEachStubOncePerRun(t *testing.T) { + t.Parallel() + now := time.Now() + store := &countingStubReader{listingAttributionStub: listingAttributionStub{infos: []cpkg.CheckpointInfo{ + {CheckpointID: "111111111111", ListedStub: true, CreatedAt: now}, + }}} + r := &attributionResolver{ctx: context.Background(), store: store} + for _, sha := range []string{"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb"} { + r.linkedCheckpoints(&object.Commit{Hash: plumbing.NewHash(sha), Committer: object.Signature{When: now}}) + } + if store.reads != 1 { + t.Fatalf("stub read %d times, want once per run", store.reads) + } +} + +// A pushed commit already linked by an earlier attach is linked: a second +// session joins that checkpoint, like the trailer paths, rather than starting a +// separate checkpoint for the same commit. +func TestAttachCommit_SecondSessionJoinsTheRecordedLinkCheckpoint(t *testing.T) { + setupAttachTestRepo(t) + head := commitAt(t, "work.txt") + pushToOrigin(t) + + if out, err := attachHeadless(t, "attach-recorded-first", attachOptions{}); err != nil { + t.Fatalf("first attach: %v\n%s", err, out) + } + first, err := loadAttachState(t, "attach-recorded-first") + if err != nil || first == nil { + t.Fatalf("load first state: %v, %v", first, err) + } + out, err := attachHeadless(t, "attach-recorded-second", attachOptions{}) + if err != nil { + t.Fatalf("second attach: %v\n%s", err, out) + } + second, err := loadAttachState(t, "attach-recorded-second") + if err != nil || second == nil { + t.Fatalf("load second state: %v, %v", second, err) + } + if second.LastCheckpointID != first.LastCheckpointID { + t.Fatalf("second session got checkpoint %s, want the commit's existing %s", second.LastCheckpointID, first.LastCheckpointID) + } + summary := readSummary(t, first.LastCheckpointID.String()) + if len(summary.Sessions) != 2 { + t.Fatalf("checkpoint has %d sessions, want 2", len(summary.Sessions)) + } + if len(summary.LinkedCommits) != 1 || summary.LinkedCommits[0].SHA != head.Hash.String() { + t.Errorf("LinkedCommits = %v, want just [%s]", summary.LinkedCommits, head.Hash) + } +} + +// A pushed commit whose linked checkpoint isn't in the local store yet (a fresh +// clone, a deleted local metadata ref) is joined, not duplicated: attach finds +// it through the remote-tracking copy, fetches it into the local store through +// the availability guard, and appends — never rebuilding it from scratch. +func TestAttachCommit_JoinsARemoteOnlyLinkedCheckpointAfterFetchingIt(t *testing.T) { + setupAttachTestRepo(t) + commitAt(t, "work.txt") + pushToOrigin(t) + if out, err := attachHeadless(t, "attach-remote-only-first", attachOptions{}); err != nil { + t.Fatalf("first attach: %v\n%s", err, out) + } + first, err := loadAttachState(t, "attach-remote-only-first") + if err != nil || first == nil { + t.Fatalf("load first state: %v, %v", first, err) + } + dir := mustGetwd(t) + testutil.RunGit(t, dir, "update-ref", "-d", "refs/heads/entire/checkpoints/v1") + + if out, err := attachHeadless(t, "attach-remote-only-second", attachOptions{}); err != nil { + t.Fatalf("second attach: %v\n%s", err, out) + } + second, err := loadAttachState(t, "attach-remote-only-second") + if err != nil || second == nil { + t.Fatalf("load second state: %v, %v", second, err) + } + if second.LastCheckpointID != first.LastCheckpointID { + t.Fatalf("second attach started checkpoint %s; want it to join the commit's existing %s", second.LastCheckpointID, first.LastCheckpointID) + } + if summary := readSummary(t, first.LastCheckpointID.String()); len(summary.Sessions) != 2 { + t.Fatalf("checkpoint has %d sessions, want both (the original kept, the new one appended)", len(summary.Sessions)) + } +} + +// Remote-tracking refs can be stale: a commit someone already pushed from +// another clone reads as unpushed here. attach fetches before deciding, so it +// records the link instead of amending a shared commit. +func TestAttachCommit_FetchesBeforeDecidingAHeadIsUnpushed(t *testing.T) { + setupAttachTestRepo(t) + head := commitAt(t, "work.txt") + dir := mustGetwd(t) + remote := t.TempDir() + testutil.RunGit(t, remote, "init", "--bare", "-q") + testutil.RunGit(t, dir, "remote", "add", "origin", remote) + // Pushed by URL, as another clone would: this repo's origin/* refs don't move. + testutil.RunGit(t, dir, "push", "-q", remote, "HEAD:refs/heads/main") + if refs := testutil.RunGit(t, dir, "branch", "-r"); strings.TrimSpace(refs) != "" { + t.Fatalf("expected no remote-tracking refs before attach, got %q", refs) + } + + out, err := attachHeadless(t, "attach-stale-tracking", attachOptions{}) + if err != nil { + t.Fatalf("runAttach: %v\n%s", err, out) + } + if got := headCommitOf(t); got.Hash != head.Hash || strings.Contains(got.Message, "Entire-Checkpoint") { + t.Fatalf("a commit the remote already holds was amended: %s %q\n%s", got.Hash, got.Message, out) + } +} + +// Finding 1: a failed fetch is not evidence the commit is unpushed. attach +// refuses to amend rather than rewrite a commit it couldn't check. +func TestAttachCommit_RefusesToAmendWhenAFetchFails(t *testing.T) { + setupAttachTestRepo(t) + head := commitAt(t, "work.txt") + dir := mustGetwd(t) + testutil.RunGit(t, dir, "remote", "add", "origin", filepath.Join(t.TempDir(), "missing.git")) + + out, err := attachHeadless(t, "attach-fetch-fails", attachOptions{}) + if err == nil || !strings.Contains(err.Error(), "couldn't confirm") { + t.Fatalf("err = %v, want a refusal explaining the commit's push state couldn't be confirmed\n%s", err, out) + } + if got := headCommitOf(t); got.Hash != head.Hash { + t.Fatalf("HEAD was rewritten after a failed fetch: %s", got.Hash) + } +} + +// A session that already has a checkpoint can be attached to another commit: +// it goes into that commit's checkpoint, which records only the turns since +// its previous one. +func TestAttachCommit_SessionCanBeAttachedToASecondCommit(t *testing.T) { + setupAttachTestRepo(t) + const sessionID = "attach-second-commit" + if out, err := attachHeadless(t, sessionID, attachOptions{}); err != nil { + t.Fatalf("first attach: %v\n%s", err, out) + } + first, ok := trailers.ParseCheckpoint(headCommitOf(t).Message) + if !ok { + t.Fatal("first attach left no trailer") + } + transcript := attachCommitTranscript + `{"type":"user","message":{"role":"user","content":"one more change"},"uuid":"u2"} +` + setupClaudeTranscript(t, sessionID, transcript) + commitAt(t, "second.txt") + + var out bytes.Buffer + if err := runAttach(context.Background(), &out, &out, sessionID, agent.AgentNameClaudeCode, attachOptions{Force: true}); err != nil { + t.Fatalf("second attach: %v\n%s", err, out.String()) + } + second, ok := trailers.ParseCheckpoint(headCommitOf(t).Message) + if !ok || second == first { + t.Fatalf("second commit should get its own checkpoint, got %q (first %s)", headCommitOf(t).Message, first) + } + if !strings.Contains(out.String(), "1 turn") { + t.Errorf("second checkpoint should record only the new turn:\n%s", out.String()) + } +} + +// A pushed HEAD that already carries a checkpoint is joined, never rewritten. +func TestAttachCommit_PushedHeadWithACheckpointIsJoined(t *testing.T) { + setupAttachTestRepo(t) + if out, err := attachHeadless(t, "attach-pushed-join-a", attachOptions{}); err != nil { + t.Fatalf("first attach: %v\n%s", err, out) + } + head := headCommitOf(t) + cpID, _ := trailers.ParseCheckpoint(head.Message) + pushToOrigin(t) + + out, err := attachHeadless(t, "attach-pushed-join-b", attachOptions{}) + if err != nil { + t.Fatalf("second attach: %v\n%s", err, out) + } + if got := headCommitOf(t); got.Hash != head.Hash { + t.Fatalf("pushed HEAD was rewritten: %s", got.Hash) + } + if summary := readSummary(t, cpID.String()); len(summary.Sessions) != 2 { + t.Fatalf("checkpoint has %d sessions, want 2", len(summary.Sessions)) + } + // The commit is pushed, so no later push of it may carry the checkpoint. + if !strings.Contains(out, "Pushed checkpoint metadata to origin") { + t.Fatalf("joined checkpoint of a pushed commit was not pushed:\n%s", out) + } +} + +// Joining the checkpoint of an unpushed commit leaves it for the next git push. +func TestAttachCommit_UnpushedHeadWithACheckpointWaitsForPush(t *testing.T) { + setupAttachTestRepo(t) + if out, err := attachHeadless(t, "attach-unpushed-join-a", attachOptions{}); err != nil { + t.Fatalf("first attach: %v\n%s", err, out) + } + out, err := attachHeadless(t, "attach-unpushed-join-b", attachOptions{}) + if err != nil { + t.Fatalf("second attach: %v\n%s", err, out) + } + if strings.Contains(out, "Pushed checkpoint metadata") { + t.Fatalf("pushed the checkpoint of an unpushed commit:\n%s", out) + } + if !strings.Contains(out, "pushed with your next git push") { + t.Fatalf("warning doesn't say when the checkpoint is pushed:\n%s", out) + } +} + +// Only the remotes the branch pushes to are checked, so an unrelated remote +// that can't be reached (an old fork, say) doesn't block amending an unpushed +// HEAD. +func TestAttachCommit_IgnoresAnUnreachableUnrelatedRemote(t *testing.T) { + setupAttachTestRepo(t) + commitAt(t, "work.txt") + pushToOrigin(t) + testutil.RunGit(t, mustGetwd(t), "remote", "add", "oldfork", filepath.Join(t.TempDir(), "missing.git")) + commitAt(t, "more.txt") + + out, err := attachHeadless(t, "attach-unrelated-remote", attachOptions{}) + if err != nil { + t.Fatalf("runAttach: %v\n%s", err, out) + } + if _, ok := trailers.ParseCheckpoint(headCommitOf(t).Message); !ok { + t.Fatalf("unpushed HEAD was not amended:\n%s", out) + } +} + +// Any remote's tracking ref holding the commit means it is shared — upstream +// in a fork, say — so it is never rewritten. The checkpoint still goes to the +// branch's own remote, not to the unrelated one. +func TestAttachCommit_TrackingRefOfAnotherRemoteBlocksTheRewrite(t *testing.T) { + setupAttachTestRepo(t) + commitAt(t, "work.txt") + pushToOrigin(t) + dir := mustGetwd(t) + head := commitAt(t, "more.txt") + testutil.RunGit(t, dir, "remote", "add", "upstream", filepath.Join(t.TempDir(), "missing.git")) + testutil.RunGit(t, dir, "update-ref", "refs/remotes/upstream/main", "HEAD") + + out, err := attachHeadless(t, "attach-upstream-tracking-ref", attachOptions{}) + if err != nil { + t.Fatalf("runAttach: %v\n%s", err, out) + } + if got := headCommitOf(t); got.Hash != head.Hash { + t.Fatalf("a commit upstream holds was rewritten: %s\n%s", got.Hash, out) + } + if !strings.Contains(out, "already pushed to upstream") || !strings.Contains(out, "Pushed checkpoint metadata to origin") { + t.Fatalf("want the link recorded against upstream and pushed to origin:\n%s", out) + } +} + +// commit-tree writes UTF-8 with no extra headers, so a commit it can't +// reproduce is refused rather than silently changed. +// The same holds when the commit is HEAD, the common case. +func TestAttachCommit_RefusesToRewriteANonUTF8Head(t *testing.T) { + setupAttachTestRepo(t) + dir := mustGetwd(t) + testutil.WriteFile(t, dir, "work.txt", "work") + testutil.GitAdd(t, dir, "work.txt") + testutil.RunGit(t, dir, "-c", "i18n.commitEncoding=ISO-8859-1", "commit", "-q", "-m", "add work.txt") + head := headCommitOf(t) + + out, err := attachHeadless(t, "attach-non-utf8-head", attachOptions{}) + if err == nil || !strings.Contains(err.Error(), "non-UTF-8") { + t.Fatalf("err = %v, want a refusal naming the encoding\n%s", err, out) + } + if got := headCommitOf(t); got.Hash != head.Hash { + t.Fatalf("HEAD changed: %s", got.Hash) + } +} + +func TestAttachCommit_RefusesToRewriteANonUTF8Commit(t *testing.T) { + setupAttachTestRepo(t) + dir := mustGetwd(t) + testutil.WriteFile(t, dir, "work.txt", "work") + testutil.GitAdd(t, dir, "work.txt") + testutil.RunGit(t, dir, "-c", "i18n.commitEncoding=ISO-8859-1", "commit", "-q", "-m", "add work.txt") + target := headCommitOf(t) + head := commitAt(t, "later.txt") + + out, err := attachHeadless(t, "attach-non-utf8", attachOptions{Commit: target.Hash.String()}) + if err == nil || !strings.Contains(err.Error(), "non-UTF-8") { + t.Fatalf("err = %v, want a refusal naming the encoding\n%s", err, out) + } + if got := headCommitOf(t); got.Hash != head.Hash { + t.Fatalf("HEAD changed: %s", got.Hash) + } +} + +// Finding 1: a remote's branch tip is checked directly, so a commit pushed to a +// branch this clone's fetch refspec doesn't track still reads as pushed. +func TestAttachCommit_SeesACommitPushedToAnUntrackedBranch(t *testing.T) { + setupAttachTestRepo(t) + head := commitAt(t, "work.txt") + dir := mustGetwd(t) + remote := t.TempDir() + testutil.RunGit(t, remote, "init", "--bare", "-q") + testutil.RunGit(t, dir, "remote", "add", "origin", remote) + testutil.RunGit(t, dir, "config", "remote.origin.fetch", "+refs/heads/main:refs/remotes/origin/main") + testutil.RunGit(t, dir, "push", "-q", remote, "HEAD:refs/heads/someone-elses-branch") + + out, err := attachHeadless(t, "attach-untracked-branch", attachOptions{}) + if err != nil { + t.Fatalf("runAttach: %v\n%s", err, out) + } + if got := headCommitOf(t); got.Hash != head.Hash || strings.Contains(got.Message, "Entire-Checkpoint") { + t.Fatalf("a commit the remote already holds was amended: %s %q\n%s", got.Hash, got.Message, out) + } +} + +// Finding 2: a recorded link only exists in the checkpoint, so attach must not +// report success when the checkpoint never reached the remote. +func TestAttachCommit_FailsWhenTheCheckpointIsNotDelivered(t *testing.T) { + setupAttachTestRepo(t) + commitAt(t, "work.txt") + pushToOrigin(t) + writeAttachTestSettings(t, `{"enabled": true, "strategy_options": {"push_sessions": false}}`) + + out, err := attachHeadless(t, "attach-not-delivered", attachOptions{}) + if err == nil || !strings.Contains(err.Error(), "push_sessions") { + t.Fatalf("err = %v, want an error naming push_sessions as the reason nothing was pushed\n%s", err, out) + } + if strings.Contains(out, "Pushed checkpoint metadata") { + t.Fatalf("reported a push that didn't happen:\n%s", out) + } +} + +// Rerunning attach after a push that didn't land retries it rather than +// reporting the session as already done. +func TestAttachCommit_RerunRetriesAnUndeliveredCheckpoint(t *testing.T) { + setupAttachTestRepo(t) + commitAt(t, "work.txt") + pushToOrigin(t) + writeAttachTestSettings(t, `{"enabled": true, "strategy_options": {"push_sessions": false}}`) + if out, err := attachHeadless(t, "attach-retry-delivery", attachOptions{}); err == nil { + t.Fatalf("first attach should fail to deliver:\n%s", out) + } + + writeAttachTestSettings(t, `{"enabled": true}`) + // Without a terminal or --force the rerun pushes nothing: it sends the + // transcript, so it needs the same go-ahead as any attach. + var unconfirmed bytes.Buffer + if err := runAttach(context.Background(), &unconfirmed, &unconfirmed, "attach-retry-delivery", agent.AgentNameClaudeCode, attachOptions{}); err == nil { + t.Fatalf("rerun without confirmation succeeded:\n%s", unconfirmed.String()) + } + if strings.Contains(unconfirmed.String(), "Pushed checkpoint metadata") { + t.Fatalf("rerun pushed without confirmation:\n%s", unconfirmed.String()) + } + out, err := attachHeadless(t, "attach-retry-delivery", attachOptions{}) + if err != nil { + t.Fatalf("rerun: %v\n%s", err, out) + } + if !strings.Contains(out, "is already in checkpoint") || !strings.Contains(out, "Pushed checkpoint metadata to origin") { + t.Fatalf("rerun should push the checkpoint it already holds:\n%s", out) + } +} + +func writeAttachTestSettings(t *testing.T, content string) { + t.Helper() + if err := os.WriteFile(filepath.Join(mustGetwd(t), ".entire", "settings.json"), []byte(content), 0o600); err != nil { + t.Fatal(err) + } +} + +// A commit someone pushed and then built on, on a branch this clone doesn't +// track, is an ancestor of that branch's tip rather than the tip itself. It is +// still pushed, so attach must not amend it. +func TestAttachCommit_SeesACommitBuriedInAnUntrackedBranch(t *testing.T) { + setupAttachTestRepo(t) + head := commitAt(t, "work.txt") + dir := mustGetwd(t) + remote := t.TempDir() + testutil.RunGit(t, remote, "init", "--bare", "-q") + testutil.RunGit(t, dir, "remote", "add", "origin", remote) + testutil.RunGit(t, dir, "config", "remote.origin.fetch", "+refs/heads/main:refs/remotes/origin/main") + testutil.RunGit(t, dir, "push", "-q", remote, "HEAD:refs/heads/shared") + other := filepath.Join(t.TempDir(), "other") + testutil.RunGit(t, t.TempDir(), "clone", "-q", "--branch", "shared", remote, other) + testutil.WriteFile(t, other, "more.txt", "more") + testutil.GitAdd(t, other, "more.txt") + testutil.RunGit(t, other, "-c", "user.name=Other", "-c", "user.email=other@example.com", "commit", "-q", "-m", "built on top") + testutil.RunGit(t, other, "push", "-q", "origin", "shared") + + out, err := attachHeadless(t, "attach-buried", attachOptions{}) + if err != nil { + t.Fatalf("runAttach: %v\n%s", err, out) + } + if got := headCommitOf(t); got.Hash != head.Hash || strings.Contains(got.Message, "Entire-Checkpoint") { + t.Fatalf("a commit already on the remote (buried under another commit) was amended: %s %q\n%s", got.Hash, got.Message, out) + } +} + +// Recording a link to a pushed commit publishes the transcript, so it needs the +// user's go-ahead too: without a terminal or --force nothing is written. +func TestAttachCommit_PushedCommitNeedsConfirmation(t *testing.T) { + setupAttachTestRepo(t) + commitAt(t, "work.txt") + pushToOrigin(t) + const sessionID = "attach-pushed-unconfirmed" + setupClaudeTranscript(t, sessionID, attachCommitTranscript) + + var out, errOut bytes.Buffer + err := runAttach(context.Background(), &out, &errOut, sessionID, agent.AgentNameClaudeCode, attachOptions{}) + if err == nil { + t.Fatalf("attach without confirmation succeeded:\n%s", out.String()) + } + if !strings.Contains(errOut.String(), "already pushed") || !strings.Contains(errOut.String(), "are pushed to origin now") { + t.Errorf("expected the pushed-commit warning, got:\n%s", errOut.String()) + } + if state, err := loadAttachState(t, sessionID); err != nil || state != nil { + t.Fatalf("session state written without confirmation: %+v (%v)", state, err) + } +} + +// A history walk that can't finish is an unknown answer, not "pushed". +func TestReachableFromAny_FailedWalkIsAnError(t *testing.T) { + setupAttachTestRepo(t) + head := headCommitOf(t) + if _, err := reachableFromAny(context.Background(), head, []string{strings.Repeat("ab", 20)}); err == nil { + t.Fatal("a walk from a missing tip reported an answer") + } + reachable, err := reachableFromAny(context.Background(), head, []string{head.Hash.String()}) + if err != nil || !reachable { + t.Fatalf("reachableFromAny(HEAD from HEAD) = %v, %v", reachable, err) + } +} + +// Branch names a remote advertises become fetch refspecs, so only well-formed +// branch refs are used: a ":" would name a local destination. +func TestIsRemoteBranchName(t *testing.T) { + t.Parallel() + for name, want := range map[string]bool{ + "refs/heads/main": true, + "refs/heads/feature/x": true, + "refs/heads/a:refs/heads/victim": false, + "refs/heads/a:refs/entire/cp/x": false, + "refs/heads/*": false, + "refs/heads/a^b": false, + "refs/tags/v1": false, + "refs/heads/..": false, + "+refs/heads/main:refs/heads/main": false, + } { + if got := isRemoteBranchName(name); got != want { + t.Errorf("isRemoteBranchName(%q) = %v, want %v", name, got, want) + } + } +} + +func TestTrackingRefRemote(t *testing.T) { + t.Parallel() + remotes := []string{"a", "a/b", "origin"} + for ref, want := range map[string]string{ + "refs/remotes/a/main": "a", + "refs/remotes/a/b/main": "a/b", + "refs/remotes/origin/x/y": "origin", + "refs/remotes/other/main": "", + } { + if got := trackingRefRemote(ref, remotes); got != want { + t.Errorf("trackingRefRemote(%q) = %q, want %q", ref, got, want) + } + } +} + +// Blame marks a line attributed through a recorded link, which nothing +// verified, apart from trailer-linked ones. +func TestAttributionLineMarker_RecordedLink(t *testing.T) { + t.Parallel() + line := attributionLine{RecordedLink: true, MetadataMissing: true} + if got := attributionLineMarker(line); got != "!" { + t.Fatalf("marker = %q, want !", got) + } + var legend bytes.Buffer + renderAttributionMarkerLegend(&legend, newStatusStyles(&legend), []attributionLine{line}) + if !strings.Contains(legend.String(), "recorded link") { + t.Fatalf("legend doesn't explain !: %q", legend.String()) + } +} + +// An agent that reports no transcript position still ends the window at the +// transcript's line count, so the session's offset advances and a later attach +// doesn't record the same turns again. +func TestAttachTranscriptWindowFor_FallsBackToLineCount(t *testing.T) { + t.Parallel() + data := []byte("{\"a\":1}\n{\"b\":2}\n{\"c\":3}\n") + window := attachTranscriptWindowFor(io.Discard, fakeAgent{typ: "No Position Agent"}, "", data, nil) + if window.end != 3 { + t.Fatalf("window.end = %d, want 3", window.end) + } +} + +// Repro: the commit is pushed from another clone while the confirmation is +// open. Attach must ask the remote again after the answer, not trust what it +// saw before the prompt or its own stale tracking refs, and leave the commit +// alone. Not parallel: sets ENTIRE_TEST_TTY and the confirmation seam. +func TestAttachCommit_PushedWhileConfirmationIsOpenIsNotRewritten(t *testing.T) { + setupAttachTestRepo(t) + commitAt(t, "work.txt") + remote := pushToOrigin(t) + head := commitAt(t, "more.txt") + setupClaudeTranscript(t, "attach-pushed-during-prompt", attachCommitTranscript) + + t.Setenv(interactive.EnvTestTTY, "1") + old := askAttachConfirmation + t.Cleanup(func() { askAttachConfirmation = old }) + askAttachConfirmation = func() (bool, error) { + // Another clone pushes the commit: by URL, so none of this clone's + // tracking refs learn about it. + testutil.RunGit(t, mustGetwd(t), "push", "-q", remote, head.Hash.String()+":refs/heads/someone-else") + return true, nil + } + + var out bytes.Buffer + err := runAttach(context.Background(), &out, &out, "attach-pushed-during-prompt", agent.AgentNameClaudeCode, attachOptions{}) + if err == nil || !strings.Contains(err.Error(), "while attach was waiting") { + t.Fatalf("err = %v, want a refusal because the commit was pushed during the prompt\n%s", err, out.String()) + } + if got := headCommitOf(t); got.Hash != head.Hash { + t.Fatalf("a commit pushed during the prompt was rewritten: %s", got.Hash) + } + if state, loadErr := loadAttachState(t, "attach-pushed-during-prompt"); loadErr != nil || state != nil { + t.Fatalf("session state was written after the refusal: %+v, %v", state, loadErr) + } +} + +// Repro: a hostile remote advertises a branch whose name is a refspec. Attach +// fetches branches whose tips it lacks by name; the name must not become a +// fetch destination that writes a local ref. +func TestAttachCommit_RemoteBranchNameCannotWriteLocalRefs(t *testing.T) { + setupAttachTestRepo(t) + commitAt(t, "work.txt") + remote := pushToOrigin(t) + + // Another clone pushes a commit this clone doesn't have. + other := t.TempDir() + testutil.RunGit(t, other, "clone", "-q", remote, ".") + testutil.WriteFile(t, other, "theirs.txt", "theirs") + testutil.GitAdd(t, other, "theirs.txt") + testutil.RunGit(t, other, "-c", "user.name=Other", "-c", "user.email=other@example.com", "commit", "-q", "-m", "theirs") + testutil.RunGit(t, other, "push", "-q", "origin", "HEAD:refs/heads/a") + theirs := strings.TrimSpace(testutil.RunGit(t, other, "rev-parse", "HEAD")) + + // The remote advertises it under a name carrying a destination. + testutil.RunGit(t, remote, "pack-refs", "--all") + packed := filepath.Join(remote, "packed-refs") + data, err := os.ReadFile(packed) + if err != nil { + t.Fatal(err) + } + data = append(data, []byte(theirs+" refs/heads/a:refs/heads/victim\n")...) + if err := os.WriteFile(packed, data, 0o600); err != nil { + t.Fatal(err) + } + if ls := testutil.RunGit(t, mustGetwd(t), "ls-remote", "--heads", "origin"); !strings.Contains(ls, "refs/heads/a:refs/heads/victim") { + t.Skipf("this git doesn't advertise the crafted name, so the attack can't be staged:\n%s", ls) + } + + commitAt(t, "mine.txt") + out, err := attachHeadless(t, "attach-hostile-branch-name", attachOptions{}) + if refs := testutil.RunGit(t, mustGetwd(t), "for-each-ref", "--format=%(refname)", "refs/heads/victim"); strings.TrimSpace(refs) != "" { + t.Fatalf("a remote's branch name wrote a local ref: %s", refs) + } + if err != nil { + t.Fatalf("runAttach: %v\n%s", err, out) + } +} diff --git a/cmd/entire/cli/attach_confirm.go b/cmd/entire/cli/attach_confirm.go new file mode 100644 index 0000000000..434c20ba2e --- /dev/null +++ b/cmd/entire/cli/attach_confirm.go @@ -0,0 +1,263 @@ +package cli + +import ( + "bytes" + "context" + "errors" + "fmt" + "io" + "os" + "os/exec" + "slices" + "strings" + + "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/entireio/cli/cmd/entire/cli/checkpoint/id" + "github.com/entireio/cli/cmd/entire/cli/interactive" + "github.com/entireio/cli/cmd/entire/cli/strategy" + "github.com/entireio/cli/cmd/entire/cli/trailers" +) + +// errAttachDeclined is returned when the user answers no; nothing was changed +// and attach exits successfully. +var errAttachDeclined = errors.New("attach declined") + +// confirmAttach prints what attach is about to do and asks before doing it. +// With --force it proceeds after printing. Without a terminal to ask on (an +// agent or a script), it changes nothing and exits non-zero, telling the +// caller to get the user's go-ahead and rerun with --force. +func confirmAttach(w, errW io.Writer, warning []string, force bool) error { + fmt.Fprintln(errW) + for _, line := range warning { + fmt.Fprintln(errW, line) + } + if force { + return nil + } + if !interactive.CanPromptInteractively() { + fmt.Fprintln(errW, "\nNothing was changed. This needs the user's go-ahead: show them the above, and if they agree, rerun with --force.") + return NewSilentError(errors.New("session attach needs the user's confirmation; rerun with --force once they agree")) + } + proceed, err := askAttachConfirmation() + if err != nil { + return err + } + if !proceed { + fmt.Fprintln(w, "Nothing was changed.") + return errAttachDeclined + } + return nil +} + +// askAttachConfirmation asks the user whether to go ahead, default No. A +// variable so tests can answer, and act while the question is open. +var askAttachConfirmation = func() (bool, error) { + proceed := false + form := NewAccessibleForm(huh.NewGroup( + huh.NewConfirm(). + Title("Continue?"). + Affirmative("Yes"). + Negative("No"). + Value(&proceed), + )) + if err := form.Run(); err != nil { + return false, fmt.Errorf("prompt failed: %w", err) + } + return proceed, nil +} + +// describeCommit is a commit's short hash and subject, for warnings. +func describeCommit(c *object.Commit) string { + subject, _, _ := strings.Cut(c.Message, "\n") + return fmt.Sprintf("%s %q", c.Hash.String()[:12], subject) +} + +// attachRewriteChain returns target and the commits after it up to HEAD, +// oldest first: the commits that adding a trailer to target rewrites. It +// refuses mid-operation (rebase, merge, ...), a target not on the current +// branch, and merges after the target, which a message-only replay can't +// reproduce faithfully. +func attachRewriteChain(ctx context.Context, repo *git.Repository, target, head *object.Commit) ([]*object.Commit, error) { + if op := strategy.GitOperationInProgress(ctx); op != "" { + return nil, fmt.Errorf("can't add the Entire-Checkpoint trailer to %s while %s is in progress; finish or abort it first", target.Hash.String()[:12], op) + } + chain, err := commitsFromTargetToHead(ctx, repo, target, head) + if err != nil { + return nil, err + } + for _, c := range chain { + if !replayable(c) { + return nil, fmt.Errorf("commit %s has a non-UTF-8 encoding or extra headers that attach can't carry over when it rewrites it; push %s first, or add the trailer yourself", c.Hash.String()[:12], target.Hash.String()[:12]) + } + } + return chain, nil +} + +// commitsFromTargetToHead lists target and the commits after it up to head, +// oldest first, refusing a target off the current branch and merges after it. +func commitsFromTargetToHead(ctx context.Context, repo *git.Repository, target, head *object.Commit) ([]*object.Commit, error) { + if target.Hash.Equal(head.Hash) { + return []*object.Commit{target}, nil + } + if exec.CommandContext(ctx, "git", "merge-base", "--is-ancestor", target.Hash.String(), head.Hash.String()).Run() != nil { + return nil, fmt.Errorf("commit %s is not pushed and is not on the current branch, so attach can't add a trailer to it; check out a branch that contains it, or push it first", target.Hash.String()[:12]) + } + out, err := exec.CommandContext(ctx, "git", "rev-list", "--reverse", "--parents", target.Hash.String()+".."+head.Hash.String()).Output() + if err != nil { + return nil, fmt.Errorf("list the commits after %s: %w", target.Hash.String()[:12], err) + } + chain := []*object.Commit{target} + for _, line := range strings.Split(strings.TrimSpace(string(out)), "\n") { + fields := strings.Fields(line) + if len(fields) == 0 { + continue + } + if len(fields) != 2 { + return nil, fmt.Errorf("commit %s after %s is a merge, so attach can't rewrite the commits after it; push %s first, or attach while it is HEAD", fields[0][:12], target.Hash.String()[:12], target.Hash.String()[:12]) + } + c, err := repo.CommitObject(plumbing.NewHash(fields[0])) + if err != nil { + return nil, fmt.Errorf("read commit %s: %w", fields[0][:12], err) + } + chain = append(chain, c) + } + return chain, nil +} + +// replayable reports whether rewriteWithTrailer reproduces c faithfully apart +// from its signature: git commit-tree writes a UTF-8 commit with no extra +// headers. +func replayable(c *object.Commit) bool { + return (c.Encoding == "" || strings.EqualFold(string(c.Encoding), "UTF-8")) && len(c.ExtraHeaders) == 0 +} + +// rewriteWarning describes adding a trailer to chain[0], for confirmAttach. +func rewriteWarning(ctx context.Context, chain []*object.Commit, checkedRemotes []string) []string { + target := chain[0] + lines := []string{"This rewrites git history: commit " + describeCommit(target) + " gets an Entire-Checkpoint trailer."} + if len(chain) > 1 { + lines = append(lines, fmt.Sprintf("The %d commit(s) after it are rewritten on top of it:", len(chain)-1)) + for _, c := range chain[1:] { + lines = append(lines, " "+describeCommit(c)) + } + } + if len(chain) == 1 { + lines = append(lines, "Its SHA changes; its content doesn't.") + } else { + lines = append(lines, "Their SHAs change; their content doesn't.") + } + if len(checkedRemotes) > 0 { + lines = append(lines, fmt.Sprintf("It isn't on %s. If you pushed it somewhere else, rewriting it means a force-push there.", strings.Join(checkedRemotes, ", "))) + } + if others := otherRefsContaining(ctx, target); len(others) > 0 { + lines = append(lines, "These keep the old commits: "+strings.Join(others, ", ")) + } + for _, c := range chain { + if c.Signature != "" { + lines = append(lines, "Signed commits lose their signatures.") + break + } + } + return lines +} + +// otherRefsContaining lists the branches and tags, other than the checked-out +// branch, that contain target. +func otherRefsContaining(ctx context.Context, target *object.Commit) []string { + current, _ := exec.CommandContext(ctx, "git", "symbolic-ref", "-q", "--short", "HEAD").Output() //nolint:errcheck // detached HEAD has none + out, err := exec.CommandContext(ctx, "git", "for-each-ref", "--contains", target.Hash.String(), "--format=%(refname:short)", "refs/heads", "refs/tags").Output() + if err != nil { + return nil + } + var refs []string + for _, ref := range strings.Fields(string(out)) { + if ref != strings.TrimSpace(string(current)) { + refs = append(refs, ref) + } + } + return refs +} + +// rewriteWithTrailer adds the checkpoint trailer to chain[0] and replays the +// rest of chain on top, keeping every tree, author, committer and message (as +// git filter-branch does), then moves the +// checked-out branch (or a detached HEAD) to the new tip if it is still at the +// old one. Trees are reused, so the worktree and index are untouched, and no +// commit hooks run. Session state that names the old commits is remapped as +// git's post-rewrite hook would. +func rewriteWithTrailer(ctx context.Context, w io.Writer, chain []*object.Commit, checkpointID id.CheckpointID) error { + oldHead := chain[len(chain)-1].Hash.String() + var pairs strings.Builder + parents := chain[0].ParentHashes + newSHA, targetSHA := "", "" + for i, c := range chain { + message := c.Message + if i == 0 { + message = trailers.AppendCheckpointTrailer(message, checkpointID.String()) + } + args := []string{"commit-tree", c.TreeHash.String()} + if i == 0 { + for _, p := range parents { + args = append(args, "-p", p.String()) + } + } else { + args = append(args, "-p", newSHA) + } + cmd := exec.CommandContext(ctx, "git", args...) + cmd.Stdin = strings.NewReader(message) + cmd.Env = append(withoutIdentityEnv(os.Environ()), + "GIT_AUTHOR_NAME="+c.Author.Name, + "GIT_AUTHOR_EMAIL="+c.Author.Email, + fmt.Sprintf("GIT_AUTHOR_DATE=@%d %s", c.Author.When.Unix(), c.Author.When.Format("-0700")), + "GIT_COMMITTER_NAME="+c.Committer.Name, + "GIT_COMMITTER_EMAIL="+c.Committer.Email, + fmt.Sprintf("GIT_COMMITTER_DATE=@%d %s", c.Committer.When.Unix(), c.Committer.When.Format("-0700")), + ) + var stderr bytes.Buffer + cmd.Stderr = &stderr + out, err := cmd.Output() + if err != nil { + return fmt.Errorf("rewrite commit %s: %w: %s", c.Hash.String()[:12], err, strings.TrimSpace(stderr.String())) + } + newSHA = strings.TrimSpace(string(out)) + if i == 0 { + targetSHA = newSHA + } + fmt.Fprintf(&pairs, "%s %s\n", c.Hash, newSHA) + } + + reason := "entire session attach: add Entire-Checkpoint " + checkpointID.String() + " to " + chain[0].Hash.String()[:12] + updateArgs := []string{"update-ref", "-m", reason} + if branch, err := exec.CommandContext(ctx, "git", "symbolic-ref", "-q", "HEAD").Output(); err == nil { + updateArgs = append(updateArgs, strings.TrimSpace(string(branch))) + } else { + updateArgs = append(updateArgs, "--no-deref", "HEAD") + } + updateArgs = append(updateArgs, newSHA, oldHead) + if out, err := exec.CommandContext(ctx, "git", updateArgs...).CombinedOutput(); err != nil { + return fmt.Errorf("move HEAD to the rewritten commits (HEAD changed meanwhile?): %w: %s", err, strings.TrimSpace(string(out))) + } + + if err := strategy.NewManualCommitStrategy().PostRewrite(ctx, "rebase", strings.NewReader(pairs.String())); err != nil { + fmt.Fprintf(w, "warning: couldn't update session state for the rewritten commits: %v\n", err) + } + fmt.Fprintf(w, " Added Entire-Checkpoint: %s to commit %s (was %s", checkpointID, targetSHA[:12], chain[0].Hash.String()[:12]) + if len(chain) > 1 { + fmt.Fprintf(w, "; HEAD was %s", oldHead[:12]) + } + fmt.Fprintln(w, ")") + return nil +} + +// withoutIdentityEnv drops GIT_AUTHOR_* and GIT_COMMITTER_* from env, so the +// identity rewriteWithTrailer sets is the only one: with a duplicate, which +// one git sees depends on the platform's getenv. +func withoutIdentityEnv(env []string) []string { + return slices.DeleteFunc(slices.Clone(env), func(kv string) bool { + return strings.HasPrefix(kv, "GIT_AUTHOR_") || strings.HasPrefix(kv, "GIT_COMMITTER_") + }) +} diff --git a/cmd/entire/cli/attach_test.go b/cmd/entire/cli/attach_test.go index 8cb94a7ff5..a1cad7a45b 100644 --- a/cmd/entire/cli/attach_test.go +++ b/cmd/entire/cli/attach_test.go @@ -273,17 +273,6 @@ func TestAttach_Success(t *testing.T) { func TestAttach_PopulatesBaseCommitFromHEAD(t *testing.T) { setupAttachTestRepo(t) - repoRoot := mustGetwd(t) - repo, err := git.PlainOpen(repoRoot) - if err != nil { - t.Fatal(err) - } - headRef, err := repo.Head() - if err != nil { - t.Fatal(err) - } - headHash := headRef.Hash().String() - sessionID := "test-attach-empty-base-commit" setupClaudeTranscript(t, sessionID, `{"type":"user","message":{"role":"user","content":"hello"},"uuid":"u1"} {"type":"assistant","message":{"role":"assistant","content":"hi"},"uuid":"a1"} @@ -310,6 +299,8 @@ func TestAttach_PopulatesBaseCommitFromHEAD(t *testing.T) { t.Fatalf("runAttach failed: %v", err) } + // Attach added the trailer to HEAD, so the base is the rewritten HEAD. + headHash := headCommitOf(t).Hash.String() state, err := store.Load(context.Background(), sessionID) if err != nil { t.Fatal(err) @@ -856,7 +847,7 @@ func TestExtractTranscriptMetadataForAgent_Pi(t *testing.T) { t.Fatalf("generic parser unexpectedly understood native Pi transcript: %+v", generic) } - got := extractTranscriptMetadataForAgent(piagent.NewPiAgent(), path, data) + got := extractTranscriptMetadataForAgent(piagent.NewPiAgent(), path, data, 0) if got.FirstPrompt != "Review this trail" { t.Errorf("FirstPrompt = %q, want %q", got.FirstPrompt, "Review this trail") } @@ -889,7 +880,7 @@ func TestExtractTranscriptMetadataForAgent_CodexSkipsEnvironmentContext(t *testi t.Fatal(err) } - got := extractTranscriptMetadataForAgent(codexagent.NewCodexAgent(), path, data) + got := extractTranscriptMetadataForAgent(codexagent.NewCodexAgent(), path, data, 0) if got.FirstPrompt != "Review this trail for correctness" { t.Errorf("FirstPrompt = %q, want the genuine user prompt, not the injected environment context", got.FirstPrompt) } @@ -916,7 +907,7 @@ func TestExtractTranscriptMetadata_CodexOnlyEnvironmentContext(t *testing.T) { t.Fatal(err) } - got := extractTranscriptMetadataForAgent(codexagent.NewCodexAgent(), path, data) + got := extractTranscriptMetadataForAgent(codexagent.NewCodexAgent(), path, data, 0) if got.FirstPrompt != envContext { t.Errorf("FirstPrompt = %q, want the raw environment context as fallback", got.FirstPrompt) } @@ -1280,8 +1271,8 @@ func TestAttach_ReviewWithExistingCheckpointErrors(t *testing.T) { if err == nil { t.Fatal("expected error when review-attaching a session that already has a checkpoint") } - if !strings.Contains(err.Error(), "already has checkpoint") { - t.Errorf("error should mention 'already has checkpoint'; got: %v", err) + if !strings.Contains(err.Error(), "already recorded in checkpoint") { + t.Errorf("error should mention 'already recorded in checkpoint'; got: %v", err) } } @@ -1834,11 +1825,11 @@ func TestAttachSummaryLine(t *testing.T) { } } -// TestAttach_NonInteractivePrintsTrailerForManualPaste: with --force unset and -// no TTY (the test default), attach cannot prompt to amend, so it prints the -// Entire-Checkpoint trailer for manual paste instead of failing. -func TestAttach_NonInteractivePrintsTrailerForManualPaste(t *testing.T) { +// Without a terminal, attach describes the change and makes none until the +// caller reruns with --force; with it, the trailer is added to the unpushed HEAD. +func TestAttach_NonInteractiveNeedsForce(t *testing.T) { setupAttachTestRepo(t) + head := headCommitOf(t) sessionID := "test-attach-noninteractive" setupClaudeTranscript(t, sessionID, `{"type":"user","message":{"role":"user","content":"hello"},"uuid":"u1"} @@ -1846,14 +1837,28 @@ func TestAttach_NonInteractivePrintsTrailerForManualPaste(t *testing.T) { `) var out, errOut bytes.Buffer - // Force:false — exercise the non-interactive fallback branch. - if err := runAttach(context.Background(), &out, &errOut, sessionID, agent.AgentNameClaudeCode, attachOptions{}); err != nil { - t.Fatalf("runAttach failed: %v", err) + err := runAttach(context.Background(), &out, &errOut, sessionID, agent.AgentNameClaudeCode, attachOptions{}) + if err == nil { + t.Fatalf("attach without --force and without a terminal succeeded:\n%s", out.String()) + } + if !strings.Contains(errOut.String(), "This rewrites git history") || !strings.Contains(errOut.String(), "rerun with --force") { + t.Errorf("expected the rewrite warning and the --force instruction, got:\n%s", errOut.String()) + } + if got := headCommitOf(t); got.Hash != head.Hash { + t.Fatalf("HEAD changed without confirmation: %s", got.Hash) + } + if state, err := loadAttachState(t, sessionID); err != nil || state != nil { + t.Fatalf("session state written without confirmation: %+v (%v)", state, err) } - re := regexp.MustCompile(`Entire-Checkpoint: ` + id.CheckpointPattern) + out.Reset() + errOut.Reset() + if err := runAttach(context.Background(), &out, &errOut, sessionID, agent.AgentNameClaudeCode, attachOptions{Force: true}); err != nil { + t.Fatalf("runAttach --force: %v\n%s", err, errOut.String()) + } + re := regexp.MustCompile(`Added Entire-Checkpoint: ` + id.CheckpointPattern + ` to commit [0-9a-f]+ \(was ` + head.Hash.String()[:12] + `\)`) if !re.MatchString(out.String()) { - t.Errorf("expected Entire-Checkpoint trailer for manual paste, got:\n%s", out.String()) + t.Errorf("expected HEAD to get the trailer, got:\n%s", out.String()) } } diff --git a/cmd/entire/cli/attach_transcript.go b/cmd/entire/cli/attach_transcript.go index 15486beb11..7402b1b030 100644 --- a/cmd/entire/cli/attach_transcript.go +++ b/cmd/entire/cli/attach_transcript.go @@ -85,12 +85,14 @@ func countUserTurns(prompts []string) int { // agent-native prompt and model extraction when available. Native extractors // are authoritative because they understand format-specific nesting and // conversation branches (Pi, Codex, Droid, etc.); failures remain best-effort -// and preserve whatever the generic parser found. -func extractTranscriptMetadataForAgent(ag agent.Agent, sessionRef string, data []byte) transcriptMetadata { - meta := extractTranscriptMetadata(data) +// and preserve whatever the generic parser found. Prompts and turns are counted +// from start, a transcript position in the agent's own metric (0 for the whole +// session); the model is read from the whole transcript. +func extractTranscriptMetadataForAgent(ag agent.Agent, sessionRef string, data []byte, start int) transcriptMetadata { + meta := extractTranscriptMetadata(sliceTranscriptFrom(ag, data, start)) if extractor, ok := agent.AsPromptExtractor(ag); ok { - if prompts, err := extractor.ExtractPrompts(sessionRef, 0); err == nil && len(prompts) > 0 { + if prompts, err := extractor.ExtractPrompts(sessionRef, start); err == nil && len(prompts) > 0 { // Native extractors return every user-role item in transcript order, // including the agent's own injected preambles (Codex leads with an // AGENTS.md dump and/or ). Title from the first @@ -112,3 +114,15 @@ func extractTranscriptMetadataForAgent(ag agent.Agent, sessionRef string, data [ return meta } + +// sliceTranscriptFrom returns the part of data after start, counted by the +// agent's own position metric where it has one, else by JSONL lines. +func sliceTranscriptFrom(ag agent.Agent, data []byte, start int) []byte { + if start <= 0 { + return data + } + if lw, ok := agent.AsLateTranscriptWriter(ag); ok { + return lw.SliceTranscriptFromPosition(data, start) + } + return transcript.SliceFromLine(data, start) +} diff --git a/cmd/entire/cli/attribution.go b/cmd/entire/cli/attribution.go index 9107def469..dbf8d1067c 100644 --- a/cmd/entire/cli/attribution.go +++ b/cmd/entire/cli/attribution.go @@ -78,9 +78,14 @@ type attributionLine struct { // specific checkpoint. `why` labels these differently and points at // `checkpoint explain`, since the prompt may not appear in this checkpoint's // own transcript slice. - PromptSessionLevel bool `json:"prompt_session_level,omitempty"` - Content string `json:"content"` - Candidates []attributionCandidate `json:"candidates,omitempty"` + PromptSessionLevel bool `json:"prompt_session_level,omitempty"` + // RecordedLink is set when the commit has no Entire-Checkpoint trailer and + // its checkpoint was found through a link recorded in checkpoint metadata + // (entire session attach on a pushed commit). The CLI can't verify who + // recorded it: anyone who can push checkpoints can name any commit. + RecordedLink bool `json:"recorded_link,omitempty"` + Content string `json:"content"` + Candidates []attributionCandidate `json:"candidates,omitempty"` } // attributionCheckpointContext is the resolved metadata for one checkpoint as @@ -142,6 +147,35 @@ type attributionResolver struct { commitCache map[string]*object.Commit checkpointCache map[string]attributionCheckpointContext + // linked lists checkpoints once, for commits linked without a trailer + // (`entire session attach --commit`). nil until first needed. + linked []checkpoint.CheckpointInfo + // linkedByCommit caches linkedCheckpoints per commit, since reading a + // remote-discovered stub for its links can fetch. + linkedByCommit map[string][]id.CheckpointID + // linkedStubs reads each remote-discovered stub at most once per run, and + // linkedDeadline gives every such read in the run one shared budget. + linkedStubs *stubSummaryCache + linkedDeadline time.Time +} + +// stubSummaryCache remembers each checkpoint summary it reads, failures +// included, so a blame run reads a stub at most once. +type stubSummaryCache struct { + reader attributionCheckpointReader + summaries map[id.CheckpointID]*checkpoint.CheckpointSummary +} + +func (c *stubSummaryCache) Read(ctx context.Context, cpID id.CheckpointID) (*checkpoint.CheckpointSummary, error) { + if summary, ok := c.summaries[cpID]; ok { + return summary, nil + } + summary, err := c.reader.Read(ctx, cpID) + if err != nil { + summary = nil + } + c.summaries[cpID] = summary + return summary, err //nolint:wrapcheck // callers treat a failed read as no links } func newBlameCmd() *cobra.Command { @@ -407,6 +441,10 @@ func (r *attributionResolver) resolveLine(raw rawBlameLine, file string) attribu } cpIDs := trailers.ParseAllCheckpoints(commit.Message) + if len(cpIDs) == 0 { + cpIDs = r.linkedCheckpoints(commit) + line.RecordedLink = len(cpIDs) > 0 + } if len(cpIDs) == 0 { return line } @@ -434,6 +472,36 @@ func (r *attributionResolver) commit(sha string) (*object.Commit, error) { return commit, nil } +// linkedCheckpoints returns the checkpoints that link commit without a +// trailer. The store is listed once per blame run; a store that cannot list +// contributes none. +func (r *attributionResolver) linkedCheckpoints(commit *object.Commit) []id.CheckpointID { + sha := commit.Hash.String() + if ids, ok := r.linkedByCommit[sha]; ok { + return ids + } + if r.linked == nil { + r.linked = []checkpoint.CheckpointInfo{} + r.linkedStubs = &stubSummaryCache{reader: r.store, summaries: make(map[id.CheckpointID]*checkpoint.CheckpointSummary)} + r.linkedDeadline = time.Now().Add(checkpoint.ListHydrationPassTimeout) + if lister, ok := r.store.(interface { + List(ctx context.Context) ([]checkpoint.CheckpointInfo, error) + }); ok { + if infos, err := lister.List(r.ctx); err == nil { + r.linked = infos + } + } + } + ctx, cancel := context.WithDeadline(r.ctx, r.linkedDeadline) + ids := checkpoint.CheckpointsLinkedToWithStubs(ctx, r.linkedStubs, r.linked, sha, commit.Committer.When) + cancel() + if r.linkedByCommit == nil { + r.linkedByCommit = make(map[string][]id.CheckpointID) + } + r.linkedByCommit[sha] = ids + return ids +} + func (r *attributionResolver) checkpointContext(cpID id.CheckpointID, file string) attributionCheckpointContext { key := cpID.String() if ctx, ok := r.checkpointCache[key]; ok { @@ -889,14 +957,21 @@ func largestRemainderPercent(counts []int, total int) []int { return pct } +// recordedLinkNote explains a checkpoint found through a recorded link. +const recordedLinkNote = "The commit has no Entire-Checkpoint trailer; this checkpoint names it in a link recorded by entire session attach. The CLI can't verify who recorded the link: anyone who can push checkpoints can name any commit." + // attributionLineMarker returns a one-character flag for the blame tables: // "~" when the agent/checkpoint shown is a best-effort guess (the file is not in // the checkpoint session's recorded paths, or only trailer-level metadata was // found), "?" when more than one checkpoint is a candidate for the line, and a -// space otherwise. `entire why` surfaces the same information in prose; this -// closes the gap where the blame table looked equally confident on every line. +// space otherwise; "!" when the checkpoint was found through an unverified +// recorded link rather than a trailer. `entire why` surfaces the same +// information in prose; this closes the gap where the blame table looked +// equally confident on every line. func attributionLineMarker(line attributionLine) string { switch { + case line.RecordedLink: + return "!" case line.SessionFallback || line.MetadataMissing: return "~" case len(line.Candidates) > 1: @@ -909,19 +984,24 @@ func attributionLineMarker(line attributionLine) string { // renderAttributionMarkerLegend prints a one-line legend explaining the blame // markers, but only for the markers actually present in the table. func renderAttributionMarkerLegend(w io.Writer, sty statusStyles, lines []attributionLine) { - approximate, ambiguous := false, false + approximate, ambiguous, recorded := false, false, false for _, line := range lines { switch attributionLineMarker(line) { case "~": approximate = true case "?": ambiguous = true + case "!": + recorded = true } } - if !approximate && !ambiguous { + if !approximate && !ambiguous && !recorded { return } var parts []string + if recorded { + parts = append(parts, "! from a recorded link, not a trailer (unverified)") + } if approximate { parts = append(parts, "~ best-effort session match (file not in the checkpoint's recorded paths)") } @@ -1134,6 +1214,9 @@ func renderAttributionLineWhy(w io.Writer, file string, line attributionLine) { } fmt.Fprintf(w, " %s\n", sty.render(sty.yellow, message)) } + if line.RecordedLink { + fmt.Fprintf(w, " %s\n", sty.render(sty.yellow, recordedLinkNote)) + } if line.SessionFallback { fmt.Fprintf(w, " %s\n", sty.render(sty.yellow, "This file is not in the checkpoint session's recorded paths (it may have been renamed); the agent and prompt shown are a best-effort guess, not necessarily the session that produced this line.")) } diff --git a/cmd/entire/cli/checkpoint/aliases.go b/cmd/entire/cli/checkpoint/aliases.go index 20d3da7fcf..ec32f74d28 100644 --- a/cmd/entire/cli/checkpoint/aliases.go +++ b/cmd/entire/cli/checkpoint/aliases.go @@ -30,6 +30,7 @@ type ( Summary = apicheckpoint.Summary LearningsSummary = apicheckpoint.LearningsSummary CodeLearning = apicheckpoint.CodeLearning + LinkedCommit = apicheckpoint.LinkedCommit // Operation option types. WriteOptions = apicheckpoint.WriteOptions diff --git a/cmd/entire/cli/checkpoint/linked_commits_test.go b/cmd/entire/cli/checkpoint/linked_commits_test.go new file mode 100644 index 0000000000..814267a3ce --- /dev/null +++ b/cmd/entire/cli/checkpoint/linked_commits_test.go @@ -0,0 +1,71 @@ +package checkpoint + +import ( + "context" + "errors" + "testing" + "time" + + "github.com/entireio/cli/cmd/entire/cli/checkpoint/id" + "github.com/stretchr/testify/assert" +) + +type linkedSummaryReader struct { + summaries map[id.CheckpointID]*CheckpointSummary + reads []id.CheckpointID +} + +func (r *linkedSummaryReader) Read(_ context.Context, cid id.CheckpointID) (*CheckpointSummary, error) { + r.reads = append(r.reads, cid) + if s, ok := r.summaries[cid]; ok { + return s, nil + } + return nil, errors.New("not found") +} + +// Stubs minted around or after the commit are read for their links; older ones +// predate the commit and are never read. +func TestCheckpointsLinkedToWithStubs(t *testing.T) { + t.Parallel() + sha := "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa" + committedAt := time.Date(2026, 10, 1, 12, 0, 0, 0, time.UTC) + link := []LinkedCommit{{SHA: sha}} + + local := id.CheckpointID("111111111111") + newStub := id.CheckpointID("222222222222") + oldStub := id.CheckpointID("333333333333") + unreadable := id.CheckpointID("444444444444") + reader := &linkedSummaryReader{summaries: map[id.CheckpointID]*CheckpointSummary{ + newStub: {LinkedCommits: link}, + oldStub: {LinkedCommits: link}, + }} + infos := []CheckpointInfo{ + {CheckpointID: local, LinkedCommits: link}, + {CheckpointID: newStub, ListedStub: true, CreatedAt: committedAt.Add(-time.Hour)}, + {CheckpointID: oldStub, ListedStub: true, CreatedAt: committedAt.Add(-48 * time.Hour)}, + {CheckpointID: unreadable, ListedStub: true, CreatedAt: committedAt.Add(time.Hour)}, + } + + got := CheckpointsLinkedToWithStubs(context.Background(), reader, infos, sha, committedAt) + assert.Equal(t, []id.CheckpointID{local, newStub}, got) + assert.Equal(t, []id.CheckpointID{newStub, unreadable}, reader.reads) +} + +// A checkpoint dated in the future is ignored: its ID is the pusher's choice, +// and as the most recent it would otherwise always win. +func TestCheckpointsLinkedToWithStubs_IgnoresFutureDatedCheckpoints(t *testing.T) { + t.Parallel() + sha := "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa" + link := []LinkedCommit{{SHA: sha}} + future := id.CheckpointID("555555555555") + stub := id.CheckpointID("666666666666") + reader := &linkedSummaryReader{summaries: map[id.CheckpointID]*CheckpointSummary{stub: {LinkedCommits: link}}} + infos := []CheckpointInfo{ + {CheckpointID: future, LinkedCommits: link, CreatedAt: time.Now().Add(30 * 24 * time.Hour)}, + {CheckpointID: stub, ListedStub: true, CreatedAt: time.Now().Add(30 * 24 * time.Hour)}, + } + + got := CheckpointsLinkedToWithStubs(context.Background(), reader, infos, sha, time.Now()) + assert.Empty(t, got) + assert.Empty(t, reader.reads) +} diff --git a/cmd/entire/cli/checkpoint/persistent.go b/cmd/entire/cli/checkpoint/persistent.go index 9ae80cd26f..75142b9506 100644 --- a/cmd/entire/cli/checkpoint/persistent.go +++ b/cmd/entire/cli/checkpoint/persistent.go @@ -14,6 +14,7 @@ import ( "os" "path" "path/filepath" + "slices" "sort" "strconv" "strings" @@ -763,6 +764,7 @@ func (s *treeWriter) writeCheckpointSummary(opts WriteOptions, basePath string, // session package imports checkpoint, so we can't reference its constant. imported := opts.Kind == "imported" commitSHA := opts.CommitSHA + linkedCommits := opts.LinkedCommits var legacyCombinedAttribution json.RawMessage rootMetadataPath := checkpointSubtreePath(basePath, paths.MetadataFileName) if entry, exists := entries[rootMetadataPath]; exists { @@ -785,6 +787,7 @@ func (s *treeWriter) writeCheckpointSummary(opts WriteOptions, basePath string, if commitSHA == "" { commitSHA = existingSummary.CommitSHA } + linkedCommits = unionLinkedCommits(existingSummary.LinkedCommits, linkedCommits) } } @@ -794,6 +797,7 @@ func (s *treeWriter) writeCheckpointSummary(opts WriteOptions, basePath string, Strategy: opts.Strategy, Branch: opts.Branch, CommitSHA: commitSHA, + LinkedCommits: linkedCommits, CheckpointsCount: checkpointsCount, FilesTouched: filesTouched, Sessions: sessions, @@ -1557,6 +1561,7 @@ func readCommittedInfoFromCheckpointTree(checkpointID id.CheckpointID, checkpoin info.FilesTouched = summary.FilesTouched info.SessionCount = len(summary.Sessions) info.Imported = summary.Imported + info.LinkedCommits = summary.LinkedCommits for i := range summary.Sessions { sessionMetadata, ok := readCommittedMetadataFromCheckpointTree(checkpointTree, i) @@ -2884,3 +2889,74 @@ func getCheckpointAuthorFromRef(ctx context.Context, repo *git.Repository, refNa return author, nil } + +// unionLinkedCommits returns existing followed by the entries of added whose +// commit it does not already hold, so rewriting a checkpoint never drops a +// link. +func unionLinkedCommits(existing, added []LinkedCommit) []LinkedCommit { + out := slices.Clone(existing) + for _, link := range added { + if !slices.ContainsFunc(out, func(l LinkedCommit) bool { return l.SHA == link.SHA }) { + out = append(out, link) + } + } + return out +} + +// CheckpointsLinkedTo returns the IDs of the listed checkpoints whose +// trailer-less links (LinkedCommits) name commitSHA, in listing order (most +// recent first for List results). It is the reverse of an Entire-Checkpoint +// trailer for commits linked by `entire session attach --commit`; callers +// consult trailers first. +func CheckpointsLinkedTo(infos []CheckpointInfo, commitSHA string) []id.CheckpointID { + var ids []id.CheckpointID + for _, info := range infos { + if linksCommit(info.LinkedCommits, commitSHA) { + ids = append(ids, info.CheckpointID) + } + } + return ids +} + +// linkedStubSkew is how far before a commit a checkpoint linking it may claim +// to have been minted, allowing for clock skew between machines. +const linkedStubSkew = 24 * time.Hour + +// CheckpointsLinkedToWithStubs is CheckpointsLinkedTo for a listing that may +// hold names-only stubs (git-refs checkpoints discovered on a remote), whose +// links are unknown until read. A stub minted no earlier than committedAt +// (less linkedStubSkew) is read for its links; an older one predates the commit +// and can't link it. Reads share ListHydrationPassTimeout, and a stub that +// can't be read links nothing. +func CheckpointsLinkedToWithStubs(ctx context.Context, reader interface { + Read(ctx context.Context, checkpointID id.CheckpointID) (*CheckpointSummary, error) +}, infos []CheckpointInfo, commitSHA string, committedAt time.Time) []id.CheckpointID { + passCtx, cancel := context.WithTimeout(ctx, ListHydrationPassTimeout) + defer cancel() + since := committedAt.Add(-linkedStubSkew) + // A checkpoint ID dated in the future can't be a real attach; its ID is + // chosen by whoever pushed it, and as "most recent" it would always win. + latest := time.Now().Add(linkedStubSkew) + var ids []id.CheckpointID + for _, info := range infos { + if info.CreatedAt.After(latest) { + continue + } + links := info.LinkedCommits + if info.ListedStub && !info.CreatedAt.Before(since) && passCtx.Err() == nil { + readCtx, readCancel := context.WithTimeout(passCtx, ListHydrationTimeout) + if summary, err := reader.Read(readCtx, info.CheckpointID); err == nil && summary != nil { + links = summary.LinkedCommits + } + readCancel() + } + if linksCommit(links, commitSHA) { + ids = append(ids, info.CheckpointID) + } + } + return ids +} + +func linksCommit(links []LinkedCommit, commitSHA string) bool { + return slices.ContainsFunc(links, func(l LinkedCommit) bool { return l.SHA == commitSHA }) +} diff --git a/cmd/entire/cli/checkpoint/refs_store.go b/cmd/entire/cli/checkpoint/refs_store.go index 4490bf282c..90c4893069 100644 --- a/cmd/entire/cli/checkpoint/refs_store.go +++ b/cmd/entire/cli/checkpoint/refs_store.go @@ -610,6 +610,7 @@ func HydrateListedCheckpointInfo(ctx context.Context, store interface { out.FilesTouched = summary.FilesTouched out.SessionCount = len(summary.Sessions) out.Imported = summary.Imported + out.LinkedCommits = summary.LinkedCommits out.SessionIDs = nil lastMetaOK := len(summary.Sessions) == 0 for i := range summary.Sessions { diff --git a/cmd/entire/cli/checkpoint/refs_store_test.go b/cmd/entire/cli/checkpoint/refs_store_test.go index eb23beeb42..8b7d90b9e6 100644 --- a/cmd/entire/cli/checkpoint/refs_store_test.go +++ b/cmd/entire/cli/checkpoint/refs_store_test.go @@ -557,6 +557,28 @@ func TestHydrateListedCheckpointInfo_MatchesLocalList(t *testing.T) { assert.Equal(t, local, hydrated) } +// A remote-discovered stub carries no links until hydrated; hydration must +// copy them, as local List does, or a commit linked by attach in another clone +// looks unlinked. +func TestHydrateListedCheckpointInfo_CopiesLinkedCommits(t *testing.T) { + t.Parallel() + + store := newRefsStore(t) + cid := id.MustCheckpointID("01KVBJCWYA4YW6J5M9GP655HZN") + links := []LinkedCommit{{SHA: "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", Repo: "github/o/r"}} + require.NoError(t, store.Write(context.Background(), Session{ + CheckpointID: cid, + SessionID: "sess-linked", + Strategy: "manual-commit", + Transcript: redact.AlreadyRedacted([]byte("transcript")), + LinkedCommits: links, + })) + + hydrated := HydrateListedCheckpointInfo(context.Background(), store, remoteDiscoveredInfo(cid)) + assert.Equal(t, links, hydrated.LinkedCommits) + assert.Equal(t, []id.CheckpointID{cid}, CheckpointsLinkedTo([]CheckpointInfo{hydrated}, links[0].SHA)) +} + func TestGitRefsStore_WriteAllVariantsAndRead(t *testing.T) { t.Parallel() store := newRefsStore(t) diff --git a/cmd/entire/cli/explain.go b/cmd/entire/cli/explain.go index f8f2d8dafd..c35c0bea82 100644 --- a/cmd/entire/cli/explain.go +++ b/cmd/entire/cli/explain.go @@ -10,6 +10,7 @@ import ( "os" "os/exec" "runtime" + "slices" "sort" "strconv" "strings" @@ -214,6 +215,9 @@ type associatedCommit struct { Author string Email string Date time.Time + // RecordedLink: listed from the checkpoint's recorded links, not found by + // its trailer; unverified (see recordedLinkNote). + RecordedLink bool } func newExplainCmd() *cobra.Command { @@ -632,6 +636,16 @@ func runExplainAuto(ctx context.Context, w, errW io.Writer, target string, noPag return fmt.Errorf("failed to get commit %s: %w", abbreviateCommitHash(lookup.repo, hash), commitErr) } cpID, hasCheckpoint := trailers.ParseCheckpoint(commit.Message) + linkVia := "its Entire-Checkpoint trailer" + if !hasCheckpoint { + // A commit linked by `entire session attach --commit` carries no + // trailer; its checkpoint names it instead. Most recent wins. + if linked := checkpoint.CheckpointsLinkedToWithStubs(ctx, lookup.store, lookup.committed, hash.String(), commit.Committer.When); len(linked) > 0 { + cpID, hasCheckpoint = linked[0], true + linkVia = "a link recorded by entire session attach" + fmt.Fprintf(errW, "Note: commit %s has no Entire-Checkpoint trailer; checkpoint %s names it in a link recorded by entire session attach. The CLI can't verify who recorded the link: anyone who can push checkpoints can name any commit.\n", abbreviateCommitHash(lookup.repo, hash), cpID) + } + } if !hasCheckpoint { // Side-effect modes must error — silently succeeding would leave // scripts unable to distinguish "done" from "didn't happen". @@ -646,7 +660,7 @@ func runExplainAuto(ctx context.Context, w, errW io.Writer, target string, noPag slog.String("commit", abbreviateCommitHash(lookup.repo, hash)), slog.String("checkpoint_id", cpID.String())) if err := runExplainCheckpointWithLookup(ctx, w, errW, cpID.String(), noPager, verbose, full, rawTranscript, generate, force, searchAll, lookup, nil, summaryTimeoutSeconds); err != nil { - return trailerCheckpointError(ctx, lookup.repo, hash, cpID, err) + return trailerCheckpointError(ctx, lookup.repo, hash, cpID, linkVia, err) } return nil } @@ -830,6 +844,9 @@ func runExplainCheckpointWithLookup(ctx context.Context, w, errW io.Writer, chec // Find associated commits (git commits with matching Entire-Checkpoint trailer) associatedCommits, _ := getAssociatedCommits(ctx, lookup.repo, fullCheckpointID, searchAll) //nolint:errcheck // Best-effort + if summary != nil { + associatedCommits = withLinkedCommits(lookup.repo, associatedCommits, summary.LinkedCommits) + } // Derive author from the first associated commit (the user who made the commit). // Fall back to the committed checkpoint store for checkpoints @@ -1614,6 +1631,47 @@ func (s *summaryProgressWriter) updateLine(line string) { s.lastLine = line } +// recordedLinkSuffix marks a commit listed from a recorded link. +func recordedLinkSuffix(c associatedCommit) string { + if c.RecordedLink { + return " (recorded link, unverified)" + } + return "" +} + +func newAssociatedCommit(c *object.Commit) associatedCommit { + fullSHA := c.Hash.String() + shortSHA := fullSHA + if len(fullSHA) >= 7 { + shortSHA = fullSHA[:7] + } + return associatedCommit{ + SHA: fullSHA, + ShortSHA: shortSHA, + Message: strings.Split(c.Message, "\n")[0], + Author: c.Author.Name, + Email: c.Author.Email, + Date: c.Author.When, + } +} + +// withLinkedCommits adds the commits a checkpoint records links to (`entire +// session attach` on a pushed commit), which carry no trailer for +// getAssociatedCommits to find. A linked commit not in this clone is skipped. +func withLinkedCommits(repo *git.Repository, commits []associatedCommit, links []checkpoint.LinkedCommit) []associatedCommit { + for _, link := range links { + if slices.ContainsFunc(commits, func(c associatedCommit) bool { return c.SHA == link.SHA }) { + continue + } + if c, err := repo.CommitObject(plumbing.NewHash(link.SHA)); err == nil { + linked := newAssociatedCommit(c) + linked.RecordedLink = true + commits = append(commits, linked) + } + } + return commits +} + // getAssociatedCommits finds git commits that reference the given checkpoint ID. // Searches commits on the current branch for Entire-Checkpoint trailer matches. // When searchAll is true, uses full DAG walk with no depth limit (may be slow). @@ -1628,19 +1686,7 @@ func getAssociatedCommits(ctx context.Context, repo *git.Repository, checkpointI targetID := checkpointID.String() collectCommit := func(c *object.Commit) { - fullSHA := c.Hash.String() - shortSHA := fullSHA - if len(fullSHA) >= 7 { - shortSHA = fullSHA[:7] - } - commits = append(commits, associatedCommit{ - SHA: fullSHA, - ShortSHA: shortSHA, - Message: strings.Split(c.Message, "\n")[0], - Author: c.Author.Name, - Email: c.Author.Email, - Date: c.Author.When, - }) + commits = append(commits, newAssociatedCommit(c)) } if searchAll { @@ -2119,12 +2165,12 @@ func formatCheckpointHeader( writeRow("commits", "(none on this branch)") case len(commits) == 1: c := commits[0] - writeRow("commits", fmt.Sprintf("%s %s", c.ShortSHA, c.Message)) + writeRow("commits", fmt.Sprintf("%s %s%s", c.ShortSHA, c.Message, recordedLinkSuffix(c))) default: writeRow("commits", fmt.Sprintf("(%d)", len(commits))) for _, c := range commits { - fmt.Fprintf(&sb, " %s %s %s\n", - c.ShortSHA, c.Date.Format("2006-01-02"), c.Message) + fmt.Fprintf(&sb, " %s %s %s%s\n", + c.ShortSHA, c.Date.Format("2006-01-02"), c.Message, recordedLinkSuffix(c)) } } @@ -2731,7 +2777,7 @@ func runExplainCommit(ctx context.Context, w, errW io.Writer, commitRef string, // Delegate to checkpoint detail view, forwarding the full flag set so // --generate / --raw-transcript / --force work via --commit as well. if err := runExplainCheckpoint(ctx, w, errW, checkpointID.String(), noPager, verbose, full, rawTranscript, generate, force, searchAll, summaryTimeoutSeconds); err != nil { - return trailerCheckpointError(ctx, repo, hash, checkpointID, err) + return trailerCheckpointError(ctx, repo, hash, checkpointID, "its Entire-Checkpoint trailer", err) } return nil } @@ -2740,13 +2786,13 @@ func runExplainCommit(ctx context.Context, w, errW io.Writer, commitRef string, // Entire-Checkpoint trailer named it. Commits keep their trailers when // `entire checkpoint delete` removes a checkpoint, so a miss on an ID this // clone deleted says so; any other miss stays a plain not-found. -func trailerCheckpointError(ctx context.Context, repo *git.Repository, hash plumbing.Hash, cpID id.CheckpointID, err error) error { +func trailerCheckpointError(ctx context.Context, repo *git.Repository, hash plumbing.Hash, cpID id.CheckpointID, linkVia string, err error) error { if errors.Is(err, checkpoint.ErrCheckpointNotFound) && shouldFallBackToCommitResolution(err) && deletedFromThisClone(ctx, cpID) { - return fmt.Errorf("commit %s references checkpoint %s via its Entire-Checkpoint trailer: %w (deleted with `entire checkpoint delete`)", abbreviateCommitHash(repo, hash), cpID, checkpoint.ErrCheckpointNotFound) + return fmt.Errorf("commit %s references checkpoint %s via %s: %w (deleted with `entire checkpoint delete`)", abbreviateCommitHash(repo, hash), cpID, linkVia, checkpoint.ErrCheckpointNotFound) } // The user typed a commit, not this checkpoint ID — without the trailer // linkage the error reads as if they asked for an unknown ID. - return fmt.Errorf("commit %s references checkpoint %s via its Entire-Checkpoint trailer: %w", abbreviateCommitHash(repo, hash), cpID, err) + return fmt.Errorf("commit %s references checkpoint %s via %s: %w", abbreviateCommitHash(repo, hash), cpID, linkVia, err) } // deletedFromThisClone reports whether cpID is on the local deleted list. An diff --git a/cmd/entire/cli/integration_test/attach_test.go b/cmd/entire/cli/integration_test/attach_test.go index 36d1d46572..75d61bd50f 100644 --- a/cmd/entire/cli/integration_test/attach_test.go +++ b/cmd/entire/cli/integration_test/attach_test.go @@ -130,7 +130,7 @@ func TestAttach_ExistingCheckpoint_AddSession(t *testing.T) { } // Attach the second session - output := env.RunCLI("session", "attach", session2ID, "-a", agentClaudeCode) + output := env.RunCLI("session", "attach", session2ID, "-a", agentClaudeCode, "-f") if !strings.Contains(output, "Attached session") { t.Errorf("expected 'Attached session' in output, got:\n%s", output) @@ -203,8 +203,8 @@ func TestAttach_AlreadyTracked_NoCheckpoint(t *testing.T) { } } -// TestAttach_AlreadyTracked_HasCheckpoint tests that re-attaching a session that already -// has a checkpoint just offers to link it (no duplicate checkpoint created). +// TestAttach_AlreadyTracked_HasCheckpoint tests that re-attaching a session to the +// commit whose checkpoint already holds it changes nothing. func TestAttach_AlreadyTracked_HasCheckpoint(t *testing.T) { t.Parallel() env := NewFeatureBranchEnv(t) @@ -245,8 +245,8 @@ func TestAttach_AlreadyTracked_HasCheckpoint(t *testing.T) { // Re-attach the same session output := env.RunCLI("session", "attach", session1.ID, "-a", agentClaudeCode) - if !strings.Contains(output, "already has checkpoint") { - t.Errorf("expected 'already has checkpoint' in output, got:\n%s", output) + if !strings.Contains(output, "is already in checkpoint "+firstCpID) { + t.Errorf("expected 'is already in checkpoint %s' in output, got:\n%s", firstCpID, output) } // Verify no duplicate trailer was added diff --git a/cmd/entire/cli/strategy/checkpoint_remote.go b/cmd/entire/cli/strategy/checkpoint_remote.go index d6e1268953..bb460be4f3 100644 --- a/cmd/entire/cli/strategy/checkpoint_remote.go +++ b/cmd/entire/cli/strategy/checkpoint_remote.go @@ -324,3 +324,15 @@ func urlTargetsCheckpointRepo(url string, config *settings.CheckpointRemoteConfi } return strings.EqualFold(info.Owner+"/"+info.Repo, config.Repo) } + +// CheckpointPushTarget returns where PrePush sends checkpoint metadata for +// pushRemoteName — the checkpoint_remote URL when one is configured, otherwise +// the remote itself — and whether push_sessions turns pushing off. Callers use +// it to confirm delivery after a push, since PrePush is deliberately fail-soft. +func CheckpointPushTarget(ctx context.Context, pushRemoteName string) (target string, disabled bool) { + ps := resolvePushSettings(ctx, pushRemoteName) + if ps.checkpointURL != "" { + return ps.checkpointURL, ps.pushDisabled + } + return ps.remote, ps.pushDisabled +} diff --git a/cmd/entire/cli/strategy/manual_commit_hooks.go b/cmd/entire/cli/strategy/manual_commit_hooks.go index 45d3afcc83..9f4a563bd5 100644 --- a/cmd/entire/cli/strategy/manual_commit_hooks.go +++ b/cmd/entire/cli/strategy/manual_commit_hooks.go @@ -320,6 +320,35 @@ func isGitSequenceOperation(ctx context.Context) bool { return false } +// GitOperationInProgress names the git operation the worktree is in the middle +// of (rebase or am, cherry-pick, revert, merge, bisect), or returns "" when +// none is. A command about to move a branch must not do so mid-operation. A +// git dir that can't be read reports none, as isGitSequenceOperation does. +func GitOperationInProgress(ctx context.Context) string { + gitDir, err := GetGitDir(ctx) + if err != nil { + return "" + } + // Per-worktree markers; see isGitSequenceOperation. + root, err := gitdir.OpenAt(gitDir) + if err != nil { + return "" + } + for _, m := range []struct{ marker, op string }{ + {"rebase-merge", "a rebase"}, + {"rebase-apply", "a rebase or am"}, + {"CHERRY_PICK_HEAD", "a cherry-pick"}, + {"REVERT_HEAD", "a revert"}, + {"MERGE_HEAD", "a merge"}, + {"BISECT_LOG", "a bisect"}, + } { + if _, err := root.Lstat(m.marker); err == nil { + return m.op + } + } + return "" +} + // PrepareCommitMsg is called by the git prepare-commit-msg hook. // Adds an Entire-Checkpoint trailer to the commit message with a stable checkpoint ID. // Only adds a trailer if there's actually new session content to condense. diff --git a/docs/architecture/sessions-and-checkpoints.md b/docs/architecture/sessions-and-checkpoints.md index d526d16f62..3643c9168d 100644 --- a/docs/architecture/sessions-and-checkpoints.md +++ b/docs/architecture/sessions-and-checkpoints.md @@ -656,6 +656,77 @@ When condensing multiple concurrent sessions: - `sessions` array in `CheckpointSummary` maps each session to its file paths - `files_touched` is merged from all sessions +`entire session attach [--commit ]` (default HEAD) picks how to +link from two facts about the target commit, the same for HEAD and `--commit` +(`planAttachLink`): +- the commit already carries an `Entire-Checkpoint` trailer: the session joins + that checkpoint, and history is unchanged. If a remote branch holds the + commit, the checkpoint is pushed now (there may be no later push of the + commit to carry it); otherwise it goes with the next git push; +- a remote branch already holds the commit: the link is recorded in a new + checkpoint (or joins one an earlier attach recorded for it) and the commit + is left unchanged, so nothing needs a force-push; +- no remote holds it: the trailer is added, rewriting the commit and every + commit after it up to HEAD (`attachRewriteChain`, `rewriteWithTrailer`). + The replay reuses each tree, author and message through `git commit-tree` + and moves the branch with a compare-and-swap `update-ref`, so the worktree + and index are untouched and no commit hooks run; session state naming the + old commits is remapped through `PostRewrite`. Merges after the target, a + target off the current branch, and an operation in progress (rebase, merge, + cherry-pick, revert, bisect) are refused, as is a commit with a non-UTF-8 + encoding or extra headers, which `commit-tree` can't reproduce. "No remote + holds it" means no remote-tracking ref of any remote contains the commit, + and none of the remotes the branch pushes to (its upstream and push remotes, + else `origin`, else every remote) has a branch containing it: attach fetches + those and asks each directly, fetching only branches whose tips aren't local + (single-branch clones don't track every branch). If one can't be reached it + refuses rather than rewrite a commit it couldn't check. The checkpoint of a + pushed commit goes to the branch's remote even when only another remote + (upstream in a fork) holds the commit. + +Before writing anything attach prints what it will do (`attachWarning`): the +commits it rewrites, or the remote that holds the commit and that the +transcript is pushed now, plus author and rebase caveats. It asks on a +terminal; without one (an agent, a script) it changes nothing and exits +non-zero unless `--force` is passed, and agent-help tells agents to show the +user that output and pass `--force` only once they agree. + +A session can be attached to several commits. Each checkpoint records the +turns since the session's previous checkpoint: the window starts at the +state's `CheckpointTranscriptStart` (recorded as the checkpoint's +`checkpoint_transcript_start`, with prompts, turn count and token usage +scoped to it), and an ended session's offset then advances to the transcript +end in the agent's own position metric. A running session's offset belongs to +its hooks and is left alone. The state's `TokenUsage` stays the whole +session's. Re-attaching a session to the checkpoint that already holds it is a +no-op. + +A recorded link is `linked_commits` on the root `CheckpointSummary`: a list of +`{sha, repo}` objects (`repo` is `//` from the remote that +holds the commit, possibly empty). Unlike the import anchor below it is an +**attributing** link: the server credits a verified entry as it would a +trailer. The CLI can't verify who recorded a link (anyone who can push checkpoints +can name any commit), so `explain` and `blame`/`why` label commits and lines +found through one as unverified recorded links, and lookups ignore checkpoints +dated in the future. Unlike a trailer it names one exact commit, so it does not follow a +later rebase or amend of that commit; attach says so, and the remedy is to +attach the session to the new commit. The server +verifies it only when the authenticated checkpoint pusher is the commit's +author (otherwise it is stored as an unverified attachment; attach warns when +the local git author differs). Rewrites of the checkpoint keep existing entries +(`unionLinkedCommits`). Readers consult trailers first and fall back to +`checkpoint.CheckpointsLinkedToWithStubs` (`explain `, `blame`/`why`, +and attach itself), which also reads git-refs stubs discovered on a remote +that were minted no earlier than a day before the commit. Attach +pushes the checkpoint itself through the pre-push path, since no later push may +carry it, and confirms the remote's ref now matches before reporting success: +for a pushed commit the checkpoint is the only record of the link, so a skipped +or rejected push (push_sessions off, remote gating) is an error. A second +attach to a linked commit finds the checkpoint through every copy, fetches it +into the local store through the availability guard, and joins it. Git hooks keep writing trailers while a commit is made; nothing in +Entire rewrites a commit that a remote already holds. A CLI that predates the +field drops it if it rewrites that checkpoint's root metadata. + Checkpoints written by the import path — `entire import ` and `entire enable`'s optional history import — additionally carry a `commit_sha` (omitempty) on both the session `Metadata` and the root `CheckpointSummary`,