Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
21 commits
Select commit Hold shift + click to select a range
3248cc2
fix: allow checkpoint trailers on agent-initiated sequence operations
peyton-alt Apr 2, 2026
3d87319
feat: store tree hash in checkpoint metadata
peyton-alt Apr 2, 2026
8490173
fix: add debug logging + remove unrelated files
peyton-alt Apr 3, 2026
61e0965
feat: add LinkageMetadata struct to CheckpointSummary
peyton-alt Apr 7, 2026
cce8e64
test: add GitRevParse, GitCheckout, GitRebase testutil helpers
peyton-alt Apr 7, 2026
e8242ee
feat: add ComputePatchID and ComputeFilesChangedHash to gitops
peyton-alt Apr 7, 2026
1471da7
feat: wire multi-signal linkage through condensation pipeline
peyton-alt Apr 7, 2026
99bcd27
test: add linkage round-trip tests for WriteCommitted/ReadCommitted
peyton-alt Apr 7, 2026
b41e1f7
fix: restore nolint:ireturn comments and add encoding/hex import
peyton-alt Apr 7, 2026
7c3da59
fix: restore nolint:ireturn comments and add git user config for CI
peyton-alt Apr 7, 2026
7e4d536
fix: address review feedback for multi-signal linkage
peyton-alt Apr 7, 2026
7cfa919
style: simplify ComputePatchID output parsing and fix minor issues
peyton-alt Apr 7, 2026
fcb4e4a
Narrow checkpoint linkage to tree hash and patch ID
peyton-alt Apr 13, 2026
531a4d1
Clarify commit-level linkage caching
peyton-alt Apr 13, 2026
bc8c48a
Merge pull request #945 from entireio/peyton/ent-834-linkage-cleanup
gtrrz-victor Apr 14, 2026
8571f4d
Fix sequence-operation checkpoint trailer reuse
peyton-alt Apr 15, 2026
da5cea2
Merge origin/main into peyton/ent-834-tree-hash-checkpoint-linkage
peyton-alt Apr 15, 2026
63f3c05
Tighten sequence-operation checkpoint reuse
peyton-alt Apr 15, 2026
f2fb5e7
Merge origin/main into peyton/ent-834-tree-hash-checkpoint-linkage
peyton-alt Apr 15, 2026
1334c5e
Tighten sequence-operation linkage docs and tests
peyton-alt Apr 15, 2026
2b2cb11
Merge origin/main into peyton/ent-834-tree-hash-checkpoint-linkage
peyton-alt Apr 15, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 3 additions & 2 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -385,7 +385,7 @@ The manual-commit strategy (`manual_commit*.go`) does not modify the active bran
- `manual_commit_rewind.go` - Rewind implementation: file restoration from checkpoint trees
- `manual_commit_git.go` - Git operations: checkpoint commits, tree building
- `manual_commit_logs.go` - Session log retrieval and session listing
- `manual_commit_hooks.go` - Git hook handlers (prepare-commit-msg, post-commit, post-rewrite, pre-push)
- `manual_commit_hooks.go` - Git hook handlers (prepare-commit-msg, post-commit, post-rewrite, pre-push); sequence operations like revert/cherry-pick only reuse an existing `LastCheckpointID` and never mint a fresh checkpoint ID
- `manual_commit_reset.go` - Shadow branch reset/cleanup functionality
- `session_state.go` - Package-level session state functions (`LoadSessionState`, `SaveSessionState`, `ListSessionStates`, `FindMostRecentSession`)
- `hooks.go` - Git hook installation
Expand Down Expand Up @@ -495,7 +495,7 @@ The strategy uses a **12-hex-char random checkpoint ID** (e.g., `a3b2c4d5e6f7`)
1. **Generated once per checkpoint**: When condensing session metadata to the metadata branch

