From 090173271483e4f0046747a4f0c5aa339454e813 Mon Sep 17 00:00:00 2001 From: "minion[bot]" Date: Wed, 18 Mar 2026 10:35:29 +0000 Subject: [PATCH] Content-aware carry-forward for accurate 1:1 checkpoint-to-commit mapping Automated by partio-io/minions (task: content-aware-carry-forward) Co-Authored-By: Claude --- internal/git/staged_files.go | 39 ++++ internal/hooks/carryforward.go | 103 +++++++++++ internal/hooks/carryforward_test.go | 267 ++++++++++++++++++++++++++++ internal/hooks/postcommit.go | 26 +++ internal/hooks/precommit.go | 52 +++++- 5 files changed, 478 insertions(+), 9 deletions(-) create mode 100644 internal/git/staged_files.go create mode 100644 internal/hooks/carryforward.go create mode 100644 internal/hooks/carryforward_test.go diff --git a/internal/git/staged_files.go b/internal/git/staged_files.go new file mode 100644 index 0000000..40147f2 --- /dev/null +++ b/internal/git/staged_files.go @@ -0,0 +1,39 @@ +package git + +import "strings" + +// StagedFiles returns the list of file paths staged for commit. +func StagedFiles() ([]string, error) { + out, err := execGit("diff", "--cached", "--name-only") + if err != nil { + return nil, err + } + if out == "" { + return nil, nil + } + return strings.Split(out, "\n"), nil +} + +// CommittedFiles returns the list of file paths changed in a specific commit. +func CommittedFiles(commitHash string) ([]string, error) { + out, err := execGit("diff", "--name-only", commitHash+"~1", commitHash) + if err != nil { + return nil, err + } + if out == "" { + return nil, nil + } + return strings.Split(out, "\n"), nil +} + +// UnstagedFiles returns the list of file paths with unstaged modifications. +func UnstagedFiles() ([]string, error) { + out, err := execGit("diff", "--name-only") + if err != nil { + return nil, err + } + if out == "" { + return nil, nil + } + return strings.Split(out, "\n"), nil +} diff --git a/internal/hooks/carryforward.go b/internal/hooks/carryforward.go new file mode 100644 index 0000000..98e5c01 --- /dev/null +++ b/internal/hooks/carryforward.go @@ -0,0 +1,103 @@ +package hooks + +import ( + "encoding/json" + "errors" + "os" + "path/filepath" +) + +const carryForwardFile = "carry-forward.json" + +// carryForwardState records agent-modified files not yet committed, enabling +// agent attribution to carry over to subsequent partial commits. +type carryForwardState struct { + SessionPath string `json:"session_path"` + PendingFiles []string `json:"pending_files"` + Branch string `json:"branch"` +} + +func loadCarryForward(stateDir string) (*carryForwardState, error) { + data, err := os.ReadFile(filepath.Join(stateDir, carryForwardFile)) + if errors.Is(err, os.ErrNotExist) { + return nil, nil + } + if err != nil { + return nil, err + } + var cf carryForwardState + if err := json.Unmarshal(data, &cf); err != nil { + return nil, err + } + return &cf, nil +} + +func saveCarryForward(stateDir string, cf *carryForwardState) error { + if err := os.MkdirAll(stateDir, 0o755); err != nil { + return err + } + data, err := json.Marshal(cf) + if err != nil { + return err + } + return os.WriteFile(filepath.Join(stateDir, carryForwardFile), data, 0o644) +} + +func clearCarryForward(stateDir string) { + _ = os.Remove(filepath.Join(stateDir, carryForwardFile)) +} + +// computeCarryForward returns files from allAgentFiles that were not committed. +func computeCarryForward(allAgentFiles, committedFiles []string) []string { + if len(allAgentFiles) == 0 { + return nil + } + committed := make(map[string]bool, len(committedFiles)) + for _, f := range committedFiles { + committed[f] = true + } + var pending []string + for _, f := range allAgentFiles { + if !committed[f] { + pending = append(pending, f) + } + } + return pending +} + +// checkCarryForwardActivation returns whether a carry-forward state should +// activate agent attribution for the given staged files, and the session path. +func checkCarryForwardActivation(cf *carryForwardState, stagedFiles []string) (activate bool, sessionPath string) { + if cf == nil || len(cf.PendingFiles) == 0 { + return false, "" + } + staged := make(map[string]bool, len(stagedFiles)) + for _, f := range stagedFiles { + staged[f] = true + } + for _, f := range cf.PendingFiles { + if staged[f] { + return true, cf.SessionPath + } + } + return false, "" +} + +// mergeFiles returns the union of two file slices without duplicates. +func mergeFiles(a, b []string) []string { + seen := make(map[string]bool, len(a)+len(b)) + var result []string + for _, f := range a { + if !seen[f] { + seen[f] = true + result = append(result, f) + } + } + for _, f := range b { + if !seen[f] { + seen[f] = true + result = append(result, f) + } + } + return result +} diff --git a/internal/hooks/carryforward_test.go b/internal/hooks/carryforward_test.go new file mode 100644 index 0000000..202e23d --- /dev/null +++ b/internal/hooks/carryforward_test.go @@ -0,0 +1,267 @@ +package hooks + +import ( + "testing" +) + +func TestComputeCarryForward(t *testing.T) { + tests := []struct { + name string + allAgentFiles []string + committedFiles []string + wantPending []string + }{ + { + name: "all committed - no carry-forward", + allAgentFiles: []string{"a.go", "b.go", "c.go"}, + committedFiles: []string{"a.go", "b.go", "c.go"}, + wantPending: nil, + }, + { + name: "partial commit - c.go carried forward", + allAgentFiles: []string{"a.go", "b.go", "c.go"}, + committedFiles: []string{"a.go", "b.go"}, + wantPending: []string{"c.go"}, + }, + { + name: "no agent files - nothing to carry forward", + allAgentFiles: nil, + committedFiles: []string{"a.go"}, + wantPending: nil, + }, + { + name: "multiple uncommitted files carried forward", + allAgentFiles: []string{"a.go", "b.go", "c.go", "d.go"}, + committedFiles: []string{"a.go"}, + wantPending: []string{"b.go", "c.go", "d.go"}, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := computeCarryForward(tt.allAgentFiles, tt.committedFiles) + if len(got) != len(tt.wantPending) { + t.Errorf("computeCarryForward() = %v, want %v", got, tt.wantPending) + return + } + want := make(map[string]bool, len(tt.wantPending)) + for _, f := range tt.wantPending { + want[f] = true + } + for _, f := range got { + if !want[f] { + t.Errorf("computeCarryForward() unexpected file %q in result %v", f, got) + } + } + }) + } +} + +func TestCheckCarryForwardActivation(t *testing.T) { + tests := []struct { + name string + cf *carryForwardState + stagedFiles []string + wantActivate bool + wantSessionPath string + }{ + { + name: "no carry-forward state", + cf: nil, + stagedFiles: []string{"a.go"}, + wantActivate: false, + }, + { + name: "staged file matches pending - activation", + cf: &carryForwardState{ + SessionPath: "/path/to/session", + PendingFiles: []string{"c.go"}, + }, + stagedFiles: []string{"c.go"}, + wantActivate: true, + wantSessionPath: "/path/to/session", + }, + { + name: "staged file does not match pending - no activation", + cf: &carryForwardState{ + SessionPath: "/path/to/session", + PendingFiles: []string{"c.go"}, + }, + stagedFiles: []string{"a.go"}, + wantActivate: false, + }, + { + name: "empty pending files - no activation", + cf: &carryForwardState{ + SessionPath: "/path/to/session", + PendingFiles: nil, + }, + stagedFiles: []string{"a.go"}, + wantActivate: false, + }, + { + name: "partial overlap activates", + cf: &carryForwardState{ + SessionPath: "/path/to/session", + PendingFiles: []string{"c.go", "d.go"}, + }, + stagedFiles: []string{"b.go", "c.go"}, + wantActivate: true, + wantSessionPath: "/path/to/session", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + activate, sessionPath := checkCarryForwardActivation(tt.cf, tt.stagedFiles) + if activate != tt.wantActivate { + t.Errorf("checkCarryForwardActivation() activate = %v, want %v", activate, tt.wantActivate) + } + if sessionPath != tt.wantSessionPath { + t.Errorf("checkCarryForwardActivation() sessionPath = %q, want %q", sessionPath, tt.wantSessionPath) + } + }) + } +} + +func TestLoadSaveCarryForward(t *testing.T) { + dir := t.TempDir() + + // Load from empty dir returns nil, no error. + cf, err := loadCarryForward(dir) + if err != nil { + t.Fatalf("loadCarryForward on empty dir: %v", err) + } + if cf != nil { + t.Error("expected nil carry-forward initially") + } + + // Save and reload. + original := &carryForwardState{ + SessionPath: "/path/to/session", + PendingFiles: []string{"c.go", "d.go"}, + Branch: "main", + } + if err := saveCarryForward(dir, original); err != nil { + t.Fatalf("saveCarryForward: %v", err) + } + + loaded, err := loadCarryForward(dir) + if err != nil { + t.Fatalf("loadCarryForward after save: %v", err) + } + if loaded == nil { + t.Fatal("expected carry-forward state after save") + } + if loaded.SessionPath != original.SessionPath { + t.Errorf("SessionPath = %q, want %q", loaded.SessionPath, original.SessionPath) + } + if loaded.Branch != original.Branch { + t.Errorf("Branch = %q, want %q", loaded.Branch, original.Branch) + } + if len(loaded.PendingFiles) != len(original.PendingFiles) { + t.Errorf("PendingFiles len = %d, want %d", len(loaded.PendingFiles), len(original.PendingFiles)) + } + + // Clear and verify gone. + clearCarryForward(dir) + cleared, err := loadCarryForward(dir) + if err != nil { + t.Fatalf("loadCarryForward after clear: %v", err) + } + if cleared != nil { + t.Error("expected nil carry-forward after clear") + } +} + +func TestCarryForwardPartialCommitScenario(t *testing.T) { + // Scenario: agent modifies A, B, C; user commits only A and B. + // Expect: carry-forward has C pending. + allAgentFiles := []string{"a.go", "b.go", "c.go"} + committedFiles := []string{"a.go", "b.go"} + + pending := computeCarryForward(allAgentFiles, committedFiles) + + if len(pending) != 1 || pending[0] != "c.go" { + t.Errorf("expected carry-forward=[c.go], got %v", pending) + } + + // Scenario: next commit stages C; carry-forward should activate. + cf := &carryForwardState{ + SessionPath: "/sessions/abc", + PendingFiles: pending, + Branch: "main", + } + + activate, sessionPath := checkCarryForwardActivation(cf, []string{"c.go"}) + if !activate { + t.Error("expected carry-forward to activate for second commit") + } + if sessionPath != "/sessions/abc" { + t.Errorf("expected sessionPath=/sessions/abc, got %q", sessionPath) + } + + // After committing C, carry-forward should be empty. + remaining := computeCarryForward(pending, []string{"c.go"}) + if len(remaining) != 0 { + t.Errorf("expected no remaining files after committing C, got %v", remaining) + } +} + +func TestCarryForwardSingleCommitNoResidue(t *testing.T) { + // Scenario: agent modifies A, B, C and user commits all at once. + // Expect: no carry-forward. + allAgentFiles := []string{"a.go", "b.go", "c.go"} + committedFiles := []string{"a.go", "b.go", "c.go"} + + pending := computeCarryForward(allAgentFiles, committedFiles) + if len(pending) != 0 { + t.Errorf("expected no carry-forward for single full commit, got %v", pending) + } +} + +func TestMergeFiles(t *testing.T) { + tests := []struct { + name string + a, b []string + want []string + }{ + { + name: "no overlap", + a: []string{"a.go", "b.go"}, + b: []string{"c.go"}, + want: []string{"a.go", "b.go", "c.go"}, + }, + { + name: "with overlap - deduplicated", + a: []string{"a.go", "b.go"}, + b: []string{"b.go", "c.go"}, + want: []string{"a.go", "b.go", "c.go"}, + }, + { + name: "empty b", + a: []string{"a.go"}, + b: nil, + want: []string{"a.go"}, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := mergeFiles(tt.a, tt.b) + if len(got) != len(tt.want) { + t.Errorf("mergeFiles() = %v, want %v", got, tt.want) + return + } + wantSet := make(map[string]bool, len(tt.want)) + for _, f := range tt.want { + wantSet[f] = true + } + for _, f := range got { + if !wantSet[f] { + t.Errorf("mergeFiles() unexpected file %q", f) + } + } + }) + } +} diff --git a/internal/hooks/postcommit.go b/internal/hooks/postcommit.go index 0cd65b1..c1fd8e4 100644 --- a/internal/hooks/postcommit.go +++ b/internal/hooks/postcommit.go @@ -141,6 +141,32 @@ func runPostCommit(repoRoot string, cfg config.Config) error { return fmt.Errorf("writing checkpoint: %w", err) } + // Update carry-forward state: persist any agent-modified files not in this commit. + stateDir := filepath.Join(repoRoot, config.PartioDir, "state") + if len(state.AllAgentFiles) > 0 { + committedFiles, err := git.CommittedFiles(commitHash) + if err != nil { + slog.Warn("could not get committed files for carry-forward", "error", err) + } else { + pending := computeCarryForward(state.AllAgentFiles, committedFiles) + if len(pending) > 0 { + cf := &carryForwardState{ + SessionPath: state.SessionPath, + PendingFiles: pending, + Branch: state.Branch, + } + if err := saveCarryForward(stateDir, cf); err != nil { + slog.Warn("could not save carry-forward state", "error", err) + } + } else { + clearCarryForward(stateDir) + } + } + } else if state.IsCarryForward { + // Carry-forward was activated and all pending files were committed. + clearCarryForward(stateDir) + } + slog.Debug("checkpoint created", "id", cpID, "agent_pct", attr.AgentPercent) return nil } diff --git a/internal/hooks/precommit.go b/internal/hooks/precommit.go index 6752572..5ffa9ef 100644 --- a/internal/hooks/precommit.go +++ b/internal/hooks/precommit.go @@ -13,10 +13,12 @@ import ( // preCommitState records the state captured during pre-commit for use by post-commit. type preCommitState struct { - AgentActive bool `json:"agent_active"` - SessionPath string `json:"session_path,omitempty"` - PreCommitHash string `json:"pre_commit_hash,omitempty"` - Branch string `json:"branch"` + AgentActive bool `json:"agent_active"` + SessionPath string `json:"session_path,omitempty"` + PreCommitHash string `json:"pre_commit_hash,omitempty"` + Branch string `json:"branch"` + AllAgentFiles []string `json:"all_agent_files,omitempty"` // staged + unstaged agent-modified files + IsCarryForward bool `json:"is_carry_forward,omitempty"` // activated by carry-forward } // PreCommit runs pre-commit hook logic. @@ -49,15 +51,47 @@ func runPreCommit(repoRoot string, cfg config.Config) error { branch, _ := git.CurrentBranch() commitHash, _ := git.CurrentCommit() + agentActive := running && sessionPath != "" + + // Collect all agent-modified files (staged + unstaged) when agent is active. + var allAgentFiles []string + if agentActive { + staged, _ := git.StagedFiles() + unstaged, _ := git.UnstagedFiles() + allAgentFiles = mergeFiles(staged, unstaged) + } + + // Check carry-forward state: if a previous partial commit left pending files + // that overlap with the current staged set, activate agent attribution. + isCarryForward := false + stateDir := filepath.Join(repoRoot, config.PartioDir, "state") + if cf, err := loadCarryForward(stateDir); err == nil && cf != nil { + staged, _ := git.StagedFiles() + if activate, cfSessionPath := checkCarryForwardActivation(cf, staged); activate { + if !agentActive { + // Agent not running but carry-forward applies: use carry-forward session. + agentActive = true + sessionPath = cfSessionPath + isCarryForward = true + allAgentFiles = cf.PendingFiles + } else { + // Both agent active and carry-forward: merge file sets. + isCarryForward = true + allAgentFiles = mergeFiles(allAgentFiles, cf.PendingFiles) + } + } + } + state := preCommitState{ - AgentActive: running && sessionPath != "", - SessionPath: sessionPath, - PreCommitHash: commitHash, - Branch: branch, + AgentActive: agentActive, + SessionPath: sessionPath, + PreCommitHash: commitHash, + Branch: branch, + AllAgentFiles: allAgentFiles, + IsCarryForward: isCarryForward, } // Save state for post-commit - stateDir := filepath.Join(repoRoot, config.PartioDir, "state") if err := os.MkdirAll(stateDir, 0o755); err != nil { return err }