de99aad4d4
Hop-State: A_06FN55REVAR7VQG4BWJBCD8 Hop-Proposal: R_06FN55QTEWKH16NSR59YJYG Hop-Task: T_06FN3MBF98GWD4NA5PA1RWG Hop-Attempt: AT_06FN55BQ80H2S8WHNDD24G0
1633 lines
56 KiB
Go
1633 lines
56 KiB
Go
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(), ".hop is already tracked") {
|
|
t.Fatalf("InitProject error = %v, want tracked .hop 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 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 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(5 * 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 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 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(2 * 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 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": `<link rel="stylesheet" href="/css/hop-home.css?v=2">` + "\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"), `<link rel="stylesheet" href="/css/hop-home.css?v=3">`+"\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": `<link rel="stylesheet" href="/css/hop-home.css?v=3">` + "\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 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
|
|
}
|