2. **Added to user commits** via `Entire-Checkpoint` trailer:
- **Manual-commit**: Added via `prepare-commit-msg` hook (user can remove it before committing)
- **Manual-commit**: Added via `prepare-commit-msg` hook (user can remove it before committing). During revert/cherry-pick, the hook only reuses an existing `LastCheckpointID`; it does not create a new checkpoint ID because sequence-operation commits are not condensed immediately.

3. **Used for directory sharding** on `entire/checkpoints/v1` branch:
- Path format: `<id[:2]>/<id[2:]>/`
Expand Down Expand Up @@ -546,6 +546,7 @@ entire/checkpoints/v1 commit:

- `Entire-Checkpoint: <checkpoint-id>` - 12-hex-char ID linking to metadata on `entire/checkpoints/v1`
- Added via `prepare-commit-msg` hook; user can remove it before committing to skip linking
- During revert/cherry-pick, only an existing `LastCheckpointID` is reused; no fresh checkpoint ID is generated for the sequence-operation commit

**On shadow branch commits (`entire/<commit-hash[:7]>-<worktreeHash[:6]>`):**

Expand Down
23 changes: 23 additions & 0 deletions cmd/entire/cli/checkpoint/checkpoint.go
Original file line number Diff line number Diff line change
Expand Up @@ -304,6 +304,10 @@ type WriteCommittedOptions struct {
// - the checkpoint predates the summarization feature
Summary *Summary

// Linkage contains content-based signals for re-linking after history rewrites.
// Written to the root-level CheckpointSummary, not per-session metadata.
Linkage *LinkageMetadata

// CompactTranscript is the Entire Transcript Format (transcript.jsonl) bytes.
// Written to v2 /main ref alongside metadata. May be nil if compaction
// was not performed (unknown agent, compaction error, empty transcript).
Expand Down Expand Up @@ -453,6 +457,24 @@ type SessionFilePaths struct {
Prompt string `json:"prompt"`
}

// LinkageMetadata contains Git-native signals for limited fallback re-linking
// after git history rewrites (rebase, reword, amend, filter-branch).
// Stored at the checkpoint level (root metadata.json), not per-session.
//
// These signals are intended for limited fallback re-linking when a commit
// arrives without an Entire-Checkpoint trailer:
// 1. TreeHash match - covers: reword, amend (msg-only), filter-branch (msg-only)
// 2. PatchID match - covers: clean rebase, cherry-pick to other branch
type LinkageMetadata struct {
// TreeHash is the git tree hash of the commit (full repo snapshot).
// Survives rewrites that don't change code (reword, msg-only amend).
TreeHash string `json:"tree_hash,omitempty"`

// PatchID is the git patch-id of the commit's diff (parent->HEAD).
// Survives clean rebases, but changes if conflict resolution changes the patch.
PatchID string `json:"patch_id,omitempty"`
}

// CheckpointSummary is the root-level metadata.json for a checkpoint.
// It contains aggregated statistics from all sessions and a map of session IDs
// to their file paths. Session-specific data (including initial_attribution)
Expand Down Expand Up @@ -481,6 +503,7 @@ type CheckpointSummary struct {
Sessions []SessionFilePaths `json:"sessions"`
TokenUsage *agent.TokenUsage `json:"token_usage,omitempty"`
CombinedAttribution *InitialAttribution `json:"combined_attribution,omitempty"`
Linkage *LinkageMetadata `json:"linkage,omitempty"`
}

// SessionMetrics contains hook-provided session metrics from agents that report
Expand Down
73 changes: 73 additions & 0 deletions cmd/entire/cli/checkpoint/checkpoint_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -3792,6 +3792,79 @@ func TestWriteCommitted_SubagentTranscript_JSONLFallback(t *testing.T) {
}
}

