Resolve alpha4 integration while preserving accepted Hop behavior
Hop-State: A_06FN4YF9MKR7SWE1Y7GZDJR Hop-Proposal: R_06FN4YEJF4PMSYPMQ7T3SBG Hop-Task: T_06FN3MBF98GWD4NA5PA1RWG Hop-Attempt: AT_06FN4XMZP6EPRVNAAQWA8K8
This commit is contained in:
+56
-2
@@ -22,6 +22,7 @@ Usage:
|
||||
hop propose [--summary TEXT] STATE
|
||||
hop accept STATE [-- COMMAND [ARG...]]
|
||||
hop land STATE [-- COMMAND [ARG...]]
|
||||
hop refresh PROPOSAL
|
||||
hop sync
|
||||
hop status
|
||||
hop graph
|
||||
@@ -38,7 +39,7 @@ Usage:
|
||||
Add --json anywhere for machine-readable output.
|
||||
`
|
||||
|
||||
const Version = "0.1.0-alpha.3"
|
||||
const Version = "0.1.0-alpha.4"
|
||||
|
||||
func RunCLI(args []string, stdout, stderr io.Writer) int {
|
||||
return RunCLIWithInput(args, os.Stdin, stdout, stderr)
|
||||
@@ -266,6 +267,28 @@ func RunCLIWithInput(args []string, stdin io.Reader, stdout, stderr io.Writer) i
|
||||
result, err := service.Land(ctx, stateID, argv)
|
||||
value = result
|
||||
if err != nil {
|
||||
var conflict *ConflictError
|
||||
if errors.As(err, &conflict) {
|
||||
refresh, refreshErr := service.Refresh(ctx, stateID)
|
||||
if refreshErr != nil {
|
||||
preparationErr := fmt.Errorf("automatic merge conflict detected (%v), but reconciliation preparation failed: %v", err, refreshErr)
|
||||
return printCLIError(preparationErr, jsonOutput, stdout, stderr)
|
||||
}
|
||||
if jsonOutput {
|
||||
writeJSON(stdout, map[string]any{
|
||||
"ok": false,
|
||||
"error": err.Error(),
|
||||
"exit_code": 20,
|
||||
"conflict": conflict,
|
||||
"reconciliation": refresh,
|
||||
"next_command": "resolve the returned workspace, then check, propose, and land using the returned prompt state",
|
||||
})
|
||||
return 20
|
||||
}
|
||||
fmt.Fprintf(stderr, "hop: %v\n", err)
|
||||
printRefreshSummary(stdout, refresh)
|
||||
return 20
|
||||
}
|
||||
return printCLIError(err, jsonOutput, stdout, stderr)
|
||||
}
|
||||
if !jsonOutput {
|
||||
@@ -279,6 +302,20 @@ func RunCLIWithInput(args []string, stdin io.Reader, stdout, stderr io.Writer) i
|
||||
}
|
||||
}
|
||||
|
||||
case "refresh", "reconcile":
|
||||
if len(commandArgs) != 1 {
|
||||
fmt.Fprintln(stderr, "usage: hop refresh PROPOSAL")
|
||||
return 2
|
||||
}
|
||||
result, err := service.Refresh(ctx, commandArgs[0])
|
||||
value = result
|
||||
if err != nil {
|
||||
return printCLIError(err, jsonOutput, stdout, stderr)
|
||||
}
|
||||
if !jsonOutput {
|
||||
printRefreshSummary(stdout, result)
|
||||
}
|
||||
|
||||
case "sync":
|
||||
if len(commandArgs) != 0 {
|
||||
fmt.Fprintln(stderr, "usage: hop sync")
|
||||
@@ -529,7 +566,7 @@ func printCLIError(err error, jsonOutput bool, stdout, stderr io.Writer) int {
|
||||
} else {
|
||||
fmt.Fprintf(stderr, "hop: %v\n", err)
|
||||
if conflict != nil && len(conflict.Paths) > 0 {
|
||||
fmt.Fprintln(stderr, "Overlapping paths:")
|
||||
fmt.Fprintln(stderr, "Merge conflicts:")
|
||||
for _, path := range conflict.Paths {
|
||||
fmt.Fprintf(stderr, " %s\n", path)
|
||||
}
|
||||
@@ -544,6 +581,23 @@ func printCLIError(err error, jsonOutput bool, stdout, stderr io.Writer) int {
|
||||
return code
|
||||
}
|
||||
|
||||
func printRefreshSummary(w io.Writer, result RefreshResult) {
|
||||
action := "Prepared"
|
||||
if result.Reused {
|
||||
action = "Reusing"
|
||||
}
|
||||
fmt.Fprintf(w, "%s reconciliation prompt %s\n", action, result.Prompt.ID)
|
||||
fmt.Fprintf(w, "Reconciliation attempt: %s\n", result.Attempt.ID)
|
||||
fmt.Fprintf(w, "Workspace: %s\n", result.Workspace)
|
||||
fmt.Fprintf(w, "Accepted input: %s · commit %s\n", result.AcceptedHead.ID, shortHash(result.AcceptedHead.GitCommit))
|
||||
fmt.Fprintf(w, "Proposal input: %s · commit %s\n", result.Proposal.ID, shortHash(result.Proposal.GitCommit))
|
||||
fmt.Fprintln(w, "Resolve these genuine merge conflicts while preserving both intents (structural conflicts may have no text markers):")
|
||||
for _, path := range result.Conflicts {
|
||||
fmt.Fprintf(w, " %s\n", path)
|
||||
}
|
||||
fmt.Fprintf(w, "Continue automatically with: hop check %s -- <test-command>, then propose and land again.\n", result.Prompt.ID)
|
||||
}
|
||||
|
||||
func removeFlag(args []string, wanted string) (bool, []string) {
|
||||
found := false
|
||||
filtered := make([]string, 0, len(args))
|
||||
|
||||
@@ -559,6 +559,126 @@ func (s *Store) CreateTaskAttemptPrompt(
|
||||
return task, attempt, prompt, nil
|
||||
}
|
||||
|
||||
// CreateAttemptPrompt atomically adds a new attempt and its initial prompt to
|
||||
// an existing task. Reconciliation uses a fresh workspace so a frozen proposal
|
||||
// and any concurrent work in its original attempt are never overwritten.
|
||||
func (s *Store) CreateAttemptPrompt(
|
||||
ctx context.Context,
|
||||
attempt Attempt,
|
||||
prompt State,
|
||||
parents []Parent,
|
||||
) (Attempt, State, error) {
|
||||
now := time.Now().UTC()
|
||||
if attempt.ID == "" {
|
||||
attempt.ID = newID("at")
|
||||
}
|
||||
if attempt.TaskID == "" {
|
||||
return Attempt{}, State{}, errors.New("hop: reconciliation attempt requires a task ID")
|
||||
}
|
||||
if attempt.BaseStateID == "" {
|
||||
return Attempt{}, State{}, errors.New("hop: reconciliation attempt requires a base state")
|
||||
}
|
||||
if strings.TrimSpace(attempt.Workspace) == "" {
|
||||
return Attempt{}, State{}, errors.New("hop: reconciliation attempt requires a workspace")
|
||||
}
|
||||
if attempt.CreatedAt.IsZero() {
|
||||
attempt.CreatedAt = now
|
||||
}
|
||||
if attempt.Status == "" {
|
||||
attempt.Status = "reconciling"
|
||||
}
|
||||
if prompt.ID == "" {
|
||||
prompt.ID = newID("p")
|
||||
}
|
||||
if prompt.Kind == "" {
|
||||
prompt.Kind = StatePrompt
|
||||
}
|
||||
if prompt.Kind != StatePrompt {
|
||||
return Attempt{}, State{}, fmt.Errorf("hop: attempt instruction must have kind %q", StatePrompt)
|
||||
}
|
||||
if prompt.TaskID == "" {
|
||||
prompt.TaskID = attempt.TaskID
|
||||
}
|
||||
if prompt.TaskID != attempt.TaskID {
|
||||
return Attempt{}, State{}, errors.New("hop: prompt task does not match reconciliation attempt")
|
||||
}
|
||||
if prompt.AttemptID == "" {
|
||||
prompt.AttemptID = attempt.ID
|
||||
}
|
||||
if prompt.AttemptID != attempt.ID {
|
||||
return Attempt{}, State{}, errors.New("hop: prompt attempt does not match reconciliation attempt")
|
||||
}
|
||||
if prompt.Agent == "" {
|
||||
prompt.Agent = attempt.Agent
|
||||
}
|
||||
if prompt.CreatedAt.IsZero() {
|
||||
prompt.CreatedAt = now
|
||||
}
|
||||
|
||||
tx, err := s.db.BeginTx(ctx, nil)
|
||||
if err != nil {
|
||||
return Attempt{}, State{}, fmt.Errorf("begin reconciliation attempt creation: %w", err)
|
||||
}
|
||||
defer tx.Rollback()
|
||||
|
||||
var taskExists int
|
||||
if err := tx.QueryRowContext(ctx, `SELECT 1 FROM tasks WHERE id = ?`, attempt.TaskID).Scan(&taskExists); err != nil {
|
||||
return Attempt{}, State{}, dbNotFound("task", attempt.TaskID, err)
|
||||
}
|
||||
base, err := stateTx(ctx, tx, attempt.BaseStateID)
|
||||
if err != nil {
|
||||
return Attempt{}, State{}, fmt.Errorf("read reconciliation base state: %w", err)
|
||||
}
|
||||
if prompt.CanonicalAnchorID == "" {
|
||||
prompt.CanonicalAnchorID = attempt.BaseStateID
|
||||
}
|
||||
if prompt.SourceTree == "" {
|
||||
prompt.SourceTree = base.SourceTree
|
||||
}
|
||||
if prompt.GitCommit == "" {
|
||||
prompt.GitCommit = base.GitCommit
|
||||
}
|
||||
parents = chooseParents(prompt.Parents, parents)
|
||||
parents = ensureParentRole(parents, "run_parent", attempt.BaseStateID)
|
||||
parents = ensureParentRole(parents, "canonical_anchor", prompt.CanonicalAnchorID)
|
||||
parents = canonicalizeParents(parents)
|
||||
prompt.Parents = parents
|
||||
attempt.HeadStateID = prompt.ID
|
||||
|
||||
if _, err := tx.ExecContext(ctx,
|
||||
`INSERT INTO attempts(id, task_id, agent, workspace, base_state_id, head_state_id, status, created_at)
|
||||
VALUES (?, ?, ?, ?, ?, NULL, ?, ?)`,
|
||||
attempt.ID, attempt.TaskID, attempt.Agent, attempt.Workspace, attempt.BaseStateID,
|
||||
attempt.Status, formatTime(attempt.CreatedAt)); err != nil {
|
||||
return Attempt{}, State{}, fmt.Errorf("insert reconciliation attempt: %w", err)
|
||||
}
|
||||
if err := insertStateTx(ctx, tx, prompt, parents); err != nil {
|
||||
return Attempt{}, State{}, err
|
||||
}
|
||||
if _, err := tx.ExecContext(ctx,
|
||||
`UPDATE attempts SET head_state_id = ? WHERE id = ?`, prompt.ID, attempt.ID); err != nil {
|
||||
return Attempt{}, State{}, fmt.Errorf("install reconciliation attempt head: %w", err)
|
||||
}
|
||||
if _, err := tx.ExecContext(ctx,
|
||||
`UPDATE tasks SET status = 'reconciling' WHERE id = ?`, attempt.TaskID); err != nil {
|
||||
return Attempt{}, State{}, fmt.Errorf("mark task reconciling: %w", err)
|
||||
}
|
||||
if err := insertEventTx(ctx, tx, Event{
|
||||
Kind: "attempt.created", TaskID: attempt.TaskID, AttemptID: attempt.ID,
|
||||
}, map[string]string{"purpose": "reconciliation"}); err != nil {
|
||||
return Attempt{}, State{}, err
|
||||
}
|
||||
if err := insertEventTx(ctx, tx, Event{
|
||||
Kind: "prompt.created", TaskID: attempt.TaskID, AttemptID: attempt.ID, StateID: prompt.ID,
|
||||
}, map[string]string{"purpose": "reconciliation"}); err != nil {
|
||||
return Attempt{}, State{}, err
|
||||
}
|
||||
if err := tx.Commit(); err != nil {
|
||||
return Attempt{}, State{}, fmt.Errorf("commit reconciliation attempt creation: %w", err)
|
||||
}
|
||||
return attempt, prompt, nil
|
||||
}
|
||||
|
||||
// AppendState atomically appends an immutable state and compare-and-swaps the
|
||||
// owning attempt's head. An empty expectedAttemptHeadID means "the head observed
|
||||
// by this transaction"; callers that already observed a head should pass it.
|
||||
@@ -829,6 +949,66 @@ func (s *Store) AcceptedForProposal(ctx context.Context, proposalID string) (Sta
|
||||
return state, true, nil
|
||||
}
|
||||
|
||||
// ReconciliationPrompt returns the prompt already created to reconcile a
|
||||
// proposal against a particular accepted head. The pair is unique by
|
||||
// convention and makes `hop refresh` safe to retry.
|
||||
func (s *Store) ReconciliationPrompt(ctx context.Context, proposalID, acceptedID string) (State, bool, error) {
|
||||
var id string
|
||||
err := s.db.QueryRowContext(ctx,
|
||||
`SELECT s.id
|
||||
FROM states s
|
||||
JOIN state_parents source
|
||||
ON source.state_id = s.id
|
||||
AND source.parent_state_id = ?
|
||||
AND source.role = 'reconciliation_source'
|
||||
JOIN state_parents anchor
|
||||
ON anchor.state_id = s.id
|
||||
AND anchor.parent_state_id = ?
|
||||
AND anchor.role = 'canonical_anchor'
|
||||
WHERE s.kind = 'prompt'
|
||||
ORDER BY s.created_at DESC, s.id DESC
|
||||
LIMIT 1`, proposalID, acceptedID).Scan(&id)
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
return State{}, false, nil
|
||||
}
|
||||
if err != nil {
|
||||
return State{}, false, fmt.Errorf("find reconciliation prompt: %w", err)
|
||||
}
|
||||
state, err := s.GetState(ctx, id)
|
||||
if err != nil {
|
||||
return State{}, false, err
|
||||
}
|
||||
return state, true, nil
|
||||
}
|
||||
|
||||
// ReconciliationPromptForAttempt identifies attempts created specifically to
|
||||
// resolve a prior proposal. It lets proposal creation enforce reconciliation
|
||||
// evidence even when the caller names a later checkpoint instead of the
|
||||
// attempt's initial prompt.
|
||||
func (s *Store) ReconciliationPromptForAttempt(ctx context.Context, attemptID string) (State, bool, error) {
|
||||
var id string
|
||||
err := s.db.QueryRowContext(ctx,
|
||||
`SELECT s.id
|
||||
FROM states s
|
||||
JOIN state_parents source
|
||||
ON source.state_id = s.id
|
||||
AND source.role = 'reconciliation_source'
|
||||
WHERE s.attempt_id = ? AND s.kind = 'prompt'
|
||||
ORDER BY s.created_at, s.id
|
||||
LIMIT 1`, attemptID).Scan(&id)
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
return State{}, false, nil
|
||||
}
|
||||
if err != nil {
|
||||
return State{}, false, fmt.Errorf("find reconciliation prompt for attempt %s: %w", attemptID, err)
|
||||
}
|
||||
state, err := s.GetState(ctx, id)
|
||||
if err != nil {
|
||||
return State{}, false, err
|
||||
}
|
||||
return state, true, nil
|
||||
}
|
||||
|
||||
// MaterializedHead is the accepted state currently projected into the visible
|
||||
// project root. It may trail AcceptedHead after controller-only acceptance or
|
||||
// an interrupted post-acceptance synchronization.
|
||||
|
||||
+115
-37
@@ -131,7 +131,7 @@ func (s *GitStore) Ensure(ctx context.Context, path string) (*Repository, error)
|
||||
|
||||
if info, statErr := os.Stat(path); statErr == nil && !info.IsDir() {
|
||||
if root, findErr := s.FindRoot(ctx, filepath.Dir(path)); findErr == nil {
|
||||
return s.openRoot(ctx, root, true)
|
||||
return s.openRootForEnsure(ctx, root)
|
||||
}
|
||||
return nil, fmt.Errorf("cannot initialize a repository at non-directory %q", path)
|
||||
} else if statErr != nil && !errors.Is(statErr, os.ErrNotExist) {
|
||||
@@ -139,18 +139,46 @@ func (s *GitStore) Ensure(ctx context.Context, path string) (*Repository, error)
|
||||
}
|
||||
|
||||
if root, findErr := s.FindRoot(ctx, path); findErr == nil {
|
||||
return s.openRoot(ctx, root, true)
|
||||
return s.openRootForEnsure(ctx, root)
|
||||
}
|
||||
|
||||
if err := os.MkdirAll(path, 0o755); err != nil {
|
||||
return nil, fmt.Errorf("create repository directory: %w", err)
|
||||
}
|
||||
lockPath, err := repositoryInitLockPath(path)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
release, err := acquireFileLock(ctx, lockPath, "Hop repository initialization")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer release()
|
||||
|
||||
// A concurrent Hop process may have initialized this directory while this
|
||||
// process waited for the per-user bootstrap lock.
|
||||
if root, findErr := s.FindRoot(ctx, path); findErr == nil {
|
||||
return s.openRoot(ctx, root, true)
|
||||
}
|
||||
if _, err := s.run(ctx, path, nil, nil, "init", "--quiet", path); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return s.openRoot(ctx, path, true)
|
||||
}
|
||||
|
||||
func (s *GitStore) openRootForEnsure(ctx context.Context, root string) (*Repository, error) {
|
||||
lockPath, err := repositoryInitLockPath(root)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
release, err := acquireFileLock(ctx, lockPath, "Hop repository initialization")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer release()
|
||||
return s.openRoot(ctx, root, true)
|
||||
}
|
||||
|
||||
// Open opens the non-bare repository containing path.
|
||||
func (s *GitStore) Open(ctx context.Context, path string) (*Repository, error) {
|
||||
root, err := s.FindRoot(ctx, path)
|
||||
@@ -293,6 +321,15 @@ func (r *Repository) SnapshotWithOptions(ctx context.Context, options SnapshotOp
|
||||
if _, err := r.run(ctx, env, nil, "add", "-A", "--", "."); err != nil {
|
||||
return "", "", fmt.Errorf("snapshot workspace: %w", err)
|
||||
}
|
||||
// The disposable index is copied from the worktree index and therefore
|
||||
// carries its stat cache. An agent can rewrite a file to the same size within
|
||||
// the filesystem timestamp granularity, making a plain `git add -A`
|
||||
// intermittently treat fresh content as unchanged. Renormalizing forces Git
|
||||
// to hash every tracked path again while retaining additions/deletions staged
|
||||
// by the first pass.
|
||||
if _, err := r.run(ctx, env, nil, "add", "--renormalize", "--", "."); err != nil {
|
||||
return "", "", fmt.Errorf("rehash workspace snapshot: %w", err)
|
||||
}
|
||||
treeOutput, err := r.run(ctx, env, nil, "write-tree")
|
||||
if err != nil {
|
||||
return "", "", fmt.Errorf("write snapshot tree: %w", err)
|
||||
@@ -880,55 +917,85 @@ func (r *Repository) TrackedPaths(ctx context.Context, path string) ([]string, e
|
||||
return paths, nil
|
||||
}
|
||||
|
||||
// ComposeTrees conservatively composes the changes base->theirs onto ours.
|
||||
// It uses Git's three-tree index merge, so a path independently changed to
|
||||
// different content is reported as a conflict instead of being rewritten. No
|
||||
// worktree or user index is read or changed. On conflict, tree is empty and the
|
||||
// sorted conflicting path set is returned with a nil error.
|
||||
// ComposeTrees performs Git's real three-way merge without touching a
|
||||
// worktree or index. Independent hunks in the same file, identical changes,
|
||||
// renames, and compatible mode/content changes merge automatically. On a
|
||||
// genuine conflict, tree is the best-effort conflict-marker tree and conflicts
|
||||
// lists the paths an agent must reconcile.
|
||||
func (r *Repository) ComposeTrees(ctx context.Context, base, ours, theirs string) (tree string, conflicts []string, err error) {
|
||||
baseTree, err := r.resolveTree(ctx, base)
|
||||
baseCommit, err := r.resolveCommit(ctx, base)
|
||||
if err != nil {
|
||||
return "", nil, fmt.Errorf("resolve base tree: %w", err)
|
||||
return "", nil, fmt.Errorf("resolve base commit: %w", err)
|
||||
}
|
||||
oursTree, err := r.resolveTree(ctx, ours)
|
||||
oursCommit, err := r.resolveCommit(ctx, ours)
|
||||
if err != nil {
|
||||
return "", nil, fmt.Errorf("resolve current tree: %w", err)
|
||||
return "", nil, fmt.Errorf("resolve current commit: %w", err)
|
||||
}
|
||||
theirsTree, err := r.resolveTree(ctx, theirs)
|
||||
theirsCommit, err := r.resolveCommit(ctx, theirs)
|
||||
if err != nil {
|
||||
return "", nil, fmt.Errorf("resolve proposal tree: %w", err)
|
||||
return "", nil, fmt.Errorf("resolve proposal commit: %w", err)
|
||||
}
|
||||
|
||||
indexPath, cleanup, err := r.temporaryIndex(false)
|
||||
if err != nil {
|
||||
return "", nil, err
|
||||
output, mergeErr := r.run(ctx, []string{"GIT_OPTIONAL_LOCKS=0"}, nil,
|
||||
"-c", "merge.conflictStyle=zdiff3",
|
||||
"-c", "merge.renames=true",
|
||||
"-c", "merge.directoryRenames=conflict",
|
||||
"merge-tree", "--write-tree", "--merge-base", baseCommit,
|
||||
"--name-only", "-z", "--no-messages", oursCommit, theirsCommit,
|
||||
)
|
||||
fields := splitNull([]byte(output))
|
||||
if len(fields) == 0 || fields[0] == "" {
|
||||
if mergeErr != nil {
|
||||
return "", nil, fmt.Errorf("compose trees: %w", mergeErr)
|
||||
}
|
||||
return "", nil, errors.New("git merge-tree returned no tree")
|
||||
}
|
||||
defer cleanup()
|
||||
env := []string{"GIT_INDEX_FILE=" + indexPath, "GIT_OPTIONAL_LOCKS=0"}
|
||||
// --aggressive resolves only additional provably-trivial cases (for example,
|
||||
// a deletion on one side while the other side is unchanged). It does not
|
||||
// attempt a content merge of independently modified files.
|
||||
_, mergeErr := r.run(ctx, env, nil, "read-tree", "-m", "--aggressive", baseTree, oursTree, theirsTree)
|
||||
|
||||
unmergedOutput, listErr := r.run(ctx, env, nil, "ls-files", "-u", "-z")
|
||||
if listErr == nil {
|
||||
conflicts = parseUnmergedPaths([]byte(unmergedOutput))
|
||||
tree = fields[0]
|
||||
if err := validObjectName(tree); err != nil {
|
||||
return "", nil, fmt.Errorf("invalid composed tree: %w", err)
|
||||
}
|
||||
if len(conflicts) > 0 {
|
||||
return "", conflicts, nil
|
||||
conflictSet := map[string]struct{}{}
|
||||
for _, path := range fields[1:] {
|
||||
if path != "" {
|
||||
conflictSet[path] = struct{}{}
|
||||
}
|
||||
}
|
||||
if mergeErr != nil {
|
||||
for path := range conflictSet {
|
||||
conflicts = append(conflicts, path)
|
||||
}
|
||||
sort.Strings(conflicts)
|
||||
if mergeErr == nil {
|
||||
if len(conflicts) > 0 {
|
||||
return "", nil, errors.New("git merge-tree reported conflict paths with a successful exit")
|
||||
}
|
||||
return tree, nil, nil
|
||||
}
|
||||
if gitExitCode(mergeErr) != 1 {
|
||||
return "", nil, fmt.Errorf("compose trees: %w", mergeErr)
|
||||
}
|
||||
if listErr != nil {
|
||||
return "", nil, fmt.Errorf("inspect composed index: %w", listErr)
|
||||
if len(conflicts) == 0 {
|
||||
// Git documents conflict classes (notably some directory-renames) that
|
||||
// produce no conflicted-file list. Give the agent the changed-path union
|
||||
// as useful reconciliation candidates instead of converting a real merge
|
||||
// conflict into an internal error.
|
||||
for _, side := range []string{oursCommit, theirsCommit} {
|
||||
paths, pathsErr := r.ChangedPaths(ctx, baseCommit, side)
|
||||
if pathsErr != nil {
|
||||
return "", nil, fmt.Errorf("derive structural conflict paths: %w", pathsErr)
|
||||
}
|
||||
for _, path := range paths {
|
||||
conflictSet[path] = struct{}{}
|
||||
}
|
||||
}
|
||||
for path := range conflictSet {
|
||||
conflicts = append(conflicts, path)
|
||||
}
|
||||
sort.Strings(conflicts)
|
||||
if len(conflicts) == 0 {
|
||||
conflicts = []string{"(structural merge conflict; inspect both inputs)"}
|
||||
}
|
||||
}
|
||||
|
||||
treeOutput, err := r.run(ctx, env, nil, "write-tree")
|
||||
if err != nil {
|
||||
return "", nil, fmt.Errorf("write composed tree: %w", err)
|
||||
}
|
||||
return trimLine(treeOutput), nil, nil
|
||||
return tree, conflicts, nil
|
||||
}
|
||||
|
||||
// VerifyObject verifies that name resolves to an object in this repository.
|
||||
@@ -1002,6 +1069,17 @@ func (r *Repository) resolveTree(ctx context.Context, object string) (string, er
|
||||
return trimLine(output), nil
|
||||
}
|
||||
|
||||
func (r *Repository) resolveCommit(ctx context.Context, object string) (string, error) {
|
||||
if err := validObjectName(object); err != nil {
|
||||
return "", err
|
||||
}
|
||||
output, err := r.run(ctx, nil, nil, "rev-parse", "--verify", "--quiet", object+"^{commit}")
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("resolve commit %s: %w", object, err)
|
||||
}
|
||||
return trimLine(output), nil
|
||||
}
|
||||
|
||||
func (r *Repository) validateRef(ctx context.Context, ref string) error {
|
||||
if !strings.HasPrefix(ref, "refs/") {
|
||||
return fmt.Errorf("ref must be fully qualified: %q", ref)
|
||||
|
||||
+15
-2
@@ -96,6 +96,19 @@ type SyncResult struct {
|
||||
Changed bool `json:"changed"`
|
||||
}
|
||||
|
||||
type RefreshResult struct {
|
||||
Prompt State `json:"prompt"`
|
||||
Task Task `json:"task"`
|
||||
Attempt Attempt `json:"attempt"`
|
||||
Proposal State `json:"proposal"`
|
||||
AcceptedHead State `json:"accepted_head"`
|
||||
Workspace string `json:"workspace"`
|
||||
Deliver []string `json:"deliver"`
|
||||
ConflictTree string `json:"conflict_tree"`
|
||||
Conflicts []string `json:"conflicts"`
|
||||
Reused bool `json:"reused"`
|
||||
}
|
||||
|
||||
type PromptRedaction struct {
|
||||
Kind string `json:"kind"`
|
||||
Count int `json:"count"`
|
||||
@@ -170,11 +183,11 @@ func (e *CommittedStateError) Error() string {
|
||||
func (e *CommittedStateError) Unwrap() error { return e.Err }
|
||||
|
||||
type ConflictError struct {
|
||||
Paths []string
|
||||
Paths []string `json:"paths"`
|
||||
}
|
||||
|
||||
func (e *ConflictError) Error() string {
|
||||
return "proposal overlaps changes accepted since its canonical anchor"
|
||||
return "automatic three-way merge has genuine unresolved conflicts"
|
||||
}
|
||||
|
||||
type RootConflictError struct {
|
||||
|
||||
+271
-11
@@ -2,6 +2,7 @@ package hop
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"os"
|
||||
@@ -11,7 +12,10 @@ import (
|
||||
"time"
|
||||
)
|
||||
|
||||
const acceptedRef = "accepted"
|
||||
const (
|
||||
acceptedRef = "accepted"
|
||||
reconciliationSummaryPrefix = "hop-reconciliation-conflicts:"
|
||||
)
|
||||
|
||||
type Service struct {
|
||||
Root string
|
||||
@@ -36,6 +40,11 @@ func InitProject(ctx context.Context, path string) (*Service, State, error) {
|
||||
if err := os.MkdirAll(filepath.Join(hopDir, "workspaces"), 0o755); err != nil {
|
||||
return nil, State{}, fmt.Errorf("create Hop project directory: %w", err)
|
||||
}
|
||||
releaseInit, err := acquireProjectLock(ctx, root, "init")
|
||||
if err != nil {
|
||||
return nil, State{}, err
|
||||
}
|
||||
defer releaseInit()
|
||||
store, err := OpenStore(filepath.Join(hopDir, "hop.db"))
|
||||
if err != nil {
|
||||
return nil, State{}, err
|
||||
@@ -52,6 +61,9 @@ func InitProject(ctx context.Context, path string) (*Service, State, error) {
|
||||
service.Close()
|
||||
return nil, State{}, err
|
||||
}
|
||||
// Derived-ref repair acquires accept.lock. Release init.lock first so a
|
||||
// validation child opening this project cannot invert those locks.
|
||||
releaseInit()
|
||||
if err := service.reconcileDerivedRefs(ctx, true); err != nil {
|
||||
service.Close()
|
||||
return nil, State{}, err
|
||||
@@ -99,6 +111,11 @@ func OpenProject(start string) (*Service, error) {
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
releaseInit, err := acquireProjectLock(context.Background(), root, "init")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer releaseInit()
|
||||
store, err := OpenStore(filepath.Join(root, ".hop", "hop.db"))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
@@ -128,6 +145,8 @@ func OpenProject(start string) (*Service, error) {
|
||||
}
|
||||
service := &Service{Root: root, Store: store, Repo: repo}
|
||||
reconcileAccepted := os.Getenv("HOP_ACCEPTANCE_LOCK_HELD") != "1"
|
||||
// Never hold init.lock while derived-ref repair acquires accept.lock.
|
||||
releaseInit()
|
||||
if err := service.reconcileDerivedRefs(context.Background(), reconcileAccepted); err != nil {
|
||||
service.Close()
|
||||
return nil, err
|
||||
@@ -459,6 +478,18 @@ func (s *Service) Propose(ctx context.Context, stateID, summary string) (Proposa
|
||||
if err != nil {
|
||||
return ProposalResult{}, err
|
||||
}
|
||||
reconciliationPrompt, reconciliation, err := s.Store.ReconciliationPromptForAttempt(ctx, attempt.ID)
|
||||
if err != nil {
|
||||
return ProposalResult{}, err
|
||||
}
|
||||
var reconciliationConflicts []string
|
||||
if reconciliation {
|
||||
conflicts, ok := decodeReconciliationConflicts(reconciliationPrompt.Summary)
|
||||
if !ok {
|
||||
return ProposalResult{}, fmt.Errorf("reconciliation prompt %s has invalid conflict metadata", reconciliationPrompt.ID)
|
||||
}
|
||||
reconciliationConflicts = conflicts
|
||||
}
|
||||
workspaceRepo, err := OpenRepository(attempt.Workspace)
|
||||
if err != nil {
|
||||
return ProposalResult{}, err
|
||||
@@ -467,17 +498,46 @@ func (s *Service) Propose(ctx context.Context, stateID, summary string) (Proposa
|
||||
if err != nil {
|
||||
return ProposalResult{}, err
|
||||
}
|
||||
if reconciliation {
|
||||
unresolved, err := unresolvedReconciliationMarkers(ctx, workspaceRepo, tree, reconciliationConflicts)
|
||||
if err != nil {
|
||||
return ProposalResult{}, err
|
||||
}
|
||||
if len(unresolved) > 0 {
|
||||
return ProposalResult{}, fmt.Errorf("reconciliation still contains merge markers in: %s", strings.Join(unresolved, ", "))
|
||||
}
|
||||
}
|
||||
checks, err := s.Store.ListChecks(ctx, attempt.ID, tree)
|
||||
if err != nil {
|
||||
return ProposalResult{}, err
|
||||
}
|
||||
if reconciliation {
|
||||
validated := false
|
||||
for _, check := range checks {
|
||||
if check.ExitCode == 0 {
|
||||
validated = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if !validated {
|
||||
return ProposalResult{}, fmt.Errorf("reconciliation must pass hop check on the resolved tree before proposing")
|
||||
}
|
||||
}
|
||||
if strings.TrimSpace(summary) == "" {
|
||||
summary = task.Title
|
||||
}
|
||||
summary, _ = RedactPromptSecrets(summary)
|
||||
canonicalAnchorID := attempt.BaseStateID
|
||||
if reconciliation && reconciliationPrompt.CanonicalAnchorID != "" {
|
||||
canonicalAnchorID = reconciliationPrompt.CanonicalAnchorID
|
||||
}
|
||||
parents := canonicalizeParents([]Parent{{StateID: attempt.HeadStateID, Role: "run_parent", Order: 0}})
|
||||
proposal := State{
|
||||
ID: newID("r"),
|
||||
Kind: StateProposal,
|
||||
TaskID: attempt.TaskID,
|
||||
AttemptID: attempt.ID,
|
||||
CanonicalAnchorID: attempt.BaseStateID,
|
||||
CanonicalAnchorID: canonicalAnchorID,
|
||||
SourceTree: tree,
|
||||
GitCommit: commit,
|
||||
Summary: summary,
|
||||
@@ -498,10 +558,6 @@ func (s *Service) Propose(ctx context.Context, stateID, summary string) (Proposa
|
||||
}
|
||||
_ = s.Store.UpdateAttemptStatus(ctx, attempt.ID, "proposed")
|
||||
_ = s.Store.UpdateTaskStatus(ctx, task.ID, "proposed")
|
||||
checks, err := s.Store.ListChecks(ctx, attempt.ID, tree)
|
||||
if err != nil {
|
||||
return ProposalResult{}, err
|
||||
}
|
||||
return ProposalResult{Proposal: proposal, Checks: checks}, nil
|
||||
}
|
||||
|
||||
@@ -547,7 +603,11 @@ func (s *Service) accept(ctx context.Context, proposalID string, checkCommand []
|
||||
if err != nil {
|
||||
return AcceptResult{}, err
|
||||
}
|
||||
base, err := s.Store.GetState(ctx, attempt.BaseStateID)
|
||||
baseStateID := proposal.CanonicalAnchorID
|
||||
if baseStateID == "" {
|
||||
baseStateID = attempt.BaseStateID
|
||||
}
|
||||
base, err := s.Store.GetState(ctx, baseStateID)
|
||||
if err != nil {
|
||||
return AcceptResult{}, err
|
||||
}
|
||||
@@ -563,10 +623,6 @@ func (s *Service) accept(ctx context.Context, proposalID string, checkCommand []
|
||||
if err != nil {
|
||||
return AcceptResult{}, err
|
||||
}
|
||||
overlap := intersectPaths(proposalPaths, currentPaths)
|
||||
if len(overlap) > 0 {
|
||||
return AcceptResult{}, &ConflictError{Paths: overlap}
|
||||
}
|
||||
finalTree := proposal.SourceTree
|
||||
if current.SourceTree != base.SourceTree {
|
||||
var mergeConflicts []string
|
||||
@@ -727,6 +783,210 @@ func (s *Service) Sync(ctx context.Context) (SyncResult, error) {
|
||||
return s.syncLocked(ctx)
|
||||
}
|
||||
|
||||
// Refresh prepares an agent-editable three-way conflict tree in the original
|
||||
// attempt workspace. It appends an internal reconciliation prompt so the agent
|
||||
// can resolve genuine conflicts without asking the user to coordinate them.
|
||||
func (s *Service) Refresh(ctx context.Context, proposalID string) (RefreshResult, error) {
|
||||
release, err := acquireProjectLock(ctx, s.Root, "accept")
|
||||
if err != nil {
|
||||
return RefreshResult{}, err
|
||||
}
|
||||
defer release()
|
||||
|
||||
proposal, err := s.Store.GetState(ctx, proposalID)
|
||||
if err != nil {
|
||||
return RefreshResult{}, err
|
||||
}
|
||||
if proposal.Kind != StateProposal {
|
||||
return RefreshResult{}, fmt.Errorf("state %s is %s, not a proposal", proposal.ID, proposal.Kind)
|
||||
}
|
||||
sourceAttempt, err := s.Store.GetAttempt(ctx, proposal.AttemptID)
|
||||
if err != nil {
|
||||
return RefreshResult{}, err
|
||||
}
|
||||
task, err := s.Store.GetTask(ctx, proposal.TaskID)
|
||||
if err != nil {
|
||||
return RefreshResult{}, err
|
||||
}
|
||||
current, err := s.Store.AcceptedHead(ctx)
|
||||
if err != nil {
|
||||
return RefreshResult{}, err
|
||||
}
|
||||
if existing, exists, err := s.Store.ReconciliationPrompt(ctx, proposal.ID, current.ID); err != nil {
|
||||
return RefreshResult{}, err
|
||||
} else if exists {
|
||||
return s.reconciliationResult(ctx, existing, task, proposal, current, true)
|
||||
}
|
||||
baseStateID := proposal.CanonicalAnchorID
|
||||
if baseStateID == "" {
|
||||
baseStateID = sourceAttempt.BaseStateID
|
||||
}
|
||||
base, err := s.Store.GetState(ctx, baseStateID)
|
||||
if err != nil {
|
||||
return RefreshResult{}, err
|
||||
}
|
||||
conflictTree, conflicts, err := s.Repo.ComposeTrees(ctx, base.GitCommit, current.GitCommit, proposal.GitCommit)
|
||||
if err != nil {
|
||||
return RefreshResult{}, err
|
||||
}
|
||||
if len(conflicts) == 0 {
|
||||
return RefreshResult{}, fmt.Errorf("proposal %s now merges cleanly; retry hop land", proposal.ID)
|
||||
}
|
||||
commit, err := s.Repo.CommitTree(ctx, conflictTree, []string{current.GitCommit, proposal.GitCommit},
|
||||
fmt.Sprintf("Reconcile %s against %s\n", proposal.ID, current.ID))
|
||||
if err != nil {
|
||||
return RefreshResult{}, err
|
||||
}
|
||||
summary, err := encodeReconciliationConflicts(conflicts)
|
||||
if err != nil {
|
||||
return RefreshResult{}, err
|
||||
}
|
||||
instruction := fmt.Sprintf(
|
||||
"Resolve proposal %s (%s) against accepted state %s (%s). Preserve both compatible intents. Inspect both input states for structural, delete/rename, mode, symlink, or binary conflicts that may have no text markers; resolve every conflict intentionally, remove all merge markers, run hop check, propose the result, and land it without asking the user to coordinate the merge. Conflict candidates: %s",
|
||||
proposal.ID, proposal.Summary, current.ID, current.Summary, strings.Join(conflicts, ", "))
|
||||
instruction, _ = RedactPromptSecrets(instruction)
|
||||
attemptID := newID("at")
|
||||
workspace := filepath.Join(s.Root, ".hop", "workspaces", attemptID)
|
||||
reconciliationAttempt := Attempt{
|
||||
ID: attemptID,
|
||||
TaskID: proposal.TaskID,
|
||||
Agent: sourceAttempt.Agent,
|
||||
Workspace: workspace,
|
||||
BaseStateID: current.ID,
|
||||
Status: "reconciling",
|
||||
CreatedAt: time.Now().UTC(),
|
||||
}
|
||||
parents := canonicalizeParents([]Parent{
|
||||
{StateID: proposal.ID, Role: "run_parent", Order: 0},
|
||||
{StateID: current.ID, Role: "canonical_anchor", Order: 1},
|
||||
{StateID: proposal.ID, Role: "reconciliation_source", Order: 2},
|
||||
})
|
||||
prompt := State{
|
||||
ID: newID("p"),
|
||||
Kind: StatePrompt,
|
||||
TaskID: proposal.TaskID,
|
||||
AttemptID: reconciliationAttempt.ID,
|
||||
CanonicalAnchorID: current.ID,
|
||||
SourceTree: conflictTree,
|
||||
GitCommit: commit,
|
||||
Prompt: instruction,
|
||||
Summary: summary,
|
||||
Agent: reconciliationAttempt.Agent,
|
||||
CreatedAt: time.Now().UTC(),
|
||||
Parents: parents,
|
||||
}
|
||||
prompt.Digest, err = digestState(prompt, parents)
|
||||
if err != nil {
|
||||
return RefreshResult{}, err
|
||||
}
|
||||
if err := s.pinState(ctx, prompt); err != nil {
|
||||
return RefreshResult{}, err
|
||||
}
|
||||
promptPinned := true
|
||||
defer func() {
|
||||
if promptPinned {
|
||||
_ = s.Repo.DeleteRef(context.Background(), "refs/hop/states/"+prompt.ID, prompt.GitCommit)
|
||||
}
|
||||
}()
|
||||
if err := os.MkdirAll(filepath.Dir(workspace), 0o755); err != nil {
|
||||
return RefreshResult{}, fmt.Errorf("create reconciliation workspace directory: %w", err)
|
||||
}
|
||||
if _, err := s.Repo.AddDetachedWorktree(ctx, workspace, prompt.GitCommit); err != nil {
|
||||
_ = s.Repo.RemoveWorktree(context.Background(), workspace, true)
|
||||
return RefreshResult{}, fmt.Errorf("create reconciliation workspace: %w", err)
|
||||
}
|
||||
workspaceInstalled := true
|
||||
defer func() {
|
||||
if workspaceInstalled {
|
||||
_ = s.Repo.RemoveWorktree(context.Background(), workspace, true)
|
||||
}
|
||||
}()
|
||||
reconciliationAttempt, prompt, err = s.Store.CreateAttemptPrompt(ctx, reconciliationAttempt, prompt, parents)
|
||||
if err != nil {
|
||||
return RefreshResult{}, err
|
||||
}
|
||||
workspaceInstalled = false
|
||||
promptPinned = false
|
||||
task.Status = "reconciling"
|
||||
return s.reconciliationResult(ctx, prompt, task, proposal, current, false)
|
||||
}
|
||||
|
||||
func (s *Service) reconciliationResult(
|
||||
ctx context.Context,
|
||||
prompt State,
|
||||
task Task,
|
||||
proposal State,
|
||||
current State,
|
||||
reused bool,
|
||||
) (RefreshResult, error) {
|
||||
conflicts, ok := decodeReconciliationConflicts(prompt.Summary)
|
||||
if !ok || len(conflicts) == 0 {
|
||||
return RefreshResult{}, fmt.Errorf("reconciliation prompt %s has no conflict metadata", prompt.ID)
|
||||
}
|
||||
attempt, err := s.Store.GetAttempt(ctx, prompt.AttemptID)
|
||||
if err != nil {
|
||||
return RefreshResult{}, err
|
||||
}
|
||||
if _, err := OpenRepository(attempt.Workspace); err != nil {
|
||||
return RefreshResult{}, fmt.Errorf("open prepared reconciliation workspace: %w", err)
|
||||
}
|
||||
task, err = s.Store.GetTask(ctx, task.ID)
|
||||
if err != nil {
|
||||
return RefreshResult{}, err
|
||||
}
|
||||
return RefreshResult{
|
||||
Prompt: prompt,
|
||||
Task: task,
|
||||
Attempt: attempt,
|
||||
Proposal: proposal,
|
||||
AcceptedHead: current,
|
||||
Workspace: attempt.Workspace,
|
||||
Deliver: s.deliveryEnvironment(prompt, attempt),
|
||||
ConflictTree: prompt.SourceTree,
|
||||
Conflicts: conflicts,
|
||||
Reused: reused,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func encodeReconciliationConflicts(paths []string) (string, error) {
|
||||
encoded, err := json.Marshal(paths)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("encode reconciliation conflicts: %w", err)
|
||||
}
|
||||
return reconciliationSummaryPrefix + string(encoded), nil
|
||||
}
|
||||
|
||||
func decodeReconciliationConflicts(summary string) ([]string, bool) {
|
||||
if !strings.HasPrefix(summary, reconciliationSummaryPrefix) {
|
||||
return nil, false
|
||||
}
|
||||
var paths []string
|
||||
if err := json.Unmarshal([]byte(strings.TrimPrefix(summary, reconciliationSummaryPrefix)), &paths); err != nil {
|
||||
return nil, false
|
||||
}
|
||||
return paths, true
|
||||
}
|
||||
|
||||
func unresolvedReconciliationMarkers(ctx context.Context, repo *Repository, tree string, conflicts []string) ([]string, error) {
|
||||
args := []string{
|
||||
"grep", "-z", "-l", "-I",
|
||||
"-e", "^<<<<<<< ",
|
||||
"-e", "^>>>>>>> ",
|
||||
tree, "--",
|
||||
}
|
||||
args = append(args, conflicts...)
|
||||
output, err := repo.run(ctx, []string{"GIT_LITERAL_PATHSPECS=1", "GIT_OPTIONAL_LOCKS=0"}, nil, args...)
|
||||
if err != nil {
|
||||
if gitExitCode(err) == 1 {
|
||||
return nil, nil
|
||||
}
|
||||
return nil, fmt.Errorf("inspect reconciliation markers: %w", err)
|
||||
}
|
||||
unresolved := splitNull([]byte(output))
|
||||
sort.Strings(unresolved)
|
||||
return unresolved, nil
|
||||
}
|
||||
|
||||
func (s *Service) syncLocked(ctx context.Context) (SyncResult, error) {
|
||||
current, err := s.Store.AcceptedHead(ctx)
|
||||
if err != nil {
|
||||
|
||||
@@ -5,6 +5,7 @@ import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
@@ -81,6 +82,171 @@ func TestInitRefusesAUserTrackedHopDirectory(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
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()
|
||||
@@ -222,6 +388,76 @@ func TestDoctorRejectsCommitTreeAndDigestMismatch(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
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 TestOpenProjectInsideFinalValidationDoesNotReacquireAcceptanceLock(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
service, _ := newTestProject(t, map[string]string{"base.txt": "base\n"})
|
||||
@@ -479,6 +715,332 @@ func TestConcurrentDisjointAcceptancesSerialize(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
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"})
|
||||
|
||||
+21
-1
@@ -124,6 +124,13 @@ func truncateRecordedOutput(output string) string {
|
||||
|
||||
func acquireProjectLock(ctx context.Context, root, name string) (func(), error) {
|
||||
path := filepath.Join(root, ".hop", name+".lock")
|
||||
return acquireFileLock(ctx, path, "Hop "+name)
|
||||
}
|
||||
|
||||
func acquireFileLock(ctx context.Context, path, description string) (func(), error) {
|
||||
if err := os.MkdirAll(filepath.Dir(path), 0o700); err != nil {
|
||||
return nil, fmt.Errorf("create %s lock directory: %w", description, err)
|
||||
}
|
||||
for {
|
||||
file, err := os.OpenFile(path, os.O_CREATE|os.O_EXCL|os.O_WRONLY, 0o600)
|
||||
if err == nil {
|
||||
@@ -160,12 +167,25 @@ func acquireProjectLock(ctx context.Context, root, name string) (func(), error)
|
||||
}
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return nil, fmt.Errorf("wait for Hop %s lock: %w", name, ctx.Err())
|
||||
return nil, fmt.Errorf("wait for %s lock: %w", description, ctx.Err())
|
||||
case <-time.After(50 * time.Millisecond):
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func repositoryInitLockPath(path string) (string, error) {
|
||||
cache, err := os.UserCacheDir()
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("locate user cache for repository initialization lock: %w", err)
|
||||
}
|
||||
canonical, err := filepath.EvalSymlinks(path)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("resolve repository initialization path: %w", err)
|
||||
}
|
||||
digest := sha256.Sum256([]byte(filepath.Clean(canonical)))
|
||||
return filepath.Join(cache, "hop", "locks", "repository-"+hex.EncodeToString(digest[:])+".lock"), nil
|
||||
}
|
||||
|
||||
func shellQuote(argv []string) string {
|
||||
quoted := make([]string, len(argv))
|
||||
for i, arg := range argv {
|
||||
|
||||
Reference in New Issue
Block a user