package hop import ( "bytes" "context" "encoding/json" "errors" "fmt" "os" "os/exec" "path/filepath" "strings" "sync" "testing" "time" ) func TestInitPreservesGitBranchIndexAndWorkingTree(t *testing.T) { ctx := context.Background() root := t.TempDir() runGitTest(t, root, "init", "--quiet") writeTestFile(t, filepath.Join(root, "tracked.txt"), "committed\n") runGitTest(t, root, "add", "tracked.txt") runGitTest(t, root, "-c", "user.name=Test", "-c", "user.email=test@example.com", "commit", "--quiet", "-m", "initial") writeTestFile(t, filepath.Join(root, "tracked.txt"), "staged\n") runGitTest(t, root, "add", "tracked.txt") writeTestFile(t, filepath.Join(root, "tracked.txt"), "working\n") writeTestFile(t, filepath.Join(root, "untracked.txt"), "untracked\n") beforeHead := runGitTest(t, root, "rev-parse", "HEAD") beforeBranch := runGitTest(t, root, "symbolic-ref", "--short", "HEAD") beforeIndex := runGitTest(t, root, "diff", "--cached", "--binary") beforeStatus := runGitTest(t, root, "status", "--porcelain=v1") service, initial, err := InitProject(ctx, root) if err != nil { t.Fatal(err) } if got := runGitTest(t, root, "rev-parse", "HEAD"); got != beforeHead { t.Fatalf("HEAD moved from %s to %s", beforeHead, got) } if got := runGitTest(t, root, "symbolic-ref", "--short", "HEAD"); got != beforeBranch { t.Fatalf("branch changed from %s to %s", beforeBranch, got) } if got := runGitTest(t, root, "diff", "--cached", "--binary"); got != beforeIndex { t.Fatal("Hop init changed the user's index") } if got := runGitTest(t, root, "status", "--porcelain=v1"); got != beforeStatus { t.Fatalf("working status changed:\nwant %q\n got %q", beforeStatus, got) } assertTreeFiles(t, service, initial.GitCommit, map[string]string{ "tracked.txt": "working\n", "untracked.txt": "untracked\n", }) if err := service.Close(); err != nil { t.Fatal(err) } service, again, err := InitProject(ctx, root) if err != nil { t.Fatal(err) } t.Cleanup(func() { _ = service.Close() }) if again.ID != initial.ID { t.Fatalf("idempotent init created %s, want existing %s", again.ID, initial.ID) } } func TestInitRefusesAUserTrackedHopDirectory(t *testing.T) { root := t.TempDir() runGitTest(t, root, "init", "--quiet") writeTestFile(t, filepath.Join(root, ".hop", "user-owned.txt"), "do not overwrite\n") runGitTest(t, root, "add", "-f", ".hop/user-owned.txt") _, _, err := InitProject(context.Background(), root) if err == nil || !strings.Contains(err.Error(), "local .hop runtime path is already tracked") { t.Fatalf("InitProject error = %v, want tracked local-runtime refusal", err) } contents, readErr := os.ReadFile(filepath.Join(root, ".hop", "user-owned.txt")) if readErr != nil || string(contents) != "do not overwrite\n" { t.Fatalf("tracked .hop content changed: %q, %v", string(contents), readErr) } } func TestInitAllowsTrackedPortableHopRecords(t *testing.T) { root := t.TempDir() runGitTest(t, root, "init", "--quiet") writeTestFile(t, filepath.Join(root, ".hop", "records", "prompts.json"), `{"schema_version":1,"prompts":[]}`+"\n") runGitTest(t, root, "add", "-f", ".hop/records/prompts.json") service, _, err := InitProject(context.Background(), root) if err != nil { t.Fatalf("InitProject error = %v", err) } t.Cleanup(func() { _ = service.Close() }) exclude, err := os.ReadFile(filepath.Join(root, ".git", "info", "exclude")) if err != nil { t.Fatal(err) } if strings.Contains(string(exclude), "\n.hop/\n") || !strings.Contains(string(exclude), "!.hop/records/**") { t.Fatalf("portable ledger is not allowed by exclude file:\n%s", exclude) } } func TestConcurrentFirstBeginsInitializeExactlyOnce(t *testing.T) { t.Setenv("HOP_ROOT", "") root := t.TempDir() writeTestFile(t, filepath.Join(root, "base.txt"), "base\n") previousDirectory, err := os.Getwd() if err != nil { t.Fatal(err) } if err := os.Chdir(root); err != nil { t.Fatal(err) } t.Cleanup(func() { _ = os.Chdir(previousDirectory) }) type result struct { code int stdout string stderr string } const workers = 8 start := make(chan struct{}) results := make(chan result, workers) var group sync.WaitGroup for index := 0; index < workers; index++ { group.Add(1) go func(index int) { defer group.Done() <-start var stdout, stderr bytes.Buffer code := RunCLI([]string{ "begin", "--json", "--agent", fmt.Sprintf("agent-%d", index), "--session", fmt.Sprintf("session-%d", index), fmt.Sprintf("Prompt %d", index), }, &stdout, &stderr) results <- result{code: code, stdout: stdout.String(), stderr: stderr.String()} }(index) } close(start) group.Wait() close(results) var workspaces []string for result := range results { if result.code != 0 { t.Fatalf("concurrent first begin exited %d\nstdout: %s\nstderr: %s", result.code, result.stdout, result.stderr) } var response map[string]any if err := json.Unmarshal([]byte(result.stdout), &response); err != nil { t.Fatalf("decode concurrent begin output %q: %v", result.stdout, err) } data := objectField(t, response, "data") workspaces = append(workspaces, stringField(t, data, "workspace")) } for _, workspace := range workspaces { if info, err := os.Stat(workspace); err != nil || !info.IsDir() { t.Fatalf("concurrent begin workspace %q missing: %v", workspace, err) } } service, err := OpenProject(root) if err != nil { t.Fatal(err) } defer service.Close() graph, err := service.Store.Graph(context.Background(), "") if err != nil { t.Fatal(err) } initialAccepted := 0 prompts := 0 for _, row := range graph { if row.State.Kind == StateAccepted && row.State.TaskID == "" { initialAccepted++ } if row.State.Kind == StatePrompt { prompts++ } } if initialAccepted != 1 || prompts != workers { t.Fatalf("concurrent bootstrap created %d initial states and %d prompts, want 1 and %d", initialAccepted, prompts, workers) } } func TestConcurrentInitInExistingGitRepository(t *testing.T) { root := t.TempDir() runGitTest(t, root, "init", "--quiet") writeTestFile(t, filepath.Join(root, "base.txt"), "base\n") const workers = 8 start := make(chan struct{}) type result struct { stateID string err error } results := make(chan result, workers) var group sync.WaitGroup for range workers { group.Add(1) go func() { defer group.Done() <-start service, initial, err := InitProject(context.Background(), root) if service != nil { _ = service.Close() } results <- result{stateID: initial.ID, err: err} }() } close(start) group.Wait() close(results) initialID := "" for result := range results { if result.err != nil { t.Fatalf("concurrent existing-repository init: %v", result.err) } if initialID == "" { initialID = result.stateID } else if result.stateID != initialID { t.Fatalf("concurrent init returned initial states %s and %s", initialID, result.stateID) } } exclude, err := os.ReadFile(filepath.Join(root, ".git", "info", "exclude")) if err != nil { t.Fatal(err) } if count := strings.Count(string(exclude), ".hop/*\n"); count != 1 { t.Fatalf("concurrent initialization wrote .hop/* exclusion %d times", count) } } func TestProposeExportsPortablePromptLedger(t *testing.T) { ctx := context.Background() service, _ := newTestProject(t, map[string]string{"base.txt": "base\n"}) result, err := service.CreatePrompt(ctx, "Publish a portable prompt ledger", "", "codex") if err != nil { t.Fatal(err) } writeTestFile(t, filepath.Join(result.Workspace, "feature.txt"), "done\n") proposal, err := service.Propose(ctx, result.Prompt.ID, "Publish the ledger") if err != nil { t.Fatal(err) } materialized := filepath.Join(t.TempDir(), "proposal") if _, err := service.Repo.AddDetachedWorktree(ctx, materialized, proposal.Proposal.GitCommit); err != nil { t.Fatal(err) } t.Cleanup(func() { _ = service.Repo.RemoveWorktree(ctx, materialized, true) }) contents, err := os.ReadFile(filepath.Join(materialized, ".hop", "records", "prompts", result.Prompt.ID+".json")) if err != nil { t.Fatal(err) } var record PortablePromptRecord if err := json.Unmarshal(contents, &record); err != nil { t.Fatal(err) } if record.ID != result.Prompt.ID || record.Prompt != "Publish a portable prompt ledger" || record.Status != "proposed" || record.ResponseSummary != "Publish the ledger" { t.Fatalf("unexpected portable prompt record: %#v", record) } } func TestExportOmitsActiveInternalAttempt(t *testing.T) { ctx := context.Background() service, _ := newTestProject(t, map[string]string{"base.txt": "base\n"}) if _, err := service.CreatePrompt(ctx, "Read-only internal audit", "", "codex"); err != nil { t.Fatal(err) } ledger, err := service.ExportPromptLedger(ctx, service.Root) if err != nil { t.Fatal(err) } if len(ledger.Prompts) != 0 { t.Fatalf("exported active internal prompts: %#v", ledger.Prompts) } } func TestExportRemovesPreviouslyPublishedReconciliationPrompt(t *testing.T) { ctx := context.Background() service, _ := newTestProject(t, map[string]string{"base.txt": "base\n"}) result, err := service.CreatePrompt(ctx, "Resolve proposal R_TEST against accepted state A_TEST", "", "codex") if err != nil { t.Fatal(err) } recordDir := filepath.Join(service.Root, ".hop", "records", "prompts") if err := os.MkdirAll(recordDir, 0o755); err != nil { t.Fatal(err) } recordPath := filepath.Join(recordDir, result.Prompt.ID+".json") if err := os.WriteFile(recordPath, []byte("{}\n"), 0o644); err != nil { t.Fatal(err) } if _, err := service.ExportPromptLedger(ctx, service.Root); err != nil { t.Fatal(err) } if _, err := os.Stat(recordPath); !errors.Is(err, os.ErrNotExist) { t.Fatalf("internal reconciliation record still exists: %v", err) } } func TestProposeRespectsSuppressedPromptManifest(t *testing.T) { ctx := context.Background() service, _ := newTestProject(t, map[string]string{"base.txt": "base\n"}) result, err := service.CreatePrompt(ctx, "Internal controller instruction", "", "codex") if err != nil { t.Fatal(err) } manifest := fmt.Sprintf("{\"prompt_ids\":[%q]}\n", result.Prompt.ID) writeTestFile(t, filepath.Join(result.Workspace, ".hop", "records", "suppressed.json"), manifest) writeTestFile(t, filepath.Join(result.Workspace, "feature.txt"), "done\n") proposal, err := service.Propose(ctx, result.Prompt.ID, "Apply the user-facing change") if err != nil { t.Fatal(err) } materialized := filepath.Join(t.TempDir(), "proposal") if _, err := service.Repo.AddDetachedWorktree(ctx, materialized, proposal.Proposal.GitCommit); err != nil { t.Fatal(err) } t.Cleanup(func() { _ = service.Repo.RemoveWorktree(ctx, materialized, true) }) if _, err := os.Stat(filepath.Join(materialized, ".hop", "records", "prompts", result.Prompt.ID+".json")); !errors.Is(err, os.ErrNotExist) { t.Fatalf("suppressed prompt was published: %v", err) } } func TestOpenProjectWaitsForInitializationLock(t *testing.T) { service, _ := newTestProject(t, map[string]string{"base.txt": "base\n"}) root := service.Root if err := service.Close(); err != nil { t.Fatal(err) } release, err := acquireProjectLock(context.Background(), root, "init") if err != nil { t.Fatal(err) } opened := make(chan error, 1) go func() { project, openErr := OpenProject(root) if openErr == nil { openErr = project.Close() } opened <- openErr }() select { case err := <-opened: release() t.Fatalf("OpenProject returned before initialization lock release: %v", err) case <-time.After(100 * time.Millisecond): } release() select { case err := <-opened: if err != nil { t.Fatal(err) } case <-time.After(10 * time.Second): t.Fatal("OpenProject did not resume after initialization lock release") } } func TestCLIJSONWorkflow(t *testing.T) { t.Setenv("HOP_ROOT", "") root := t.TempDir() writeTestFile(t, filepath.Join(root, "base.txt"), "base\n") previousDirectory, err := os.Getwd() if err != nil { t.Fatal(err) } if err := os.Chdir(root); err != nil { t.Fatal(err) } t.Cleanup(func() { _ = os.Chdir(previousDirectory) }) runCLIJSONTest(t, []string{"init", "--json"}) started := runCLIJSONTest(t, []string{"start", "--agent", "fake", "--json", "Add CLI file"}) data := objectField(t, started, "data") prompt := objectField(t, data, "prompt") promptID := stringField(t, prompt, "id") workspace := stringField(t, data, "workspace") if promptID == "" || workspace == "" { t.Fatal("start JSON omitted prompt ID or workspace") } writeTestFile(t, filepath.Join(workspace, "cli.txt"), "cli\n") proposed := runCLIJSONTest(t, []string{"propose", "--summary", "CLI change", "--json", promptID}) proposal := objectField(t, objectField(t, proposed, "data"), "proposal") proposalID := stringField(t, proposal, "id") accepted := runCLIJSONTest(t, []string{"land", proposalID, "--json", "--", "sh", "-c", "test -f cli.txt"}) acceptedState := objectField(t, objectField(t, accepted, "data"), "state") if kind := stringField(t, acceptedState, "kind"); kind != string(StateAccepted) { t.Fatalf("landed kind = %q, want %q", kind, StateAccepted) } if contents, err := os.ReadFile(filepath.Join(root, "cli.txt")); err != nil || string(contents) != "cli\n" { t.Fatalf("visible root was not materialized: contents=%q err=%v", string(contents), err) } status := runCLIJSONTest(t, []string{"status", "--json"}) statusData := objectField(t, status, "data") head := objectField(t, statusData, "accepted_head") if stringField(t, head, "id") != stringField(t, acceptedState, "id") { t.Fatal("status accepted head does not match landed state") } if stringField(t, statusData, "root_status") != "synchronized" { t.Fatalf("root status = %q", stringField(t, statusData, "root_status")) } } func TestCLIExportOmitsActivePromptRecords(t *testing.T) { t.Setenv("HOP_ROOT", "") root := t.TempDir() writeTestFile(t, filepath.Join(root, "base.txt"), "base\n") previousDirectory, err := os.Getwd() if err != nil { t.Fatal(err) } if err := os.Chdir(root); err != nil { t.Fatal(err) } t.Cleanup(func() { _ = os.Chdir(previousDirectory) }) runCLIJSONTest(t, []string{"init", "--json"}) runCLIJSONTest(t, []string{"begin", "--agent", "codex", "--json", "Publish prompt records"}) runCLIJSONTest(t, []string{"export", "--output", ".", "--json"}) entries, err := filepath.Glob(filepath.Join(root, ".hop", "records", "prompts", "P_*.json")) if err != nil || len(entries) != 0 { t.Fatalf("portable prompt records = %v, err=%v", entries, err) } } func TestCLIBeginAutoInitializesAndContinuesCodexSession(t *testing.T) { t.Setenv("HOP_ROOT", "") root := t.TempDir() writeTestFile(t, filepath.Join(root, "base.txt"), "base\n") previousDirectory, err := os.Getwd() if err != nil { t.Fatal(err) } if err := os.Chdir(root); err != nil { t.Fatal(err) } t.Cleanup(func() { _ = os.Chdir(previousDirectory) }) firstPrompt := " Add a Desktop-safe file\nwithout changing this spacing. " started := runCLIJSONInputTest(t, []string{"begin", "--agent", "codex", "--session", "thread-test", "--heredoc", "--json"}, firstPrompt+"\n") first := objectField(t, started, "data") if initialized, _ := first["initialized"].(bool); !initialized { t.Fatal("begin did not report automatic Hop initialization") } firstState := objectField(t, first, "prompt") if got := stringField(t, firstState, "prompt"); got != firstPrompt { t.Fatalf("heredoc prompt = %q, want %q", got, firstPrompt) } firstAttempt := objectField(t, first, "attempt") workspace := stringField(t, first, "workspace") writeTestFile(t, filepath.Join(workspace, "desktop.txt"), "first turn\n") followupPrompt := "Now preserve this final newline.\n" followed := runCLIJSONInputTest(t, []string{"begin", "--agent", "codex", "--session", "thread-test", "--stdin", "--json"}, followupPrompt) second := objectField(t, followed, "data") if initialized, _ := second["initialized"].(bool); initialized { t.Fatal("follow-up begin unexpectedly reinitialized Hop") } secondState := objectField(t, second, "prompt") if got := stringField(t, secondState, "prompt"); got != followupPrompt { t.Fatalf("stdin prompt = %q, want %q", got, followupPrompt) } secondAttempt := objectField(t, second, "attempt") if stringField(t, secondAttempt, "id") != stringField(t, firstAttempt, "id") { t.Fatal("Codex session follow-up created a new attempt") } if stringField(t, second, "workspace") != workspace { t.Fatal("Codex session follow-up changed workspaces") } checkpoint := objectField(t, second, "checkpoint") if stringField(t, checkpoint, "kind") != string(StateCheckpoint) { t.Fatal("Codex session follow-up did not checkpoint prior effects") } service, err := OpenProject(root) if err != nil { t.Fatal(err) } t.Cleanup(func() { _ = service.Close() }) head, exists, err := service.Store.AgentSessionHead(context.Background(), "codex", "thread-test") if err != nil { t.Fatal(err) } if !exists || head != stringField(t, secondState, "id") { t.Fatalf("session head = %q, %v; want second prompt", head, exists) } assertTreeFiles(t, service, stringField(t, checkpoint, "git_commit"), map[string]string{ "base.txt": "base\n", "desktop.txt": "first turn\n", }) } func TestBeginRollsAcceptedReconciliationSessionToCurrentHead(t *testing.T) { ctx := context.Background() service, _ := newTestProject(t, map[string]string{"shared.txt": "color=base\n"}) session, err := service.BeginPrompt(ctx, "Use blue", "", "codex", "thread-rollover") if err != nil { t.Fatal(err) } concurrent, err := service.CreatePrompt(ctx, "Use red", "", "other") if err != nil { t.Fatal(err) } writeTestFile(t, filepath.Join(session.Workspace, "shared.txt"), "color=blue\n") writeTestFile(t, filepath.Join(concurrent.Workspace, "shared.txt"), "color=red\n") sessionProposal, err := service.Propose(ctx, session.Prompt.ID, "Blue") if err != nil { t.Fatal(err) } concurrentProposal, err := service.Propose(ctx, concurrent.Prompt.ID, "Red") if err != nil { t.Fatal(err) } if _, err := service.Land(ctx, concurrentProposal.Proposal.ID, nil); err != nil { t.Fatal(err) } if _, err := service.Land(ctx, sessionProposal.Proposal.ID, nil); err == nil { t.Fatal("stale session proposal unexpectedly landed without reconciliation") } else { var conflict *ConflictError if !errors.As(err, &conflict) { t.Fatalf("land error = %v, want ConflictError", err) } } reconciliation, err := service.Refresh(ctx, sessionProposal.Proposal.ID) if err != nil { t.Fatal(err) } reconciliationHead, exists, err := service.Store.AgentSessionHead(ctx, "codex", "thread-rollover") if err != nil { t.Fatal(err) } if !exists || reconciliationHead != reconciliation.Prompt.ID { t.Fatalf("session head = %q, %v; want reconciliation prompt %s", reconciliationHead, exists, reconciliation.Prompt.ID) } writeTestFile(t, filepath.Join(reconciliation.Workspace, "shared.txt"), "color=red-blue\n") if _, err := service.RunCheck(ctx, reconciliation.Prompt.ID, []string{ "sh", "-c", `test "$(cat shared.txt)" = "color=red-blue"`, }); err != nil { t.Fatal(err) } resolvedProposal, err := service.Propose(ctx, reconciliation.Prompt.ID, "Resolve red and blue") if err != nil { t.Fatal(err) } if _, err := service.Land(ctx, resolvedProposal.Proposal.ID, nil); err != nil { t.Fatal(err) } sourceAttempt, err := service.Store.GetAttempt(ctx, session.Attempt.ID) if err != nil { t.Fatal(err) } if sourceAttempt.Status != "completed" { t.Fatalf("source attempt status = %q, want completed", sourceAttempt.Status) } // Rollover must use the latest global accepted state, not merely this task's // accepted outcome. later, err := service.CreatePrompt(ctx, "Add a later accepted change", "", "other") if err != nil { t.Fatal(err) } writeTestFile(t, filepath.Join(later.Workspace, "later.txt"), "later\n") laterProposal, err := service.Propose(ctx, later.Prompt.ID, "Later accepted change") if err != nil { t.Fatal(err) } laterAccepted, err := service.Land(ctx, laterProposal.Proposal.ID, nil) if err != nil { t.Fatal(err) } next, err := service.BeginPrompt(ctx, "Next request", "", "codex", "thread-rollover") if err != nil { t.Fatal(err) } assertFreshRollover := func(label string, result PromptResult) { t.Helper() if result.Checkpoint != nil { t.Fatalf("%s continued accepted session with checkpoint %s", label, result.Checkpoint.ID) } if result.Attempt.ID == session.Attempt.ID || result.Attempt.ID == reconciliation.Attempt.ID { t.Fatalf("%s reused completed attempt %s", label, result.Attempt.ID) } if result.Task.ID == session.Task.ID { t.Fatalf("%s reopened accepted task %s", label, result.Task.ID) } if result.Attempt.BaseStateID != laterAccepted.State.ID { t.Fatalf("%s base = %s, want %s", label, result.Attempt.BaseStateID, laterAccepted.State.ID) } if result.Prompt.CanonicalAnchorID != laterAccepted.State.ID || result.Prompt.SourceTree != laterAccepted.State.SourceTree || result.Prompt.GitCommit != laterAccepted.State.GitCommit { t.Fatalf("%s prompt was not rooted at latest accepted head: %#v; accepted=%#v", label, result.Prompt, laterAccepted.State) } if contents, err := os.ReadFile(filepath.Join(result.Workspace, "shared.txt")); err != nil || string(contents) != "color=red-blue\n" { t.Fatalf("%s shared workspace = %q, err=%v", label, string(contents), err) } if contents, err := os.ReadFile(filepath.Join(result.Workspace, "later.txt")); err != nil || string(contents) != "later\n" { t.Fatalf("%s later workspace = %q, err=%v", label, string(contents), err) } } assertFreshRollover("normal reconciliation session", next) head, exists, err := service.Store.AgentSessionHead(ctx, "codex", "thread-rollover") if err != nil { t.Fatal(err) } if !exists || head != next.Prompt.ID { t.Fatalf("session head = %q, %v; want %s", head, exists, next.Prompt.ID) } // Older Hop builds could reactivate both records after acceptance. The // immutable accepted outcome, not these mutable statuses, must drive rollover. if err := service.Store.UpdateAttemptStatus(ctx, session.Attempt.ID, "active"); err != nil { t.Fatal(err) } if err := service.Store.UpdateTaskStatus(ctx, session.Task.ID, "active"); err != nil { t.Fatal(err) } if err := service.Store.SetAgentSessionHead(ctx, "codex", "legacy-thread-rollover", session.Prompt.ID); err != nil { t.Fatal(err) } legacyNext, err := service.BeginPrompt(ctx, "Legacy next request", "", "codex", "legacy-thread-rollover") if err != nil { t.Fatal(err) } assertFreshRollover("legacy session", legacyNext) sourceAttempt, err = service.Store.GetAttempt(ctx, session.Attempt.ID) if err != nil { t.Fatal(err) } if sourceAttempt.Status == "active" { t.Fatalf("source attempt %s was left active", sourceAttempt.ID) } head, exists, err = service.Store.AgentSessionHead(ctx, "codex", "legacy-thread-rollover") if err != nil { t.Fatal(err) } if !exists || head != legacyNext.Prompt.ID { t.Fatalf("legacy session head = %q, %v; want %s", head, exists, legacyNext.Prompt.ID) } } func TestLandRejectsProposalSupersededByFollowup(t *testing.T) { ctx := context.Background() service, initial := newTestProject(t, map[string]string{"base.txt": "base\n"}) started, err := service.BeginPrompt(ctx, "Create a change", "", "codex", "thread-stale-proposal") if err != nil { t.Fatal(err) } writeTestFile(t, filepath.Join(started.Workspace, "change.txt"), "first\n") proposal, err := service.Propose(ctx, started.Prompt.ID, "First change") if err != nil { t.Fatal(err) } followup, err := service.BeginPrompt(ctx, "Refine that change", "", "codex", "thread-stale-proposal") if err != nil { t.Fatal(err) } if followup.Attempt.ID != started.Attempt.ID || followup.Checkpoint == nil { t.Fatalf("follow-up did not advance the original attempt: %#v", followup) } if _, err := service.Land(ctx, proposal.Proposal.ID, nil); !errors.Is(err, ErrAttemptHeadChanged) { t.Fatalf("superseded proposal land error = %v, want ErrAttemptHeadChanged", err) } parents := canonicalizeParents([]Parent{ {StateID: initial.ID, Role: "canonical_parent", Order: 0}, {StateID: proposal.Proposal.ID, Role: "proposal_parent", Order: 1}, }) accepted := State{ ID: newID("a"), Kind: StateAccepted, TaskID: proposal.Proposal.TaskID, AttemptID: proposal.Proposal.AttemptID, CanonicalAnchorID: initial.ID, SourceTree: proposal.Proposal.SourceTree, GitCommit: proposal.Proposal.GitCommit, Summary: proposal.Proposal.Summary, Agent: proposal.Proposal.Agent, CreatedAt: time.Now().UTC(), Parents: parents, } accepted.Digest, err = digestState(accepted, parents) if err != nil { t.Fatal(err) } if _, err := service.Store.CASAccept(ctx, initial.ID, accepted, parents); !errors.Is(err, ErrAttemptHeadChanged) { t.Fatalf("transactional stale proposal error = %v, want ErrAttemptHeadChanged", err) } head, err := service.Store.AcceptedHead(ctx) if err != nil { t.Fatal(err) } if head.ID != initial.ID { t.Fatalf("superseded proposal advanced accepted head to %s", head.ID) } } func TestBeginKeepsUnfinishedStateCreatedAfterAcceptedOutcome(t *testing.T) { ctx := context.Background() service, _ := newTestProject(t, map[string]string{"base.txt": "base\n"}) started, err := service.BeginPrompt(ctx, "Land the first change", "", "codex", "thread-post-accept") if err != nil { t.Fatal(err) } writeTestFile(t, filepath.Join(started.Workspace, "landed.txt"), "landed\n") proposal, err := service.Propose(ctx, started.Prompt.ID, "Landed change") if err != nil { t.Fatal(err) } if _, err := service.Land(ctx, proposal.Proposal.ID, nil); err != nil { t.Fatal(err) } // Although agents must not mutate a frozen proposal, preserve any such // residual work rather than silently rolling it away. writeTestFile(t, filepath.Join(started.Workspace, "pending.txt"), "pending\n") continued, err := service.BeginPrompt(ctx, "Preserve the pending work", "", "codex", "thread-post-accept") if err != nil { t.Fatal(err) } if continued.Attempt.ID != started.Attempt.ID || continued.Checkpoint == nil { t.Fatalf("dirty accepted workspace was not preserved: %#v", continued) } assertTreeFiles(t, service, continued.Checkpoint.GitCommit, map[string]string{ "base.txt": "base\n", "landed.txt": "landed\n", "pending.txt": "pending\n", }) again, err := service.BeginPrompt(ctx, "Continue that pending work", "", "codex", "thread-post-accept") if err != nil { t.Fatal(err) } if again.Attempt.ID != started.Attempt.ID || again.Checkpoint == nil { t.Fatalf("post-acceptance prompt lineage was abandoned: %#v", again) } } func TestDoctorRejectsCommitTreeAndDigestMismatch(t *testing.T) { ctx := context.Background() service, initial := newTestProject(t, map[string]string{"base.txt": "base\n"}) emptyTree, err := service.Repo.EmptyTree(ctx) if err != nil { t.Fatal(err) } if _, err := service.Store.db.ExecContext(ctx, `UPDATE states SET source_tree = ?, digest = 'tampered' WHERE id = ?`, emptyTree, initial.ID); err != nil { t.Fatal(err) } report, err := service.Doctor(ctx, false) if err != nil { t.Fatal(err) } if report.OK { t.Fatal("doctor approved a state whose commit tree and digest were tampered") } joined := strings.Join(report.Problems, "\n") if !strings.Contains(joined, "records tree") || !strings.Contains(joined, "digest mismatch") { t.Fatalf("doctor problems did not explain both mismatches: %s", joined) } } func TestCLILandConflictReturnsAutomaticReconciliation(t *testing.T) { t.Setenv("HOP_ROOT", "") ctx := context.Background() service, _ := newTestProject(t, map[string]string{"shared.txt": "base\n"}) first, err := service.CreatePrompt(ctx, "First", "", "one") if err != nil { t.Fatal(err) } second, err := service.CreatePrompt(ctx, "Second", "", "two") if err != nil { t.Fatal(err) } writeTestFile(t, filepath.Join(first.Workspace, "shared.txt"), "first\n") writeTestFile(t, filepath.Join(second.Workspace, "shared.txt"), "second\n") firstProposal, err := service.Propose(ctx, first.Prompt.ID, "First") if err != nil { t.Fatal(err) } secondProposal, err := service.Propose(ctx, second.Prompt.ID, "Second") if err != nil { t.Fatal(err) } if _, err := service.Land(ctx, firstProposal.Proposal.ID, nil); err != nil { t.Fatal(err) } root := service.Root if err := service.Close(); err != nil { t.Fatal(err) } previousDirectory, err := os.Getwd() if err != nil { t.Fatal(err) } if err := os.Chdir(root); err != nil { t.Fatal(err) } t.Cleanup(func() { _ = os.Chdir(previousDirectory) }) var stdout, stderr bytes.Buffer code := RunCLI([]string{"land", secondProposal.Proposal.ID, "--json"}, &stdout, &stderr) if code != 20 { t.Fatalf("land exit = %d, want 20\nstdout: %s\nstderr: %s", code, stdout.String(), stderr.String()) } var response map[string]any if err := json.Unmarshal(stdout.Bytes(), &response); err != nil { t.Fatal(err) } if response["next_command"] == nil { t.Fatalf("conflict response omitted next command: %s", stdout.String()) } reconciliation := objectField(t, response, "reconciliation") prompt := objectField(t, reconciliation, "prompt") if stringField(t, prompt, "id") == "" || stringField(t, reconciliation, "workspace") == "" { t.Fatalf("reconciliation response omitted prompt/workspace: %s", stdout.String()) } if status := stringField(t, objectField(t, reconciliation, "task"), "status"); status != "reconciling" { t.Fatalf("reconciliation task status = %q, want reconciling", status) } if status := stringField(t, objectField(t, reconciliation, "attempt"), "status"); status != "reconciling" { t.Fatalf("reconciliation attempt status = %q, want reconciling", status) } conflicts, ok := reconciliation["conflicts"].([]any) if !ok || len(conflicts) != 1 || conflicts[0] != "shared.txt" { t.Fatalf("conflicts = %#v", reconciliation["conflicts"]) } if contents, err := os.ReadFile(filepath.Join(root, "shared.txt")); err != nil || string(contents) != "first\n" { t.Fatalf("conflicted land changed visible root: %q, %v", string(contents), err) } } func TestCLIVersionUsesReleaseLinkerValue(t *testing.T) { previous := Version Version = "v1.2.3" t.Cleanup(func() { Version = previous }) var stdout, stderr bytes.Buffer if code := RunCLI([]string{"version", "--json"}, &stdout, &stderr); code != 0 { t.Fatalf("version exited %d: %s", code, stderr.String()) } var response map[string]any if err := json.Unmarshal(stdout.Bytes(), &response); err != nil { t.Fatal(err) } if got, _ := response["version"].(string); got != "1.2.3" { t.Fatalf("version = %q, want 1.2.3", got) } } func TestOpenProjectInsideFinalValidationDoesNotReacquireAcceptanceLock(t *testing.T) { ctx := context.Background() service, _ := newTestProject(t, map[string]string{"base.txt": "base\n"}) release, err := acquireProjectLock(ctx, service.Root, "accept") if err != nil { t.Fatal(err) } defer release() previous, hadPrevious := os.LookupEnv("HOP_ACCEPTANCE_LOCK_HELD") if err := os.Setenv("HOP_ACCEPTANCE_LOCK_HELD", "1"); err != nil { t.Fatal(err) } t.Cleanup(func() { if hadPrevious { _ = os.Setenv("HOP_ACCEPTANCE_LOCK_HELD", previous) } else { _ = os.Unsetenv("HOP_ACCEPTANCE_LOCK_HELD") } }) opened := make(chan error, 1) go func() { nested, openErr := OpenProject(service.Root) if openErr == nil { openErr = nested.Close() } opened <- openErr }() select { case openErr := <-opened: if openErr != nil { t.Fatal(openErr) } case <-time.After(10 * time.Second): t.Fatal("OpenProject deadlocked by reacquiring the acceptance lock") } } func TestPromptFollowupAndProposalAreImmutable(t *testing.T) { ctx := context.Background() service, initial := newTestProject(t, map[string]string{"app.txt": "initial\n"}) exactPrompt := " Change the app\nwithout normalizing this text. " started, err := service.CreatePrompt(ctx, exactPrompt, "", "test-agent") if err != nil { t.Fatal(err) } if started.Prompt.SourceTree != initial.SourceTree { t.Fatalf("prompt tree = %s, want accepted tree %s", started.Prompt.SourceTree, initial.SourceTree) } if started.Prompt.Prompt != exactPrompt { t.Fatalf("stored prompt = %q, want exact %q", started.Prompt.Prompt, exactPrompt) } writeTestFile(t, filepath.Join(started.Workspace, "app.txt"), "first change\n") followup, err := service.CreatePrompt(ctx, "Use the other approach", started.Prompt.ID, "") if err != nil { t.Fatal(err) } if followup.Checkpoint == nil { t.Fatal("follow-up did not create a checkpoint") } if followup.Prompt.SourceTree != followup.Checkpoint.SourceTree { t.Fatal("follow-up prompt did not preserve the checkpoint tree") } if followup.Prompt.SourceTree == initial.SourceTree { t.Fatal("checkpoint failed to capture workspace edits") } parent, err := service.Store.ParentByRole(ctx, followup.Prompt.ID, "run_parent") if err != nil { t.Fatal(err) } if parent.StateID != followup.Checkpoint.ID { t.Fatalf("follow-up parent = %s, want checkpoint %s", parent.StateID, followup.Checkpoint.ID) } writeTestFile(t, filepath.Join(started.Workspace, "app.txt"), "proposed\n") proposed, err := service.Propose(ctx, followup.Prompt.ID, "Changed the app") if err != nil { t.Fatal(err) } frozenTree := proposed.Proposal.SourceTree writeTestFile(t, filepath.Join(started.Workspace, "app.txt"), "changed after proposal\n") stored, err := service.State(ctx, proposed.Proposal.ID) if err != nil { t.Fatal(err) } if stored.SourceTree != frozenTree { t.Fatal("proposal tree changed after later workspace edits") } } func TestCheckRunsAgainstTheRecordedCheckpointTree(t *testing.T) { ctx := context.Background() service, _ := newTestProject(t, map[string]string{"app.txt": "initial\n"}) started, err := service.CreatePrompt(ctx, "Check exact state", "", "test") if err != nil { t.Fatal(err) } writeTestFile(t, filepath.Join(started.Workspace, "app.txt"), "checkpointed\n") signal := filepath.Join(t.TempDir(), "check-started") type outcome struct { check Check err error } result := make(chan outcome, 1) go func() { check, checkErr := service.RunCheck(ctx, started.Prompt.ID, []string{ "sh", "-c", `touch "$1"; sleep 0.2; cat app.txt`, "hop-check", signal, }) result <- outcome{check: check, err: checkErr} }() deadline := time.Now().Add(5 * time.Second) for { if _, err := os.Stat(signal); err == nil { break } if time.Now().After(deadline) { t.Fatal("validation command did not start") } time.Sleep(10 * time.Millisecond) } writeTestFile(t, filepath.Join(started.Workspace, "app.txt"), "raced edit\n") finished := <-result if finished.err != nil { t.Fatal(finished.err) } if finished.check.Output != "checkpointed\n" { t.Fatalf("check observed %q, want checkpointed tree", finished.check.Output) } } func TestDisjointProposalsLandAndUndoMovesForward(t *testing.T) { ctx := context.Background() service, initial := newTestProject(t, map[string]string{"base.txt": "base\n"}) first, err := service.CreatePrompt(ctx, "Add one", "", "agent-one") if err != nil { t.Fatal(err) } second, err := service.CreatePrompt(ctx, "Add two", "", "agent-two") if err != nil { t.Fatal(err) } writeTestFile(t, filepath.Join(first.Workspace, "one.txt"), "one\n") writeTestFile(t, filepath.Join(second.Workspace, "two.txt"), "two\n") proposalOne, err := service.Propose(ctx, first.Prompt.ID, "Add one") if err != nil { t.Fatal(err) } proposalTwo, err := service.Propose(ctx, second.Prompt.ID, "Add two") if err != nil { t.Fatal(err) } acceptedOne, err := service.Accept(ctx, proposalOne.Proposal.ID, nil) if err != nil { t.Fatal(err) } acceptedTwo, err := service.Accept(ctx, proposalTwo.Proposal.ID, nil) if err != nil { t.Fatal(err) } if acceptedTwo.State.SourceTree == acceptedOne.State.SourceTree { t.Fatal("second disjoint acceptance did not change the accepted tree") } assertTreeFiles(t, service, acceptedTwo.State.GitCommit, map[string]string{ "base.txt": "base\n", "one.txt": "one\n", "two.txt": "two\n", }) undo, err := service.Undo(ctx) if err != nil { t.Fatal(err) } if undo.ID == acceptedOne.State.ID || undo.ID == acceptedTwo.State.ID { t.Fatal("undo rewrote an old state instead of creating a new one") } if undo.SourceTree != acceptedOne.State.SourceTree { t.Fatalf("undo tree = %s, want previous accepted tree %s", undo.SourceTree, acceptedOne.State.SourceTree) } if undo.SourceTree == initial.SourceTree { t.Fatal("undo erased more than the latest accepted transition") } assertTreeFiles(t, service, undo.GitCommit, map[string]string{ "base.txt": "base\n", "one.txt": "one\n", }) assertTreeMissing(t, service, undo.GitCommit, "two.txt") report, err := service.Doctor(ctx, false) if err != nil { t.Fatal(err) } if !report.OK { t.Fatalf("doctor reported problems: %#v", report.Problems) } } func TestAcceptedCommitAttributesPromptingUserAndRetainsHopCommitter(t *testing.T) { ctx := context.Background() service, _ := newTestProject(t, map[string]string{"base.txt": "base\n"}) runGitTest(t, service.Root, "config", "user.name", "Prompting User") runGitTest(t, service.Root, "config", "user.email", "prompter@example.com") attempt, err := service.CreatePrompt(ctx, "Add authored change", "", "codex") if err != nil { t.Fatal(err) } writeTestFile(t, filepath.Join(attempt.Workspace, "authored.txt"), "authored\n") proposal, err := service.Propose(ctx, attempt.Prompt.ID, "Add authored change") if err != nil { t.Fatal(err) } accepted, err := service.Accept(ctx, proposal.Proposal.ID, nil) if err != nil { t.Fatal(err) } metadata := runGitTest(t, service.Root, "show", "-s", "--format=%an <%ae>%n%cn <%ce>", accepted.State.GitCommit) want := "Prompting User \nHop " if metadata != want { t.Fatalf("accepted commit identity = %q, want %q", metadata, want) } } func TestConcurrentDisjointAcceptancesSerialize(t *testing.T) { ctx := context.Background() service, _ := newTestProject(t, map[string]string{"base.txt": "base\n"}) first, err := service.CreatePrompt(ctx, "Add alpha", "", "alpha") if err != nil { t.Fatal(err) } second, err := service.CreatePrompt(ctx, "Add beta", "", "beta") if err != nil { t.Fatal(err) } writeTestFile(t, filepath.Join(first.Workspace, "alpha.txt"), "alpha\n") writeTestFile(t, filepath.Join(second.Workspace, "beta.txt"), "beta\n") alpha, err := service.Propose(ctx, first.Prompt.ID, "Alpha") if err != nil { t.Fatal(err) } beta, err := service.Propose(ctx, second.Prompt.ID, "Beta") if err != nil { t.Fatal(err) } proposalIDs := []string{alpha.Proposal.ID, beta.Proposal.ID} errs := make(chan error, len(proposalIDs)) var wg sync.WaitGroup for _, id := range proposalIDs { wg.Add(1) go func() { defer wg.Done() _, acceptErr := service.Accept(ctx, id, nil) errs <- acceptErr }() } wg.Wait() close(errs) for acceptErr := range errs { if acceptErr != nil { t.Fatalf("concurrent acceptance: %v", acceptErr) } } head, err := service.Store.AcceptedHead(ctx) if err != nil { t.Fatal(err) } assertTreeFiles(t, service, head.GitCommit, map[string]string{ "base.txt": "base\n", "alpha.txt": "alpha\n", "beta.txt": "beta\n", }) history, err := service.History(ctx) if err != nil { t.Fatal(err) } if len(history) != 3 { t.Fatalf("canonical history has %d states, want initial plus two acceptances", len(history)) } } func TestOverlappingSameFileIndependentHunksAutoMerge(t *testing.T) { ctx := context.Background() service, _ := newTestProject(t, map[string]string{ "shared.txt": "header\nmiddle\nfooter\n", }) first, err := service.CreatePrompt(ctx, "Change header", "", "one") if err != nil { t.Fatal(err) } second, err := service.CreatePrompt(ctx, "Change footer", "", "two") if err != nil { t.Fatal(err) } writeTestFile(t, filepath.Join(first.Workspace, "shared.txt"), "HEADER\nmiddle\nfooter\n") writeTestFile(t, filepath.Join(second.Workspace, "shared.txt"), "header\nmiddle\nFOOTER\n") firstProposal, err := service.Propose(ctx, first.Prompt.ID, "Header") if err != nil { t.Fatal(err) } secondProposal, err := service.Propose(ctx, second.Prompt.ID, "Footer") if err != nil { t.Fatal(err) } if _, err := service.Accept(ctx, firstProposal.Proposal.ID, nil); err != nil { t.Fatal(err) } merged, err := service.Accept(ctx, secondProposal.Proposal.ID, []string{ "sh", "-c", `grep -qx HEADER shared.txt && grep -qx FOOTER shared.txt`, }) if err != nil { var failed *CheckFailedError if errors.As(err, &failed) { contents, showErr := service.Repo.run(ctx, nil, nil, "show", failed.Check.TreeHash+":shared.txt") t.Fatalf("mergeable same-file proposal failed validation with shared.txt=%q (show error %v): %v", contents, showErr, err) } t.Fatalf("mergeable same-file proposal was blocked: %v", err) } assertTreeFiles(t, service, merged.State.GitCommit, map[string]string{ "shared.txt": "HEADER\nmiddle\nFOOTER\n", }) if len(merged.ProposalPaths) != 1 || len(merged.CurrentPaths) != 1 || merged.ProposalPaths[0] != "shared.txt" || merged.CurrentPaths[0] != "shared.txt" { t.Fatalf("overlap audit paths were not retained: proposal=%v current=%v", merged.ProposalPaths, merged.CurrentPaths) } } func TestSnapshotCapturesRacySameSizeRewrite(t *testing.T) { ctx := context.Background() service, _ := newTestProject(t, map[string]string{"shared.txt": "lower\n"}) runGitTest(t, service.Root, "config", "core.trustctime", "false") runGitTest(t, service.Root, "config", "core.checkStat", "minimal") prompt, err := service.CreatePrompt(ctx, "Uppercase the value", "", "agent") if err != nil { t.Fatal(err) } path := filepath.Join(prompt.Workspace, "shared.txt") before, err := os.Stat(path) if err != nil { t.Fatal(err) } writeTestFile(t, path, "UPPER\n") if err := os.Chtimes(path, before.ModTime(), before.ModTime()); err != nil { t.Fatal(err) } proposal, err := service.Propose(ctx, prompt.Prompt.ID, "Uppercase") if err != nil { t.Fatal(err) } assertTreeFiles(t, service, proposal.Proposal.GitCommit, map[string]string{"shared.txt": "UPPER\n"}) } func TestSameAttemptFollowupCoalescesAlreadyAcceptedEdit(t *testing.T) { ctx := context.Background() service, initial := newTestProject(t, map[string]string{ "hop-home.css": "body { color: black; }\n", "footer.html": `` + "\n", }) first, err := service.CreatePrompt(ctx, "Add the primary color", "", "agent") if err != nil { t.Fatal(err) } writeTestFile(t, filepath.Join(first.Workspace, "hop-home.css"), "body { color: black; }\n:root { --color-primary: #724bdb; }\n") firstProposal, err := service.Propose(ctx, first.Prompt.ID, "Add primary color") if err != nil { t.Fatal(err) } if _, err := service.Land(ctx, firstProposal.Proposal.ID, nil); err != nil { t.Fatal(err) } followup, err := service.CreatePrompt(ctx, "Bust the stylesheet cache", firstProposal.Proposal.ID, "agent") if err != nil { t.Fatal(err) } writeTestFile(t, filepath.Join(followup.Workspace, "footer.html"), ``+"\n") secondProposal, err := service.Propose(ctx, followup.Prompt.ID, "Bump stylesheet cache key") if err != nil { t.Fatal(err) } if secondProposal.Proposal.CanonicalAnchorID != initial.ID { t.Fatalf("test no longer exercises stale attempt base: proposal anchor = %s, want %s", secondProposal.Proposal.CanonicalAnchorID, initial.ID) } landed, err := service.Land(ctx, secondProposal.Proposal.ID, nil) if err != nil { t.Fatalf("same-attempt follow-up was blocked: %v", err) } assertTreeFiles(t, service, landed.State.GitCommit, map[string]string{ "hop-home.css": "body { color: black; }\n:root { --color-primary: #724bdb; }\n", "footer.html": `` + "\n", }) } func TestIdenticalSameFileChangesCoalesceAutomatically(t *testing.T) { ctx := context.Background() service, _ := newTestProject(t, map[string]string{"shared.css": "body {}\n"}) first, err := service.CreatePrompt(ctx, "Add primary color", "", "one") if err != nil { t.Fatal(err) } second, err := service.CreatePrompt(ctx, "Add the same primary color", "", "two") if err != nil { t.Fatal(err) } want := "body {}\n:root { --primary: purple; }\n" writeTestFile(t, filepath.Join(first.Workspace, "shared.css"), want) writeTestFile(t, filepath.Join(second.Workspace, "shared.css"), want) firstProposal, err := service.Propose(ctx, first.Prompt.ID, "Primary color") if err != nil { t.Fatal(err) } secondProposal, err := service.Propose(ctx, second.Prompt.ID, "Same primary color") if err != nil { t.Fatal(err) } if _, err := service.Accept(ctx, firstProposal.Proposal.ID, nil); err != nil { t.Fatal(err) } secondAccepted, err := service.Accept(ctx, secondProposal.Proposal.ID, nil) if err != nil { t.Fatalf("identical same-file change was blocked: %v", err) } assertTreeFiles(t, service, secondAccepted.State.GitCommit, map[string]string{"shared.css": want}) } func TestConcurrentSameFileCompatibleAcceptancesSerializeAndMerge(t *testing.T) { ctx := context.Background() service, _ := newTestProject(t, map[string]string{"shared.txt": "one\ntwo\nthree\n"}) first, err := service.CreatePrompt(ctx, "Uppercase one", "", "one") if err != nil { t.Fatal(err) } second, err := service.CreatePrompt(ctx, "Uppercase three", "", "two") if err != nil { t.Fatal(err) } writeTestFile(t, filepath.Join(first.Workspace, "shared.txt"), "ONE\ntwo\nthree\n") writeTestFile(t, filepath.Join(second.Workspace, "shared.txt"), "one\ntwo\nTHREE\n") firstProposal, err := service.Propose(ctx, first.Prompt.ID, "One") if err != nil { t.Fatal(err) } secondProposal, err := service.Propose(ctx, second.Prompt.ID, "Three") if err != nil { t.Fatal(err) } errs := make(chan error, 2) var wg sync.WaitGroup for _, proposalID := range []string{firstProposal.Proposal.ID, secondProposal.Proposal.ID} { wg.Add(1) go func() { defer wg.Done() _, acceptErr := service.Accept(ctx, proposalID, nil) errs <- acceptErr }() } wg.Wait() close(errs) for err := range errs { if err != nil { t.Fatalf("concurrent compatible acceptance: %v", err) } } head, err := service.Store.AcceptedHead(ctx) if err != nil { t.Fatal(err) } assertTreeFiles(t, service, head.GitCommit, map[string]string{ "shared.txt": "ONE\ntwo\nTHREE\n", }) } func TestTrueConflictCreatesAgentReconciliationWorkspace(t *testing.T) { ctx := context.Background() service, _ := newTestProject(t, map[string]string{"shared.txt": "color=base\n"}) first, err := service.CreatePrompt(ctx, "Use red", "", "one") if err != nil { t.Fatal(err) } second, err := service.CreatePrompt(ctx, "Use blue with fallback", "", "two") if err != nil { t.Fatal(err) } writeTestFile(t, filepath.Join(first.Workspace, "shared.txt"), "color=red\n") writeTestFile(t, filepath.Join(second.Workspace, "shared.txt"), "color=blue\n") firstProposal, err := service.Propose(ctx, first.Prompt.ID, "Red") if err != nil { t.Fatal(err) } secondProposal, err := service.Propose(ctx, second.Prompt.ID, "Blue") if err != nil { t.Fatal(err) } if _, err := service.Land(ctx, firstProposal.Proposal.ID, nil); err != nil { t.Fatal(err) } _, err = service.Land(ctx, secondProposal.Proposal.ID, nil) var conflict *ConflictError if !errors.As(err, &conflict) { t.Fatalf("land error = %v, want ConflictError", err) } refresh, err := service.Refresh(ctx, secondProposal.Proposal.ID) if err != nil { t.Fatal(err) } if refresh.Task.ID != second.Task.ID { t.Fatalf("reconciliation escaped original task: %#v", refresh) } if refresh.Attempt.ID == second.Attempt.ID || refresh.Attempt.BaseStateID != refresh.AcceptedHead.ID { t.Fatalf("reconciliation did not receive a fresh attempt at the accepted head: %#v", refresh.Attempt) } if len(refresh.Conflicts) != 1 || refresh.Conflicts[0] != "shared.txt" { t.Fatalf("conflicts = %#v", refresh.Conflicts) } contents, err := os.ReadFile(filepath.Join(refresh.Workspace, "shared.txt")) if err != nil { t.Fatal(err) } if !bytes.Contains(contents, []byte("<<<<<<< ")) || !bytes.Contains(contents, []byte("=======")) || !bytes.Contains(contents, []byte(">>>>>>> ")) { t.Fatalf("reconciliation workspace lacks useful diff3 markers:\n%s", contents) } if _, err := service.Propose(ctx, refresh.Prompt.ID, "Unresolved"); err == nil || !strings.Contains(err.Error(), "merge markers") { t.Fatalf("unresolved proposal error = %v", err) } writeTestFile(t, filepath.Join(refresh.Workspace, "shared.txt"), "color=red-blue-fallback\n") if _, err := service.Propose(ctx, refresh.Prompt.ID, "Unchecked resolution"); err == nil || !strings.Contains(err.Error(), "must pass hop check") { t.Fatalf("unchecked reconciliation proposal error = %v", err) } if _, err := service.RunCheck(ctx, refresh.Prompt.ID, []string{ "sh", "-c", `test "$(cat shared.txt)" = "color=red-blue-fallback"`, }); err != nil { t.Fatal(err) } resolvedProposal, err := service.Propose(ctx, refresh.Prompt.ID, "Resolve both color intents") if err != nil { t.Fatal(err) } resolved, err := service.Land(ctx, resolvedProposal.Proposal.ID, []string{ "sh", "-c", `test "$(cat shared.txt)" = "color=red-blue-fallback"`, }) if err != nil { t.Fatal(err) } if contents, err := os.ReadFile(filepath.Join(service.Root, "shared.txt")); err != nil || string(contents) != "color=red-blue-fallback\n" { t.Fatalf("visible resolution = %q, err=%v", string(contents), err) } if resolved.MaterializedRoot != service.Root { t.Fatalf("resolved root = %q", resolved.MaterializedRoot) } } func TestMarkerlessModifyDeleteConflictRequiresCheckedResolution(t *testing.T) { ctx := context.Background() service, _ := newTestProject(t, map[string]string{"shared.txt": "base\n"}) modify, err := service.CreatePrompt(ctx, "Modify the shared file", "", "modifier") if err != nil { t.Fatal(err) } remove, err := service.CreatePrompt(ctx, "Remove the shared file", "", "remover") if err != nil { t.Fatal(err) } writeTestFile(t, filepath.Join(modify.Workspace, "shared.txt"), "accepted modification\n") if err := os.Remove(filepath.Join(remove.Workspace, "shared.txt")); err != nil { t.Fatal(err) } modifyProposal, err := service.Propose(ctx, modify.Prompt.ID, "Modify") if err != nil { t.Fatal(err) } removeProposal, err := service.Propose(ctx, remove.Prompt.ID, "Remove") if err != nil { t.Fatal(err) } if _, err := service.Land(ctx, modifyProposal.Proposal.ID, nil); err != nil { t.Fatal(err) } if _, err := service.Land(ctx, removeProposal.Proposal.ID, nil); err == nil { t.Fatal("modify/delete proposal unexpectedly landed without reconciliation") } refresh, err := service.Refresh(ctx, removeProposal.Proposal.ID) if err != nil { t.Fatal(err) } if _, err := service.Propose(ctx, refresh.Prompt.ID, "Unchecked structural resolution"); err == nil || !strings.Contains(err.Error(), "must pass hop check") { t.Fatalf("unchecked markerless reconciliation error = %v", err) } if err := os.Remove(filepath.Join(refresh.Workspace, "shared.txt")); err != nil && !errors.Is(err, os.ErrNotExist) { t.Fatal(err) } if _, err := service.RunCheck(ctx, refresh.Prompt.ID, []string{"sh", "-c", "test ! -e shared.txt"}); err != nil { t.Fatal(err) } resolvedProposal, err := service.Propose(ctx, refresh.Prompt.ID, "Intentionally remove shared file") if err != nil { t.Fatal(err) } if _, err := service.Land(ctx, resolvedProposal.Proposal.ID, []string{"sh", "-c", "test ! -e shared.txt"}); err != nil { t.Fatal(err) } if _, err := os.Stat(filepath.Join(service.Root, "shared.txt")); !errors.Is(err, os.ErrNotExist) { t.Fatalf("resolved visible root retained deleted file: %v", err) } } func TestOverlappingProposalAndFailedFinalCheckDoNotMoveHead(t *testing.T) { ctx := context.Background() service, _ := newTestProject(t, map[string]string{"shared.txt": "base\n"}) first, err := service.CreatePrompt(ctx, "First edit", "", "one") if err != nil { t.Fatal(err) } second, err := service.CreatePrompt(ctx, "Second edit", "", "two") if err != nil { t.Fatal(err) } writeTestFile(t, filepath.Join(first.Workspace, "shared.txt"), "first\n") writeTestFile(t, filepath.Join(second.Workspace, "shared.txt"), "second\n") firstProposal, err := service.Propose(ctx, first.Prompt.ID, "First") if err != nil { t.Fatal(err) } secondProposal, err := service.Propose(ctx, second.Prompt.ID, "Second") if err != nil { t.Fatal(err) } firstAccepted, err := service.Accept(ctx, firstProposal.Proposal.ID, nil) if err != nil { t.Fatal(err) } _, err = service.Accept(ctx, secondProposal.Proposal.ID, nil) var conflict *ConflictError if !errors.As(err, &conflict) { t.Fatalf("second acceptance error = %v, want ConflictError", err) } if len(conflict.Paths) != 1 || conflict.Paths[0] != "shared.txt" { t.Fatalf("conflict paths = %#v", conflict.Paths) } head, err := service.Store.AcceptedHead(ctx) if err != nil { t.Fatal(err) } if head.ID != firstAccepted.State.ID { t.Fatalf("blocked proposal moved accepted head to %s", head.ID) } third, err := service.CreatePrompt(ctx, "Add safe file", "", "three") if err != nil { t.Fatal(err) } writeTestFile(t, filepath.Join(third.Workspace, "safe.txt"), "safe\n") thirdProposal, err := service.Propose(ctx, third.Prompt.ID, "Safe") if err != nil { t.Fatal(err) } _, err = service.Accept(ctx, thirdProposal.Proposal.ID, []string{"sh", "-c", "exit 9"}) var checkFailed *CheckFailedError if !errors.As(err, &checkFailed) { t.Fatalf("failed validation error = %v, want CheckFailedError", err) } if checkFailed.Check.StateID == "" { t.Fatal("failed final-tree validation was not attached to a durable state") } failedState, err := service.Store.GetState(ctx, checkFailed.Check.StateID) if err != nil { t.Fatal(err) } if failedState.Kind != StateFailed || failedState.SourceTree != checkFailed.Check.TreeHash { t.Fatalf("failed state = %#v, check tree = %s", failedState, checkFailed.Check.TreeHash) } head, err = service.Store.AcceptedHead(ctx) if err != nil { t.Fatal(err) } if head.ID != firstAccepted.State.ID { t.Fatal("failed final-tree validation moved accepted head") } } func TestLandMaterializesVisibleRootWithoutMovingGitState(t *testing.T) { ctx := context.Background() root := t.TempDir() runGitTest(t, root, "init", "--quiet") writeTestFile(t, filepath.Join(root, "base.txt"), "base\n") writeTestFile(t, filepath.Join(root, "remove.txt"), "remove\n") runGitTest(t, root, "add", "base.txt", "remove.txt") runGitTest(t, root, "-c", "user.name=Test", "-c", "user.email=test@example.com", "commit", "--quiet", "-m", "initial") service, _, err := InitProject(ctx, root) if err != nil { t.Fatal(err) } t.Cleanup(func() { _ = service.Close() }) started, err := service.CreatePrompt(ctx, "Materialize the result", "", "agent") if err != nil { t.Fatal(err) } writeTestFile(t, filepath.Join(started.Workspace, "base.txt"), "landed\n") writeTestFile(t, filepath.Join(started.Workspace, "nested", "new.txt"), "new\n") if err := os.Remove(filepath.Join(started.Workspace, "remove.txt")); err != nil { t.Fatal(err) } proposal, err := service.Propose(ctx, started.Prompt.ID, "Materialized change") if err != nil { t.Fatal(err) } beforeHead := runGitTest(t, root, "rev-parse", "HEAD") beforeBranch := runGitTest(t, root, "symbolic-ref", "--short", "HEAD") beforeIndex := runGitTest(t, root, "ls-files", "--stage") result, err := service.Land(ctx, proposal.Proposal.ID, nil) if err != nil { t.Fatal(err) } if result.MaterializedRoot != service.Root { t.Fatalf("materialized root = %q, want %q", result.MaterializedRoot, service.Root) } if got := runGitTest(t, root, "rev-parse", "HEAD"); got != beforeHead { t.Fatalf("HEAD moved from %s to %s", beforeHead, got) } if got := runGitTest(t, root, "symbolic-ref", "--short", "HEAD"); got != beforeBranch { t.Fatalf("branch changed from %s to %s", beforeBranch, got) } if got := runGitTest(t, root, "ls-files", "--stage"); got != beforeIndex { t.Fatalf("real index changed:\nwant %s\n got %s", beforeIndex, got) } if contents, err := os.ReadFile(filepath.Join(root, "base.txt")); err != nil || string(contents) != "landed\n" { t.Fatalf("base.txt = %q, err=%v", string(contents), err) } if contents, err := os.ReadFile(filepath.Join(root, "nested", "new.txt")); err != nil || string(contents) != "new\n" { t.Fatalf("nested/new.txt = %q, err=%v", string(contents), err) } if _, err := os.Stat(filepath.Join(root, "remove.txt")); !errors.Is(err, os.ErrNotExist) { t.Fatalf("removed file still exists: %v", err) } status, err := service.Status(ctx) if err != nil { t.Fatal(err) } if status.RootStatus != "synchronized" || status.RootStateID != result.State.ID { t.Fatalf("root status = %#v", status) } } func TestLandAutomaticallyPushesAcceptedCommit(t *testing.T) { ctx := context.Background() service, _ := newTestProject(t, map[string]string{"base.txt": "base\n"}) remote := filepath.Join(t.TempDir(), "remote.git") runGitTest(t, service.Root, "init", "--quiet", "--bare", remote) runGitTest(t, service.Root, "remote", "add", "origin", remote) branch := runGitTest(t, service.Root, "symbolic-ref", "--short", "HEAD") started, err := service.CreatePrompt(ctx, "Publish accepted work", "", "agent") if err != nil { t.Fatal(err) } writeTestFile(t, filepath.Join(started.Workspace, "published.txt"), "published\n") proposal, err := service.Propose(ctx, started.Prompt.ID, "Published change") if err != nil { t.Fatal(err) } result, err := service.Land(ctx, proposal.Proposal.ID, nil) if err != nil { t.Fatal(err) } if result.RemotePush == nil { t.Fatal("land did not report an automatic remote push") } wantRef := "refs/heads/" + branch if result.RemotePush.Remote != "origin" || result.RemotePush.Ref != wantRef || result.RemotePush.Commit != result.State.GitCommit { t.Fatalf("automatic push = %#v", result.RemotePush) } if got := runGitTest(t, service.Root, "--git-dir", remote, "rev-parse", wantRef); got != result.State.GitCommit { t.Fatalf("remote branch = %s, want accepted commit %s", got, result.State.GitCommit) } retried, err := service.Push(ctx) if err != nil { t.Fatal(err) } if retried.Commit != result.State.GitCommit || retried.Remote != "origin" || retried.Ref != wantRef { t.Fatalf("explicit push retry = %#v", retried) } } func TestAutomaticPushReconcilesCompatibleDivergedRemote(t *testing.T) { ctx := context.Background() service, _ := newTestProject(t, map[string]string{"base.txt": "base\n"}) remote := filepath.Join(t.TempDir(), "remote.git") runGitTest(t, service.Root, "init", "--quiet", "--bare", remote) runGitTest(t, service.Root, "remote", "add", "origin", remote) branch := runGitTest(t, service.Root, "symbolic-ref", "--short", "HEAD") first, err := service.CreatePrompt(ctx, "Publish first work", "", "agent") if err != nil { t.Fatal(err) } writeTestFile(t, filepath.Join(first.Workspace, "first.txt"), "first\n") firstProposal, err := service.Propose(ctx, first.Prompt.ID, "First published change") if err != nil { t.Fatal(err) } firstLanded, err := service.Land(ctx, firstProposal.Proposal.ID, nil) if err != nil { t.Fatal(err) } other := filepath.Join(t.TempDir(), "other") runGitTest(t, service.Root, "clone", "--quiet", "--branch", branch, remote, other) writeTestFile(t, filepath.Join(other, "remote.txt"), "remote\n") runGitTest(t, other, "add", "remote.txt") runGitTest(t, other, "-c", "user.name=Remote", "-c", "user.email=remote@example.com", "commit", "--quiet", "-m", "remote change") runGitTest(t, other, "push", "--quiet", "origin", branch) remoteTip := runGitTest(t, service.Root, "--git-dir", remote, "rev-parse", "refs/heads/"+branch) if remoteTip == firstLanded.State.GitCommit { t.Fatal("test did not advance the remote independently") } second, err := service.CreatePrompt(ctx, "Publish local work", "", "agent") if err != nil { t.Fatal(err) } writeTestFile(t, filepath.Join(second.Workspace, "local.txt"), "local\n") secondProposal, err := service.Propose(ctx, second.Prompt.ID, "Local accepted change") if err != nil { t.Fatal(err) } secondLanded, err := service.Land(ctx, secondProposal.Proposal.ID, nil) if err != nil { t.Fatal(err) } if secondLanded.RemotePush == nil { t.Fatal("compatible remote advancement was not reconciled and pushed") } if got := runGitTest(t, service.Root, "--git-dir", remote, "rev-parse", "refs/heads/"+branch); got != secondLanded.State.GitCommit { t.Fatalf("remote branch = %s, want reconciled accepted commit %s", got, secondLanded.State.GitCommit) } assertTreeFiles(t, service, secondLanded.State.GitCommit, map[string]string{ "base.txt": "base\n", "first.txt": "first\n", "local.txt": "local\n", "remote.txt": "remote\n", }) parents := strings.Fields(runGitTest(t, service.Root, "show", "-s", "--format=%P", secondLanded.State.GitCommit)) if len(parents) != 2 || parents[0] != remoteTip || parents[1] != firstLanded.State.GitCommit { t.Fatalf("reconciled parents = %v, want [%s %s]", parents, remoteTip, firstLanded.State.GitCommit) } } func TestLandMaterializesEmptyUnbornRootAcrossRepeatedLands(t *testing.T) { ctx := context.Background() root := t.TempDir() service, _, err := InitProject(ctx, root) if err != nil { t.Fatal(err) } t.Cleanup(func() { _ = service.Close() }) for index, name := range []string{"one.txt", "two.txt"} { started, err := service.CreatePrompt(ctx, "Add "+name, "", "agent") if err != nil { t.Fatal(err) } writeTestFile(t, filepath.Join(started.Workspace, name), name+"\n") proposal, err := service.Propose(ctx, started.Prompt.ID, "Add "+name) if err != nil { t.Fatal(err) } if _, err := service.Land(ctx, proposal.Proposal.ID, nil); err != nil { t.Fatalf("land %d: %v", index+1, err) } } for _, name := range []string{"one.txt", "two.txt"} { contents, err := os.ReadFile(filepath.Join(service.Root, name)) if err != nil || string(contents) != name+"\n" { t.Fatalf("%s = %q, err=%v", name, string(contents), err) } } if _, exists, err := service.Repo.Head(ctx); err != nil || exists { t.Fatalf("unborn HEAD changed: exists=%v err=%v", exists, err) } if _, err := os.Stat(filepath.Join(service.Repo.GitDir(), "index")); !errors.Is(err, os.ErrNotExist) { t.Fatalf("real index was created or changed: %v", err) } } func TestAcceptLeavesVisibleRootStaleUntilSync(t *testing.T) { ctx := context.Background() service, initial := newTestProject(t, map[string]string{"base.txt": "base\n"}) started, err := service.CreatePrompt(ctx, "Add accepted file", "", "controller") if err != nil { t.Fatal(err) } writeTestFile(t, filepath.Join(started.Workspace, "accepted.txt"), "accepted\n") proposal, err := service.Propose(ctx, started.Prompt.ID, "Controller accept") if err != nil { t.Fatal(err) } accepted, err := service.Accept(ctx, proposal.Proposal.ID, nil) if err != nil { t.Fatal(err) } if accepted.MaterializedRoot != "" { t.Fatal("controller accept unexpectedly materialized the visible root") } materialized, err := service.Store.MaterializedHead(ctx) if err != nil || materialized.ID != initial.ID { t.Fatalf("controller accept moved materialized head to %s: %v", materialized.ID, err) } if _, err := os.Stat(filepath.Join(service.Root, "accepted.txt")); !errors.Is(err, os.ErrNotExist) { t.Fatalf("controller accept changed visible root: %v", err) } status, err := service.Status(ctx) if err != nil { t.Fatal(err) } if status.RootStatus != "stale" { t.Fatalf("root status = %q, want stale", status.RootStatus) } synced, err := service.Sync(ctx) if err != nil { t.Fatal(err) } if !synced.Changed || synced.State.ID != accepted.State.ID { t.Fatalf("sync result = %#v", synced) } materialized, err = service.Store.MaterializedHead(ctx) if err != nil || materialized.ID != accepted.State.ID { t.Fatalf("sync materialized head = %s, err=%v", materialized.ID, err) } if contents, err := os.ReadFile(filepath.Join(service.Root, "accepted.txt")); err != nil || string(contents) != "accepted\n" { t.Fatalf("accepted.txt = %q, err=%v", string(contents), err) } retried, err := service.Land(ctx, proposal.Proposal.ID, nil) if err != nil { t.Fatal(err) } if retried.State.ID != accepted.State.ID { t.Fatalf("retry created accepted state %s, want existing %s", retried.State.ID, accepted.State.ID) } history, err := service.History(ctx) if err != nil { t.Fatal(err) } if len(history) != 2 { t.Fatalf("retry created duplicate acceptance; history has %d states", len(history)) } } func TestLandBlocksDivergedVisibleRootBeforeAcceptance(t *testing.T) { ctx := context.Background() service, initial := newTestProject(t, map[string]string{"base.txt": "base\n"}) started, err := service.CreatePrompt(ctx, "Add landed file", "", "agent") if err != nil { t.Fatal(err) } writeTestFile(t, filepath.Join(started.Workspace, "landed.txt"), "landed\n") proposal, err := service.Propose(ctx, started.Prompt.ID, "Landed file") if err != nil { t.Fatal(err) } writeTestFile(t, filepath.Join(service.Root, "local.txt"), "do not overwrite\n") beforeRef, exists, err := service.Repo.ReadHiddenRef(ctx, acceptedRef) if err != nil || !exists { t.Fatalf("read accepted ref: exists=%v err=%v", exists, err) } _, err = service.Land(ctx, proposal.Proposal.ID, nil) var conflict *RootConflictError if !errors.As(err, &conflict) { t.Fatalf("land error = %v, want RootConflictError", err) } head, err := service.Store.AcceptedHead(ctx) if err != nil { t.Fatal(err) } if head.ID != initial.ID { t.Fatalf("blocked land moved accepted head to %s", head.ID) } afterRef, _, err := service.Repo.ReadHiddenRef(ctx, acceptedRef) if err != nil || afterRef != beforeRef { t.Fatalf("accepted ref changed from %s to %s: %v", beforeRef, afterRef, err) } if contents, err := os.ReadFile(filepath.Join(service.Root, "local.txt")); err != nil || string(contents) != "do not overwrite\n" { t.Fatalf("local file changed: %q, %v", string(contents), err) } if _, err := os.Stat(filepath.Join(service.Root, "landed.txt")); !errors.Is(err, os.ErrNotExist) { t.Fatalf("blocked land materialized proposal: %v", err) } } func TestLandBlocksDivergentRealIndexWithoutChangingIt(t *testing.T) { ctx := context.Background() root := t.TempDir() runGitTest(t, root, "init", "--quiet") writeTestFile(t, filepath.Join(root, "base.txt"), "base\n") runGitTest(t, root, "add", "base.txt") runGitTest(t, root, "-c", "user.name=Test", "-c", "user.email=test@example.com", "commit", "--quiet", "-m", "initial") service, initial, err := InitProject(ctx, root) if err != nil { t.Fatal(err) } t.Cleanup(func() { _ = service.Close() }) started, err := service.CreatePrompt(ctx, "Add landed file", "", "agent") if err != nil { t.Fatal(err) } writeTestFile(t, filepath.Join(started.Workspace, "landed.txt"), "landed\n") proposal, err := service.Propose(ctx, started.Prompt.ID, "Landed file") if err != nil { t.Fatal(err) } writeTestFile(t, filepath.Join(root, "base.txt"), "staged\n") runGitTest(t, root, "add", "base.txt") writeTestFile(t, filepath.Join(root, "base.txt"), "base\n") beforeIndex := runGitTest(t, root, "ls-files", "--stage") _, err = service.Land(ctx, proposal.Proposal.ID, nil) var conflict *RootConflictError if !errors.As(err, &conflict) { t.Fatalf("land error = %v, want RootConflictError", err) } if got := runGitTest(t, root, "ls-files", "--stage"); got != beforeIndex { t.Fatalf("real index changed:\nwant %s\n got %s", beforeIndex, got) } head, err := service.Store.AcceptedHead(ctx) if err != nil || head.ID != initial.ID { t.Fatalf("accepted head = %s, err=%v", head.ID, err) } if _, err := os.Stat(filepath.Join(root, "landed.txt")); !errors.Is(err, os.ErrNotExist) { t.Fatalf("blocked land materialized proposal: %v", err) } } func TestLandPreservesIgnoredFilesAndRejectsIgnoredDestination(t *testing.T) { t.Run("unrelated ignored content", func(t *testing.T) { ctx := context.Background() service, _ := newTestProject(t, map[string]string{".gitignore": "cache/\n", "base.txt": "base\n"}) writeTestFile(t, filepath.Join(service.Root, "cache", "private.txt"), "private\n") started, err := service.CreatePrompt(ctx, "Add visible file", "", "agent") if err != nil { t.Fatal(err) } writeTestFile(t, filepath.Join(started.Workspace, "landed.txt"), "landed\n") proposal, err := service.Propose(ctx, started.Prompt.ID, "Visible file") if err != nil { t.Fatal(err) } if _, err := service.Land(ctx, proposal.Proposal.ID, nil); err != nil { t.Fatal(err) } if contents, err := os.ReadFile(filepath.Join(service.Root, "cache", "private.txt")); err != nil || string(contents) != "private\n" { t.Fatalf("ignored file changed: %q, %v", string(contents), err) } }) t.Run("ignored destination collision", func(t *testing.T) { ctx := context.Background() service, initial := newTestProject(t, map[string]string{".gitignore": "generated\n"}) writeTestFile(t, filepath.Join(service.Root, "generated"), "private\n") started, err := service.CreatePrompt(ctx, "Generate tracked output", "", "agent") if err != nil { t.Fatal(err) } writeTestFile(t, filepath.Join(started.Workspace, ".gitignore"), "") writeTestFile(t, filepath.Join(started.Workspace, "generated"), "accepted\n") proposal, err := service.Propose(ctx, started.Prompt.ID, "Tracked output") if err != nil { t.Fatal(err) } _, err = service.Land(ctx, proposal.Proposal.ID, nil) var conflict *RootConflictError if !errors.As(err, &conflict) { t.Fatalf("land error = %v, want RootConflictError", err) } head, err := service.Store.AcceptedHead(ctx) if err != nil || head.ID != initial.ID { t.Fatalf("accepted head = %s, err=%v", head.ID, err) } if contents, err := os.ReadFile(filepath.Join(service.Root, "generated")); err != nil || string(contents) != "private\n" { t.Fatalf("ignored collision was overwritten: %q, %v", string(contents), err) } }) } func TestLandMaterializesFileDirectoryTypeChanges(t *testing.T) { ctx := context.Background() service, _ := newTestProject(t, map[string]string{ "directory/old.txt": "old\n", "file": "old file\n", }) started, err := service.CreatePrompt(ctx, "Change source entry types", "", "agent") if err != nil { t.Fatal(err) } if err := os.RemoveAll(filepath.Join(started.Workspace, "directory")); err != nil { t.Fatal(err) } writeTestFile(t, filepath.Join(started.Workspace, "directory"), "now a file\n") if err := os.Remove(filepath.Join(started.Workspace, "file")); err != nil { t.Fatal(err) } writeTestFile(t, filepath.Join(started.Workspace, "file", "new.txt"), "now a directory\n") proposal, err := service.Propose(ctx, started.Prompt.ID, "Change entry types") if err != nil { t.Fatal(err) } if _, err := service.Land(ctx, proposal.Proposal.ID, nil); err != nil { t.Fatal(err) } if contents, err := os.ReadFile(filepath.Join(service.Root, "directory")); err != nil || string(contents) != "now a file\n" { t.Fatalf("directory replacement = %q, %v", string(contents), err) } if contents, err := os.ReadFile(filepath.Join(service.Root, "file", "new.txt")); err != nil || string(contents) != "now a directory\n" { t.Fatalf("file replacement = %q, %v", string(contents), err) } } func TestFailedLandValidationDoesNotMaterializeVisibleRoot(t *testing.T) { ctx := context.Background() service, initial := newTestProject(t, map[string]string{"base.txt": "base\n"}) started, err := service.CreatePrompt(ctx, "Add invalid file", "", "agent") if err != nil { t.Fatal(err) } writeTestFile(t, filepath.Join(started.Workspace, "invalid.txt"), "invalid\n") proposal, err := service.Propose(ctx, started.Prompt.ID, "Invalid") if err != nil { t.Fatal(err) } _, err = service.Land(ctx, proposal.Proposal.ID, []string{"sh", "-c", "exit 7"}) var failed *CheckFailedError if !errors.As(err, &failed) { t.Fatalf("land error = %v, want CheckFailedError", err) } if _, err := os.Stat(filepath.Join(service.Root, "invalid.txt")); !errors.Is(err, os.ErrNotExist) { t.Fatalf("failed land materialized file: %v", err) } head, err := service.Store.AcceptedHead(ctx) if err != nil || head.ID != initial.ID { t.Fatalf("accepted head = %s, err=%v", head.ID, err) } } func newTestProject(t *testing.T, files map[string]string) (*Service, State) { t.Helper() root := t.TempDir() for path, contents := range files { writeTestFile(t, filepath.Join(root, path), contents) } service, initial, err := InitProject(context.Background(), root) if err != nil { t.Fatal(err) } t.Cleanup(func() { _ = service.Close() }) return service, initial } func writeTestFile(t *testing.T, path, contents string) { t.Helper() if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil { t.Fatal(err) } if err := os.WriteFile(path, []byte(contents), 0o644); err != nil { t.Fatal(err) } } func assertTreeFiles(t *testing.T, service *Service, commit string, expected map[string]string) { t.Helper() path := filepath.Join(t.TempDir(), "materialized") if _, err := service.Repo.AddDetachedWorktree(context.Background(), path, commit); err != nil { t.Fatal(err) } t.Cleanup(func() { _ = service.Repo.RemoveWorktree(context.Background(), path, true) }) for name, want := range expected { contents, err := os.ReadFile(filepath.Join(path, name)) if err != nil { t.Fatalf("read %s: %v", name, err) } if string(contents) != want { t.Fatalf("%s = %q, want %q", name, string(contents), want) } } } func assertTreeMissing(t *testing.T, service *Service, commit string, name string) { t.Helper() path := filepath.Join(t.TempDir(), "materialized") if _, err := service.Repo.AddDetachedWorktree(context.Background(), path, commit); err != nil { t.Fatal(err) } t.Cleanup(func() { _ = service.Repo.RemoveWorktree(context.Background(), path, true) }) if _, err := os.Stat(filepath.Join(path, name)); !errors.Is(err, os.ErrNotExist) { t.Fatalf("expected %s to be absent, stat error = %v", name, err) } } func runGitTest(t *testing.T, root string, args ...string) string { t.Helper() cmd := exec.Command("git", args...) cmd.Dir = root var stdout, stderr bytes.Buffer cmd.Stdout = &stdout cmd.Stderr = &stderr if err := cmd.Run(); err != nil { t.Fatalf("git %s: %v\n%s", strings.Join(args, " "), err, stderr.String()) } return strings.TrimSpace(stdout.String()) } func runCLIJSONTest(t *testing.T, args []string) map[string]any { t.Helper() configuredRoot, hadConfiguredRoot := os.LookupEnv("HOP_ROOT") if err := os.Unsetenv("HOP_ROOT"); err != nil { t.Fatal(err) } defer func() { if hadConfiguredRoot { _ = os.Setenv("HOP_ROOT", configuredRoot) } else { _ = os.Unsetenv("HOP_ROOT") } }() var stdout, stderr bytes.Buffer if code := RunCLI(args, &stdout, &stderr); code != 0 { t.Fatalf("hop %s exited %d\nstdout: %s\nstderr: %s", strings.Join(args, " "), code, stdout.String(), stderr.String()) } var result map[string]any if err := json.Unmarshal(stdout.Bytes(), &result); err != nil { t.Fatalf("decode JSON from hop %s: %v\n%s", strings.Join(args, " "), err, stdout.String()) } if ok, _ := result["ok"].(bool); !ok { t.Fatalf("hop %s returned non-ok JSON: %#v", strings.Join(args, " "), result) } return result } func runCLIJSONInputTest(t *testing.T, args []string, input string) map[string]any { t.Helper() configuredRoot, hadConfiguredRoot := os.LookupEnv("HOP_ROOT") if err := os.Unsetenv("HOP_ROOT"); err != nil { t.Fatal(err) } defer func() { if hadConfiguredRoot { _ = os.Setenv("HOP_ROOT", configuredRoot) } else { _ = os.Unsetenv("HOP_ROOT") } }() var stdout, stderr bytes.Buffer if code := RunCLIWithInput(args, strings.NewReader(input), &stdout, &stderr); code != 0 { t.Fatalf("hop %s exited %d\nstdout: %s\nstderr: %s", strings.Join(args, " "), code, stdout.String(), stderr.String()) } var result map[string]any if err := json.Unmarshal(stdout.Bytes(), &result); err != nil { t.Fatalf("decode JSON from hop %s: %v\n%s", strings.Join(args, " "), err, stdout.String()) } if ok, _ := result["ok"].(bool); !ok { t.Fatalf("hop %s returned non-ok JSON: %#v", strings.Join(args, " "), result) } return result } func objectField(t *testing.T, object map[string]any, key string) map[string]any { t.Helper() value, ok := object[key].(map[string]any) if !ok { t.Fatalf("field %q is not an object: %#v", key, object[key]) } return value } func stringField(t *testing.T, object map[string]any, key string) string { t.Helper() value, ok := object[key].(string) if !ok { t.Fatalf("field %q is not a string: %#v", key, object[key]) } return value }