func TestWriteCommitted_IncludesLinkage(t *testing.T) {
t.Parallel()
repo, _ := setupBranchTestRepo(t)
store := NewGitStore(repo)
checkpointID := id.MustCheckpointID("a1b2c3d4e5f6")

linkage := &LinkageMetadata{
TreeHash: "abc123def456abc123def456abc123def456abc1",
PatchID: "def456abc123def456abc123def456abc123def4",
}

err := store.WriteCommitted(context.Background(), WriteCommittedOptions{
CheckpointID: checkpointID,
SessionID: "linkage-test-session",
Strategy: "manual-commit",
Agent: agent.AgentTypeClaudeCode,
Linkage: linkage,
Transcript: redact.AlreadyRedacted([]byte(`{"type":"human","message":{"content":"test"}}` + "\n")),
FilesTouched: []string{"file.go"},
CheckpointsCount: 1,
AuthorName: "Test Author",
AuthorEmail: "test@example.com",
})
if err != nil {
t.Fatalf("WriteCommitted() error = %v", err)
}

// Read back the CheckpointSummary
summary, err := store.ReadCommitted(context.Background(), checkpointID)
if err != nil {
t.Fatalf("ReadCommitted() error = %v", err)
}
if summary.Linkage == nil {
t.Fatal("Linkage should be present in CheckpointSummary")
}
if summary.Linkage.TreeHash != linkage.TreeHash {
t.Errorf("TreeHash = %q, want %q", summary.Linkage.TreeHash, linkage.TreeHash)
}
if summary.Linkage.PatchID != linkage.PatchID {
t.Errorf("PatchID = %q, want %q", summary.Linkage.PatchID, linkage.PatchID)
}
}

func TestWriteCommitted_NilLinkageOmitted(t *testing.T) {
t.Parallel()
repo, _ := setupBranchTestRepo(t)
store := NewGitStore(repo)
checkpointID := id.MustCheckpointID("a0b1c2d3e4f5")

err := store.WriteCommitted(context.Background(), WriteCommittedOptions{
CheckpointID: checkpointID,
SessionID: "no-linkage-session",
Strategy: "manual-commit",
Agent: agent.AgentTypeClaudeCode,
Transcript: redact.AlreadyRedacted([]byte(`{"type":"human","message":{"content":"test"}}` + "\n")),
FilesTouched: []string{"file.go"},
CheckpointsCount: 1,
AuthorName: "Test Author",
AuthorEmail: "test@example.com",
})
if err != nil {
t.Fatalf("WriteCommitted() error = %v", err)
}

summary, err := store.ReadCommitted(context.Background(), checkpointID)
if err != nil {
t.Fatalf("ReadCommitted() error = %v", err)
}
if summary.Linkage != nil {
t.Errorf("Linkage should be nil when not provided, got %+v", summary.Linkage)
}
}

func TestWriteTemporaryTask_SubagentTranscript_RedactsSecrets(t *testing.T) {
// Cannot use t.Parallel() because t.Chdir is required for paths.WorktreeRoot()
tempDir := t.TempDir()
Expand Down
1 change: 1 addition & 0 deletions cmd/entire/cli/checkpoint/committed.go
Original file line number Diff line number Diff line change
Expand Up @@ -437,6 +437,7 @@ func (s *GitStore) writeCheckpointSummary(opts WriteCommittedOptions, basePath s
Sessions: sessions,
TokenUsage: tokenUsage,
CombinedAttribution: combinedAttribution,
Linkage: opts.Linkage,
}

metadataJSON, err := jsonutil.MarshalIndentWithNewline(summary, "", " ")
Expand Down
49 changes: 49 additions & 0 deletions cmd/entire/cli/gitops/diff.go
Original file line number Diff line number Diff line change
Expand Up @@ -125,3 +125,52 @@ func extractStatus(statusLine string) byte {
}
return statusField[0]
}

// ComputePatchID computes the git patch-id for the diff between two commits.
// Patch IDs are content hashes of the diff itself, independent of commit metadata
// and parent position. This means the same code change produces the same patch ID
// even after rebase (which changes the parent/base but not the diff content).
//
// For initial commits (parentHash is empty), uses --root mode.
// Returns a 40-char hex SHA1 string, or empty string for empty diffs.
func ComputePatchID(ctx context.Context, repoDir, parentHash, commitHash string) (string, error) {
var diffCmd *exec.Cmd
if parentHash == "" {
diffCmd = exec.CommandContext(ctx, "git", "diff-tree", "--root", "-p", commitHash)
} else {
diffCmd = exec.CommandContext(ctx, "git", "diff-tree", "-p", parentHash, commitHash)
}
diffCmd.Dir = repoDir

var diffOut, diffErr bytes.Buffer
diffCmd.Stdout = &diffOut
diffCmd.Stderr = &diffErr

if err := diffCmd.Run(); err != nil {
return "", fmt.Errorf("git diff-tree failed: %w: %s", err, strings.TrimSpace(diffErr.String()))
}

if diffOut.Len() == 0 {
return "", nil
}

patchIDCmd := exec.CommandContext(ctx, "git", "patch-id", "--stable")
patchIDCmd.Dir = repoDir
patchIDCmd.Stdin = &diffOut

var patchOut, patchErr bytes.Buffer
patchIDCmd.Stdout = &patchOut
patchIDCmd.Stderr = &patchErr

if err := patchIDCmd.Run(); err != nil {
return "", fmt.Errorf("git patch-id failed: %w: %s", err, strings.TrimSpace(patchErr.String()))
}

output := strings.TrimSpace(patchOut.String())
if output == "" {
return "", nil
}
// git patch-id outputs "<patch-id> <commit-id>"; we want the first field.
patchID, _, _ := strings.Cut(output, " ")
return patchID, nil
}
92 changes: 92 additions & 0 deletions cmd/entire/cli/gitops/diff_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,8 @@ import (
"path/filepath"
"sort"
"testing"

"github.com/entireio/cli/cmd/entire/cli/testutil"
)

// initTestRepo creates a temp git repo and returns its path.
Expand All @@ -31,6 +33,8 @@ func initTestRepo(t *testing.T) string {
}

run("init", "-b", "main")
run("config", "user.name", "Test")
run("config", "user.email", "test@test.com")
run("config", "commit.gpgsign", "false")

return dir
Expand Down Expand Up @@ -405,3 +409,91 @@ func TestExtractStatus(t *testing.T) {
})
}
}

func TestComputePatchID(t *testing.T) {
t.Parallel()
dir := initTestRepo(t)

writeFile(t, dir, "file.txt", "initial")
gitAdd(t, dir, "file.txt")
gitCommit(t, dir, "initial")

writeFile(t, dir, "file.txt", "modified")
gitAdd(t, dir, "file.txt")
gitCommit(t, dir, "modify file")

head := testutil.GitRevParse(t, dir, "HEAD")
parent := testutil.GitRevParse(t, dir, "HEAD~1")

patchID, err := ComputePatchID(context.Background(), dir, parent, head)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if patchID == "" {
t.Fatal("expected non-empty patch ID")
}
if len(patchID) != 40 {
t.Fatalf("expected 40-char hex, got %d chars: %s", len(patchID), patchID)
}
}

func TestComputePatchID_StableAcrossRebase(t *testing.T) {
t.Parallel()
dir := initTestRepo(t)

writeFile(t, dir, "base.txt", "base")
gitAdd(t, dir, "base.txt")
gitCommit(t, dir, "base")

testutil.GitCheckoutNewBranch(t, dir, "feature")
writeFile(t, dir, "feature.txt", "feature work")
gitAdd(t, dir, "feature.txt")
gitCommit(t, dir, "add feature")

featureHead := testutil.GitRevParse(t, dir, "HEAD")
featureParent := testutil.GitRevParse(t, dir, "HEAD~1")

patchIDBefore, err := ComputePatchID(context.Background(), dir, featureParent, featureHead)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}

testutil.GitCheckout(t, dir, "main")
writeFile(t, dir, "other.txt", "other work")
gitAdd(t, dir, "other.txt")
gitCommit(t, dir, "unrelated work on main")

testutil.GitCheckout(t, dir, "feature")
testutil.GitRebase(t, dir, "main")

rebasedHead := testutil.GitRevParse(t, dir, "HEAD")
rebasedParent := testutil.GitRevParse(t, dir, "HEAD~1")

patchIDAfter, err := ComputePatchID(context.Background(), dir, rebasedParent, rebasedHead)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}

if patchIDBefore != patchIDAfter {
t.Errorf("patch ID should survive clean rebase: before=%s, after=%s", patchIDBefore, patchIDAfter)
}
}

func TestComputePatchID_InitialCommit(t *testing.T) {
t.Parallel()
dir := initTestRepo(t)

writeFile(t, dir, "file.txt", "initial")
gitAdd(t, dir, "file.txt")
gitCommit(t, dir, "initial")

head := testutil.GitRevParse(t, dir, "HEAD")

patchID, err := ComputePatchID(context.Background(), dir, "", head)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if patchID == "" {
t.Fatal("expected non-empty patch ID for initial commit")
}
}
14 changes: 7 additions & 7 deletions cmd/entire/cli/session/phase.go
Original file line number Diff line number Diff line change
Expand Up @@ -119,8 +119,8 @@ func (a Action) String() string {
// TransitionContext provides read-only context for transitions that need
// to inspect session state without mutating it.
type TransitionContext struct {
HasFilesTouched bool // len(FilesTouched) > 0
IsRebaseInProgress bool // .git/rebase-merge/ or .git/rebase-apply/ exists
HasFilesTouched bool // len(FilesTouched) > 0
IsSequenceOperationInProgress bool // rebase/cherry-pick/revert is in progress
}

// TransitionResult holds the outcome of a state machine transition.
Expand Down Expand Up @@ -163,7 +163,7 @@ func transitionFromIdle(event Event, ctx TransitionContext) TransitionResult {
// Turn end while idle is a no-op (no active turn to end).
return TransitionResult{NewPhase: PhaseIdle}
case EventGitCommit:
if ctx.IsRebaseInProgress {
if ctx.IsSequenceOperationInProgress {
return TransitionResult{NewPhase: PhaseIdle}
}
return TransitionResult{
Expand Down Expand Up @@ -203,7 +203,7 @@ func transitionFromActive(event Event, ctx TransitionContext) TransitionResult {
Actions: []Action{ActionUpdateLastInteraction},
}
case EventGitCommit:
if ctx.IsRebaseInProgress {
if ctx.IsSequenceOperationInProgress {
return TransitionResult{NewPhase: PhaseActive}
}
return TransitionResult{
Expand Down Expand Up @@ -243,7 +243,7 @@ func transitionFromEnded(event Event, ctx TransitionContext) TransitionResult {
// Turn end while ended is a no-op.
return TransitionResult{NewPhase: PhaseEnded}
case EventGitCommit:
if ctx.IsRebaseInProgress {
if ctx.IsSequenceOperationInProgress {
return TransitionResult{NewPhase: PhaseEnded}
}
if ctx.HasFilesTouched {
Expand Down Expand Up @@ -388,12 +388,12 @@ func MermaidDiagram() string {
variants = []contextVariant{
{"[files]", TransitionContext{HasFilesTouched: true}},
{"[no files]", TransitionContext{HasFilesTouched: false}},
{"[rebase]", TransitionContext{IsRebaseInProgress: true}},
{"[rebase]", TransitionContext{IsSequenceOperationInProgress: true}},
}
case event == EventGitCommit:
variants = []contextVariant{
{"", TransitionContext{}},
{"[rebase]", TransitionContext{IsRebaseInProgress: true}},
{"[rebase]", TransitionContext{IsSequenceOperationInProgress: true}},
}
default:
variants = []contextVariant{
Expand Down
Loading
Loading