Synchronize Desktop landings into the visible project root without overwriting user work

Hop-State: A_06FN3Z6G7Y16KA22KZSK9WR
Hop-Proposal: R_06FN3Z5WYDG1WMRE9YJQ10R
Hop-Task: T_06FN3MBF98GWD4NA5PA1RWG
Hop-Attempt: AT_06FN3MBF98FBDSE1BZDP5DG
This commit is contained in:
Hop
2026-07-11 09:22:30 -07:00
parent a70773826f
commit 4c0c504935
12 changed files with 1153 additions and 47 deletions
+23 -7
View File
@@ -2,7 +2,7 @@
Hop is an experimental prompt-native version-control kernel for coding agents.
Every instruction becomes an immutable state before project effects. Agent work produces checkpoint and proposal states beneath it. Landing a proposal creates a new accepted state without moving the users Git branch or checkout. Controller integrations may capture the stronger pre-delivery boundary as well.
Every instruction becomes an immutable state before project effects. Agent work produces checkpoint and proposal states beneath it. Landing a proposal creates a new accepted state and safely materializes it in the visible project folder without moving the users Git branch or real index. Controller integrations may capture the stronger pre-delivery boundary as well.
```text
A0 accepted
@@ -31,6 +31,8 @@ This repository contains the first local alpha kernel. It supports:
- Three-tree composition of disjoint proposals
- Optional validation on the final integrated tree
- Compare-and-swap acceptance
- Safe visible-root materialization on Desktop landing
- Controller-only internal acceptance plus explicit root synchronization
- Forward-only undo of the latest accepted transition
- Git-compatible accepted commits under `refs/hop/accepted`
- SQLite WAL state graph and machine-readable JSON output
@@ -54,10 +56,11 @@ Hop skill's first action ──> hop begin ──> prompt state + workspace
edit → check → propose
Human review → land → accepted state
Agent validate → auto-land → accepted state
→ visible project root
```
The user continues typing into Codex Desktop normally. The skill invokes `hop begin` before inspecting or changing the project. `hop begin` initializes Hop when needed, uses `CODEX_THREAD_ID` to recognize follow-ups, checkpoints prior effects, and returns the isolated workspace. The skill then confines work to that workspace. This is a pre-project-effect boundary: the prompt is stored after Codex receives it but before the agent performs project work. A controller or future trusted prompt hook can provide strict pre-delivery capture.
The user continues typing into Codex Desktop normally. The skill invokes `hop begin` before inspecting or changing the project. `hop begin` initializes Hop when needed, uses `CODEX_THREAD_ID` to recognize follow-ups, checkpoints prior effects, and returns the isolated workspace. The skill confines work to that workspace, validates it, and automatically lands a successful proposal. `hop land` advances accepted state and then projects that exact tree into the selected folder. It proceeds only when the visible source still matches an accepted Hop state, so user edits are never overwritten. The original task prompt is the authorization; no extra landing prompt is required. Say `review first` or `proposal only` to opt into manual approval. This is a pre-project-effect boundary: the prompt is stored after Codex receives it but before the agent performs project work. A controller or future trusted prompt hook can provide strict pre-delivery capture.
## Build
@@ -94,7 +97,7 @@ The agent—not the user—runs this command. It initializes Hop without changin
Codex chooses implicitly invoked skills from their descriptions. Explicitly mentioning `$hop` remains the deterministic fallback if a task does not activate it automatically.
After the agent edits the printed workspace:
After editing the printed workspace, the agent runs this lifecycle automatically:
```bash
hop check P_... -- go test ./...
@@ -102,7 +105,17 @@ hop propose --summary "Added password reset emails" P_...
hop land R_... -- go test ./...
```
The command supplied to `land` runs in a temporary worktree containing the exact final tree that would become accepted. If it fails, `refs/hop/accepted` and the SQLite accepted head do not move.
The command supplied to `land` runs in a temporary worktree containing the exact final tree that would become accepted. If it fails, `refs/hop/accepted` and the SQLite accepted head do not move. After acceptance, Hop updates only the visible working files through a disposable Git index; HEAD, the active branch, and the real index do not move. The agent pauses for an explicit review-first request, failed validation, proposal overlap, visible-root divergence, or newly required destructive/external scope.
`land` and `accept` are intentionally different:
```bash
hop land R_... -- go test ./... # Desktop: accept and synchronize the selected folder
hop accept R_... -- go test ./... # Controller: accept internally, leave the folder untouched
hop sync # Catch a stale visible folder up to accepted state
```
`hop sync` is also the upgrade path for projects accepted by an older Hop build. It synchronizes only when the folder still matches an accepted ancestor.
Inspect the project:
@@ -112,6 +125,7 @@ hop graph
hop state P_...
hop diff R_...
hop history
hop sync
hop doctor
```
@@ -177,7 +191,7 @@ Source objects are pinned beneath `refs/hop/states/*`, preventing Git garbage co
└── accept.lock short-lived acceptance serialization lock
```
The repositorys `.git/info/exclude` receives `.hop/`; the public `.gitignore`, current branch, and real Git index are left alone.
The repositorys `.git/info/exclude` receives `.hop/`; the public `.gitignore`, current branch, and real Git index are left alone. `hop land`, `hop sync`, and `hop undo` update visible source files only after confirming that the folder matches accepted Hop history.
Initialization refuses to proceed if `.hop` is already tracked as user-owned project source.
@@ -203,7 +217,9 @@ The JSON shape is an alpha contract and may evolve before the first tagged relea
## Safety boundary
Hop currently treats any shared changed path as a conflict. Disjoint files can still conflict behaviorally, so manual acceptance remains the default and final-tree validation is strongly recommended.
Hop currently treats any shared changed path as a conflict. Disjoint files can still conflict behaviorally, so automatic acceptance reruns the strongest relevant validation on the exact final tree. Manual acceptance remains available as an explicit review-first mode.
Desktop landing fails closed when nonignored visible source does not exactly match an accepted Hop state or when an ignored/untracked path would be overwritten. It never uses `reset --hard`, moves HEAD, or rewrites the user's real index. The lower-level `hop accept` command deliberately retains controller-only behavior and does not update the visible root.
Agent-reported scope and test claims are never used as the source of truth. Hop computes source trees and changed paths itself.
+16 -5
View File
@@ -170,10 +170,11 @@ Prompt creation and canonical acceptance are separate transitions:
4. Hop compare-and-swaps the run head to `P`; only then may it deliver the prompt to the agent.
5. The agent receives `HOP_STATE_ID=P`, works in an isolated workspace, and declares expiring claims.
6. Work becomes descendant checkpoints and eventually frozen proposal `R`; failure or cancellation also produces an addressable terminal state.
7. If `R` is nominated for acceptance, Hop reconciles it against the current accepted head in a temporary integration workspace.
7. By default the agent immediately nominates `R` for automatic acceptance; an explicit review-first request pauses here. Hop reconciles it against the current accepted head in a temporary integration workspace.
8. Hop evaluates textual overlap, symbol and contract risk, policies, and required tests on the exact final roots.
9. Hop creates accepted state `A`, linked to both the previous accepted state and `R`, then atomically advances `accepted_head` with compare-and-swap.
10. Every prompt, checkpoint, proposal, and acceptance remains addressable regardless of later outcomes.
10. In Desktop mode, Hop materializes `A` into the selected visible root only when that root still matches accepted history; it preserves HEAD and the real Git index and blocks rather than overwriting divergence.
11. Every prompt, checkpoint, proposal, and acceptance remains addressable regardless of later outcomes.
The important invariants are:
@@ -211,6 +212,11 @@ Automatic acceptance should require all of the following:
- The proposal remains inside configured risk thresholds.
- Any required human or policy approval is present.
For ordinary local agent work, the task prompt supplies the acceptance authority
and Hop should auto-accept after deterministic checks pass. A separate landing
prompt is unnecessary ceremony. Human approval remains available when the user
explicitly requests review first or project policy protects the affected scope.
Model-generated compatibility analysis can explain risk and select extra checks, but it should not be the sole authority for acceptance.
## Undo and history
@@ -269,10 +275,13 @@ Dynamic active work belongs in `hop board` and `hop context`, not in a file copi
## Human and agent experience
The human-facing loop should be five verbs:
The default human-facing loop should be four verbs, with Review as an opt-in
pause before acceptance:
```text
Ask → Work → Review → Land → Undo
Ask → Work → Accept → Undo
optional Review
```
A plausible CLI:
@@ -326,7 +335,9 @@ The smallest complete product is a **parallel-agent landing queue** backed by Gi
8. Nominate a sealed state with its structured summary, commands, and test evidence.
9. Materialize it on the current accepted head in an integration workspace.
10. Run configured checks on that exact final state.
11. Advance the accepted head atomically and export a normal Git-compatible commit.
11. Auto-accept successful ordinary local work without another user prompt,
then advance the accepted head atomically, export a normal Git-compatible
commit, and safely synchronize the visible Desktop root.
12. Undo an accepted state through a compensating prompt/integration state.
13. Generate a small `PROJECT.md` from accepted facts.
14. Install a vendor-neutral agent skill and expose stable JSON CLI output.
+63 -4
View File
@@ -21,6 +21,8 @@ Usage:
hop check STATE -- COMMAND [ARG...]
hop propose [--summary TEXT] STATE
hop accept STATE [-- COMMAND [ARG...]]
hop land STATE [-- COMMAND [ARG...]]
hop sync
hop status
hop graph
hop state STATE
@@ -36,7 +38,7 @@ Usage:
Add --json anywhere for machine-readable output.
`
const Version = "0.1.0-alpha.2"
const Version = "0.1.0-alpha.3"
func RunCLI(args []string, stdout, stderr io.Writer) int {
return RunCLIWithInput(args, os.Stdin, stdout, stderr)
@@ -234,7 +236,7 @@ func RunCLIWithInput(args []string, stdin io.Reader, stdout, stderr io.Writer) i
fmt.Fprintf(stdout, "Created proposal %s · tree %s · %d matching checks\n", result.Proposal.ID, shortHash(result.Proposal.SourceTree), len(result.Checks))
}
case "accept", "land":
case "accept":
stateID, argv, ok := splitOptionalCommand(commandArgs)
if !ok {
fmt.Fprintln(stderr, "usage: hop accept STATE [-- COMMAND [ARG...]]")
@@ -246,15 +248,55 @@ func RunCLIWithInput(args []string, stdin io.Reader, stdout, stderr io.Writer) i
return printCLIError(err, jsonOutput, stdout, stderr)
}
if !jsonOutput {
fmt.Fprintf(stdout, "Accepted as %s · tree %s\n", result.State.ID, shortHash(result.State.SourceTree))
fmt.Fprintf(stdout, "Accepted internally as %s · tree %s · visible root unchanged\n", result.State.ID, shortHash(result.State.SourceTree))
if result.Check == nil {
fmt.Fprintln(stdout, "No final-state validation command was supplied; acceptance was manual.")
fmt.Fprintln(stdout, "No final-state validation command was supplied.")
}
for _, warning := range result.Warnings {
fmt.Fprintf(stderr, "Warning: %s\n", warning)
}
}
case "land":
stateID, argv, ok := splitOptionalCommand(commandArgs)
if !ok {
fmt.Fprintln(stderr, "usage: hop land STATE [-- COMMAND [ARG...]]")
return 2
}
result, err := service.Land(ctx, stateID, argv)
value = result
if err != nil {
return printCLIError(err, jsonOutput, stdout, stderr)
}
if !jsonOutput {
fmt.Fprintf(stdout, "Landed as %s · tree %s\n", result.State.ID, shortHash(result.State.SourceTree))
fmt.Fprintf(stdout, "Synchronized visible root: %s\n", result.MaterializedRoot)
if result.Check == nil {
fmt.Fprintln(stdout, "No final-state validation command was supplied.")
}
for _, warning := range result.Warnings {
fmt.Fprintf(stderr, "Warning: %s\n", warning)
}
}
case "sync":
if len(commandArgs) != 0 {
fmt.Fprintln(stderr, "usage: hop sync")
return 2
}
result, err := service.Sync(ctx)
value = result
if err != nil {
return printCLIError(err, jsonOutput, stdout, stderr)
}
if !jsonOutput {
if result.Changed {
fmt.Fprintf(stdout, "Synchronized %s to accepted state %s\n", result.Root, result.State.ID)
} else {
fmt.Fprintf(stdout, "Visible root already matches accepted state %s\n", result.State.ID)
}
}
case "status":
status, err := service.Status(ctx)
if err != nil {
@@ -263,6 +305,14 @@ func RunCLIWithInput(args []string, stdin io.Reader, stdout, stderr io.Writer) i
value = status
if !jsonOutput {
fmt.Fprintf(stdout, "Accepted: %s · tree %s\n", status.AcceptedHead.ID, shortHash(status.AcceptedHead.SourceTree))
switch status.RootStatus {
case "synchronized":
fmt.Fprintln(stdout, "Root: synchronized")
case "stale":
fmt.Fprintf(stdout, "Root: stale at %s\n", status.RootStateID)
default:
fmt.Fprintln(stdout, "Root: diverged; Hop will not overwrite it")
}
if len(status.Attempts) == 0 {
fmt.Fprintln(stdout, "No attempts yet.")
}
@@ -461,11 +511,14 @@ func runSkillCLI(args []string, jsonOutput bool, stdout, stderr io.Writer) int {
func printCLIError(err error, jsonOutput bool, stdout, stderr io.Writer) int {
code := 1
var conflict *ConflictError
var rootConflict *RootConflictError
var stale *StaleHeadError
var failed *CheckFailedError
switch {
case errors.As(err, &conflict):
code = 20
case errors.As(err, &rootConflict):
code = 23
case errors.As(err, &stale):
code = 21
case errors.As(err, &failed):
@@ -481,6 +534,12 @@ func printCLIError(err error, jsonOutput bool, stdout, stderr io.Writer) int {
fmt.Fprintf(stderr, " %s\n", path)
}
}
if rootConflict != nil && len(rootConflict.Paths) > 0 {
fmt.Fprintln(stderr, "Visible-root conflicts:")
for _, path := range rootConflict.Paths {
fmt.Fprintf(stderr, " %s\n", path)
}
}
}
return code
}
+98 -4
View File
@@ -15,7 +15,7 @@ import (
_ "modernc.org/sqlite"
)
const schemaVersion = 2
const schemaVersion = 3
var (
ErrNotFound = errors.New("hop: not found")
@@ -23,6 +23,7 @@ var (
ErrAlreadyInitialized = errors.New("hop: repository is already initialized")
ErrAttemptHeadChanged = errors.New("hop: attempt head changed")
ErrAcceptedHeadChanged = errors.New("hop: accepted head changed")
ErrMaterializedChanged = errors.New("hop: materialized head changed")
)
// HeadChangedError reports a failed compare-and-swap of an attempt or the
@@ -39,10 +40,14 @@ func (e *HeadChangedError) Error() string {
}
func (e *HeadChangedError) Unwrap() error {
if e.Scope == "accepted" {
switch e.Scope {
case "accepted":
return ErrAcceptedHeadChanged
case "materialized":
return ErrMaterializedChanged
default:
return ErrAttemptHeadChanged
}
return ErrAttemptHeadChanged
}
// Event is an immutable audit record. Payload is deliberately unstructured so
@@ -230,6 +235,17 @@ var migrations = []migration{
`CREATE INDEX IF NOT EXISTS agent_sessions_head_idx ON agent_sessions(head_state_id)`,
},
},
{
version: 3,
statements: []string{
`INSERT OR IGNORE INTO meta(key, value)
SELECT 'materialized_head', id
FROM states
WHERE kind = 'accepted'
ORDER BY created_at, id
LIMIT 1`,
},
},
}
func (s *Store) migrate(ctx context.Context) error {
@@ -320,7 +336,11 @@ func (s *Store) CreateInitialState(ctx context.Context, root string, initial Sta
if err := insertStateTx(ctx, tx, initial, nil); err != nil {
return State{}, err
}
for key, value := range map[string]string{"root": filepath.Clean(root), "accepted_head": initial.ID} {
for key, value := range map[string]string{
"root": filepath.Clean(root),
"accepted_head": initial.ID,
"materialized_head": initial.ID,
} {
if _, err := tx.ExecContext(ctx,
`INSERT INTO meta(key, value) VALUES (?, ?)
ON CONFLICT(key) DO UPDATE SET value = excluded.value`, key, value); err != nil {
@@ -784,6 +804,80 @@ func (s *Store) AcceptedHead(ctx context.Context) (State, error) {
return s.GetState(ctx, id)
}
// AcceptedForProposal returns the immutable accepted child already created for
// proposalID. It makes retry after a post-commit projection failure
// idempotent instead of creating a second acceptance transition.
func (s *Store) AcceptedForProposal(ctx context.Context, proposalID string) (State, bool, error) {
var id string
err := s.db.QueryRowContext(ctx,
`SELECT p.state_id
FROM state_parents p
JOIN states s ON s.id = p.state_id
WHERE p.parent_state_id = ? AND p.role = 'proposal_parent' AND s.kind = 'accepted'
ORDER BY s.created_at, s.id
LIMIT 1`, proposalID).Scan(&id)
if errors.Is(err, sql.ErrNoRows) {
return State{}, false, nil
}
if err != nil {
return State{}, false, fmt.Errorf("find accepted state for proposal %s: %w", proposalID, 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.
func (s *Store) MaterializedHead(ctx context.Context) (State, error) {
var id string
err := s.db.QueryRowContext(ctx, `SELECT value FROM meta WHERE key = 'materialized_head'`).Scan(&id)
if errors.Is(err, sql.ErrNoRows) {
return State{}, ErrNotInitialized
}
if err != nil {
return State{}, fmt.Errorf("read materialized head: %w", err)
}
return s.GetState(ctx, id)
}
// CASMaterializedHead advances the recoverable visible-root projection marker.
// Filesystem materialization must be verified before calling this method.
func (s *Store) CASMaterializedHead(ctx context.Context, expectedID, nextID string) error {
if expectedID == "" || nextID == "" {
return errors.New("hop: expected and next materialized heads are required")
}
if expectedID == nextID {
return nil
}
next, err := s.GetState(ctx, nextID)
if err != nil {
return err
}
if next.Kind != StateAccepted {
return fmt.Errorf("hop: materialized head must be an accepted state, found %s", next.Kind)
}
result, err := s.db.ExecContext(ctx,
`UPDATE meta SET value = ? WHERE key = 'materialized_head' AND value = ?`,
nextID, expectedID)
if err != nil {
return fmt.Errorf("advance materialized head: %w", err)
}
rows, err := result.RowsAffected()
if err != nil {
return fmt.Errorf("inspect materialized head update: %w", err)
}
if rows != 1 {
var actual string
_ = s.db.QueryRowContext(ctx, `SELECT value FROM meta WHERE key = 'materialized_head'`).Scan(&actual)
return &HeadChangedError{Scope: "materialized", Expected: expectedID, Actual: actual}
}
return nil
}
func (s *Store) RepositoryRoot(ctx context.Context) (string, error) {
var root string
err := s.db.QueryRowContext(ctx, `SELECT value FROM meta WHERE key = 'root'`).Scan(&root)
+262
View File
@@ -11,6 +11,7 @@ import (
"path/filepath"
"sort"
"strings"
"syscall"
"time"
)
@@ -596,6 +597,267 @@ func (r *Repository) ChangedPaths(ctx context.Context, from, to string) ([]strin
return result, nil
}
// WorktreeTree snapshots the visible worktree relative to an expected Hop
// tree. The caller's real Git index is never read or changed. Seeding from the
// expected tree makes Hop-projected files remain visible to the snapshot even
// when they are untracked by the user's branch or matched by an ignore rule.
func (r *Repository) WorktreeTree(ctx context.Context, expected string) (string, error) {
expectedTree, err := r.resolveTree(ctx, expected)
if err != nil {
return "", err
}
indexPath, cleanup, err := r.temporaryIndex(false)
if err != nil {
return "", err
}
defer cleanup()
env := []string{"GIT_INDEX_FILE=" + indexPath, "GIT_OPTIONAL_LOCKS=0"}
if _, err := r.run(ctx, env, nil, "read-tree", expectedTree); err != nil {
return "", fmt.Errorf("seed visible-root snapshot: %w", err)
}
if _, err := r.run(ctx, env, nil, "add", "-A", "--", "."); err != nil {
return "", fmt.Errorf("snapshot visible project root: %w", err)
}
output, err := r.run(ctx, env, nil, "write-tree")
if err != nil {
return "", fmt.Errorf("write visible-root tree: %w", err)
}
return trimLine(output), nil
}
// CheckIndexSafe verifies that the user's real index contains no staged state
// outside either their current HEAD or the Hop tree already visible in the
// project root. Hop never writes this index, but refusing divergent staging
// prevents a landing from obscuring the user's in-progress Git operation.
func (r *Repository) CheckIndexSafe(ctx context.Context, visibleTree string) error {
visibleTree, err := r.resolveTree(ctx, visibleTree)
if err != nil {
return err
}
head, exists, err := r.Head(ctx)
if err != nil {
return err
}
headTree := ""
if exists {
headTree, err = r.resolveTree(ctx, head)
} else {
headTree, err = r.EmptyTree(ctx)
}
if err != nil {
return err
}
indexTree, err := r.userIndexTree(ctx)
if err != nil {
return &RootConflictError{Reason: "visible project root has unmerged or intent-to-add entries in the real Git index"}
}
if indexTree == headTree || indexTree == visibleTree {
return nil
}
paths, err := r.ChangedPaths(ctx, headTree, indexTree)
if err != nil {
return err
}
return &RootConflictError{
Paths: paths,
Reason: "visible project root has staged Git changes that do not match HEAD or its materialized Hop state",
}
}
func (r *Repository) userIndexTree(ctx context.Context) (string, error) {
indexPath := filepath.Join(r.gitDir, "index")
if _, err := os.Stat(indexPath); errors.Is(err, os.ErrNotExist) {
return r.EmptyTree(ctx)
} else if err != nil {
return "", fmt.Errorf("inspect real Git index: %w", err)
}
output, err := r.run(ctx, []string{
"GIT_INDEX_FILE=" + indexPath,
"GIT_OPTIONAL_LOCKS=0",
}, nil, "write-tree")
if err != nil {
return "", fmt.Errorf("read real Git index tree: %w", err)
}
return trimLine(output), nil
}
// MaterializationConflicts finds filesystem entries that a tree projection
// would overwrite even though they are absent from the source tree. This
// catches ignored files, which intentionally do not appear in WorktreeTree.
func (r *Repository) MaterializationConflicts(ctx context.Context, from, to string) ([]string, error) {
fromPaths, err := r.treeLeafPaths(ctx, from)
if err != nil {
return nil, err
}
toPaths, err := r.treeLeafPaths(ctx, to)
if err != nil {
return nil, err
}
conflicts := map[string]struct{}{}
for path := range toPaths {
if _, existed := fromPaths[path]; existed {
continue
}
parts := strings.Split(path, "/")
for index := range parts {
prefix := strings.Join(parts[:index+1], "/")
info, statErr := os.Lstat(filepath.Join(r.root, filepath.FromSlash(prefix)))
if statErr != nil {
if errors.Is(statErr, os.ErrNotExist) || errors.Is(statErr, syscall.ENOTDIR) {
break
}
return nil, fmt.Errorf("inspect visible path %s: %w", prefix, statErr)
}
if index < len(parts)-1 {
if info.IsDir() {
continue
}
if _, expected := fromPaths[prefix]; expected {
break
}
conflicts[prefix] = struct{}{}
break
}
if info.IsDir() {
unexpected, walkErr := r.unexpectedDirectoryLeaves(path, fromPaths)
if walkErr != nil {
return nil, walkErr
}
for _, unexpectedPath := range unexpected {
conflicts[unexpectedPath] = struct{}{}
}
continue
}
conflicts[path] = struct{}{}
}
}
paths := make([]string, 0, len(conflicts))
for path := range conflicts {
paths = append(paths, path)
}
sort.Strings(paths)
return paths, nil
}
func (r *Repository) unexpectedDirectoryLeaves(path string, expected map[string]struct{}) ([]string, error) {
root := filepath.Join(r.root, filepath.FromSlash(path))
var conflicts []string
err := filepath.WalkDir(root, func(candidate string, entry os.DirEntry, walkErr error) error {
if walkErr != nil {
return walkErr
}
if candidate == root || entry.IsDir() {
return nil
}
relative, err := filepath.Rel(r.root, candidate)
if err != nil {
return err
}
relative = filepath.ToSlash(relative)
if _, ok := expected[relative]; !ok {
conflicts = append(conflicts, relative)
}
return nil
})
if err != nil {
return nil, fmt.Errorf("inspect visible directory %s: %w", path, err)
}
sort.Strings(conflicts)
return conflicts, nil
}
// MaterializeTree updates only the visible worktree from one accepted tree to
// another. HEAD, the current branch, and the user's real index remain exactly
// where they were. The operation fails closed when the worktree no longer
// matches from or an ignored destination would be overwritten.
func (r *Repository) MaterializeTree(ctx context.Context, from, to string) error {
fromTree, err := r.resolveTree(ctx, from)
if err != nil {
return err
}
toTree, err := r.resolveTree(ctx, to)
if err != nil {
return err
}
if err := r.CheckIndexSafe(ctx, fromTree); err != nil {
return err
}
actualTree, err := r.WorktreeTree(ctx, fromTree)
if err != nil {
return err
}
if actualTree != fromTree {
paths, pathErr := r.ChangedPaths(ctx, fromTree, actualTree)
if pathErr != nil {
return pathErr
}
return &RootConflictError{Paths: paths}
}
conflicts, err := r.MaterializationConflicts(ctx, fromTree, toTree)
if err != nil {
return err
}
if len(conflicts) > 0 {
return &RootConflictError{
Paths: conflicts,
Reason: "visible project root contains ignored or untracked paths that landing would overwrite",
}
}
if fromTree == toTree {
return nil
}
indexPath, cleanup, err := r.temporaryIndex(false)
if err != nil {
return err
}
defer cleanup()
env := []string{"GIT_INDEX_FILE=" + indexPath, "GIT_OPTIONAL_LOCKS=0"}
if _, err := r.run(ctx, env, nil, "read-tree", fromTree); err != nil {
return fmt.Errorf("seed visible-root materialization: %w", err)
}
if _, err := r.run(ctx, env, nil, "update-index", "--refresh"); err != nil {
return &RootConflictError{Reason: "visible project root changed while Hop was preparing to synchronize it"}
}
if _, err := r.run(ctx, env, nil, "read-tree", "-m", "-u", fromTree, toTree); err != nil {
return fmt.Errorf("materialize accepted tree into visible project root: %w", err)
}
materializedTree, err := r.WorktreeTree(ctx, toTree)
if err != nil {
return err
}
if materializedTree != toTree {
paths, pathErr := r.ChangedPaths(ctx, toTree, materializedTree)
if pathErr != nil {
return pathErr
}
return &RootConflictError{
Paths: paths,
Reason: "visible project root changed while Hop was synchronizing it",
}
}
return nil
}
func (r *Repository) treeLeafPaths(ctx context.Context, object string) (map[string]struct{}, error) {
tree, err := r.resolveTree(ctx, object)
if err != nil {
return nil, err
}
output, err := r.run(ctx, nil, nil, "ls-tree", "-r", "-z", "--name-only", tree)
if err != nil {
return nil, fmt.Errorf("list tree paths: %w", err)
}
paths := make(map[string]struct{})
for _, path := range splitNull([]byte(output)) {
if path != "" {
paths[path] = struct{}{}
}
}
return paths, nil
}
// TrackedPaths lists index entries at or below path. Hop uses this before
// initialization to avoid hiding or overwriting a repository that already
// treats .hop as user-owned source content.
+30 -7
View File
@@ -76,14 +76,24 @@ type Status struct {
Root string `json:"root"`
AcceptedHead State `json:"accepted_head"`
Attempts []Attempt `json:"attempts"`
RootStatus string `json:"root_status"`
RootStateID string `json:"root_state_id,omitempty"`
}
type AcceptResult struct {
State State `json:"state"`
ProposalPaths []string `json:"proposal_paths"`
CurrentPaths []string `json:"current_paths"`
Check *Check `json:"check,omitempty"`
Warnings []string `json:"warnings,omitempty"`
State State `json:"state"`
ProposalPaths []string `json:"proposal_paths"`
CurrentPaths []string `json:"current_paths"`
Check *Check `json:"check,omitempty"`
MaterializedRoot string `json:"materialized_root,omitempty"`
Warnings []string `json:"warnings,omitempty"`
}
type SyncResult struct {
State State `json:"state"`
Root string `json:"root"`
FromState string `json:"from_state"`
Changed bool `json:"changed"`
}
type PromptRedaction struct {
@@ -146,14 +156,15 @@ func (e *CheckFailedError) Error() string {
}
// CommittedStateError means the authoritative SQLite transition succeeded but
// a derived Git ref needs repair. Callers must not retry the state transition.
// a derived ref or visible-root synchronization needs repair. Callers must not
// retry the state transition.
type CommittedStateError struct {
State State
Err error
}
func (e *CommittedStateError) Error() string {
return fmt.Sprintf("state %s is committed, but derived Git refs need repair: %v", e.State.ID, e.Err)
return fmt.Sprintf("state %s is committed, but post-acceptance synchronization needs repair: %v", e.State.ID, e.Err)
}
func (e *CommittedStateError) Unwrap() error { return e.Err }
@@ -165,3 +176,15 @@ type ConflictError struct {
func (e *ConflictError) Error() string {
return "proposal overlaps changes accepted since its canonical anchor"
}
type RootConflictError struct {
Paths []string
Reason string
}
func (e *RootConflictError) Error() string {
if e.Reason != "" {
return e.Reason
}
return "visible project root has out-of-band changes; refusing to overwrite it"
}
+213 -9
View File
@@ -505,7 +505,19 @@ func (s *Service) Propose(ctx context.Context, stateID, summary string) (Proposa
return ProposalResult{Proposal: proposal, Checks: checks}, nil
}
// Accept advances Hop's internal accepted lineage without changing the visible
// project root. Controllers use this lower-level operation deliberately.
func (s *Service) Accept(ctx context.Context, proposalID string, checkCommand []string) (AcceptResult, error) {
return s.accept(ctx, proposalID, checkCommand, false)
}
// Land accepts a proposal and projects the resulting accepted tree into the
// visible project root while preserving the user's HEAD, branch, and index.
func (s *Service) Land(ctx context.Context, proposalID string, checkCommand []string) (AcceptResult, error) {
return s.accept(ctx, proposalID, checkCommand, true)
}
func (s *Service) accept(ctx context.Context, proposalID string, checkCommand []string, materialize bool) (AcceptResult, error) {
release, err := acquireProjectLock(ctx, s.Root, "accept")
if err != nil {
return AcceptResult{}, err
@@ -519,6 +531,18 @@ func (s *Service) Accept(ctx context.Context, proposalID string, checkCommand []
if proposal.Kind != StateProposal {
return AcceptResult{}, fmt.Errorf("state %s is %s, not a proposal", proposal.ID, proposal.Kind)
}
if existing, exists, err := s.Store.AcceptedForProposal(ctx, proposal.ID); err != nil {
return AcceptResult{}, err
} else if exists {
result := AcceptResult{State: existing}
if materialize {
if _, err := s.syncLocked(ctx); err != nil {
return result, &CommittedStateError{State: existing, Err: fmt.Errorf("repair visible root after prior acceptance: %w", err)}
}
result.MaterializedRoot = s.Root
}
return result, nil
}
attempt, err := s.Store.GetAttempt(ctx, proposal.AttemptID)
if err != nil {
return AcceptResult{}, err
@@ -543,12 +567,16 @@ func (s *Service) Accept(ctx context.Context, proposalID string, checkCommand []
if len(overlap) > 0 {
return AcceptResult{}, &ConflictError{Paths: overlap}
}
finalTree, mergeConflicts, err := s.Repo.ComposeTrees(ctx, base.GitCommit, current.GitCommit, proposal.GitCommit)
if err != nil {
return AcceptResult{}, err
}
if len(mergeConflicts) > 0 {
return AcceptResult{}, &ConflictError{Paths: mergeConflicts}
finalTree := proposal.SourceTree
if current.SourceTree != base.SourceTree {
var mergeConflicts []string
finalTree, mergeConflicts, err = s.Repo.ComposeTrees(ctx, base.GitCommit, current.GitCommit, proposal.GitCommit)
if err != nil {
return AcceptResult{}, err
}
if len(mergeConflicts) > 0 {
return AcceptResult{}, &ConflictError{Paths: mergeConflicts}
}
}
acceptedID := newID("a")
@@ -564,6 +592,11 @@ func (s *Service) Accept(ctx context.Context, proposalID string, checkCommand []
var recordedCheck *Check
validationRef := ""
defer func() {
if validationRef != "" {
_ = s.Repo.DeleteRef(ctx, "refs/hop/"+validationRef, commit)
}
}()
if len(checkCommand) > 0 {
checkID := newID("evidence")
validationRef = "validation/" + checkID
@@ -619,6 +652,14 @@ func (s *Service) Accept(ctx context.Context, proposalID string, checkCommand []
}
}
var rootBase State
if materialize {
rootBase, err = s.prepareRootMaterialization(ctx, finalTree)
if err != nil {
return AcceptResult{}, err
}
}
parents := canonicalizeParents([]Parent{
{StateID: current.ID, Role: "canonical_parent", Order: 0},
{StateID: proposal.ID, Role: "proposal_parent", Order: 1},
@@ -649,20 +690,144 @@ func (s *Service) Accept(ctx context.Context, proposalID string, checkCommand []
}
if validationRef != "" {
_ = s.Repo.DeleteRef(ctx, "refs/hop/"+validationRef, commit)
validationRef = ""
}
var warnings []string
if err := s.Repo.UpdateHiddenRef(ctx, acceptedRef, commit); err != nil {
warnings = append(warnings, "accepted state is durable in SQLite but refs/hop/accepted needs repair: "+err.Error())
}
return AcceptResult{
result := AcceptResult{
State: accepted,
ProposalPaths: proposalPaths,
CurrentPaths: currentPaths,
Check: recordedCheck,
Warnings: warnings,
}
if materialize {
if err := s.Repo.MaterializeTree(ctx, rootBase.SourceTree, accepted.SourceTree); err != nil {
return result, &CommittedStateError{State: accepted, Err: fmt.Errorf("visible root %s was not synchronized: %w", s.Root, err)}
}
if err := s.Store.CASMaterializedHead(ctx, rootBase.ID, accepted.ID); err != nil {
return result, &CommittedStateError{State: accepted, Err: fmt.Errorf("record visible root synchronization: %w", err)}
}
result.MaterializedRoot = s.Root
}
return result, nil
}
// Sync projects the current accepted tree into a visible root that still
// matches its durable materialized head. It is useful after controller-only accepts or
// when upgrading a project created before automatic materialization existed.
func (s *Service) Sync(ctx context.Context) (SyncResult, error) {
release, err := acquireProjectLock(ctx, s.Root, "accept")
if err != nil {
return SyncResult{}, err
}
defer release()
return s.syncLocked(ctx)
}
func (s *Service) syncLocked(ctx context.Context) (SyncResult, error) {
current, err := s.Store.AcceptedHead(ctx)
if err != nil {
return SyncResult{}, err
}
rootBase, err := s.prepareRootMaterialization(ctx, current.SourceTree)
if err != nil {
return SyncResult{}, err
}
changed := rootBase.SourceTree != current.SourceTree
if err := s.Repo.MaterializeTree(ctx, rootBase.SourceTree, current.SourceTree); err != nil {
return SyncResult{}, err
}
if err := s.Store.CASMaterializedHead(ctx, rootBase.ID, current.ID); err != nil {
return SyncResult{}, fmt.Errorf("record visible root synchronization: %w", err)
}
return SyncResult{
State: current,
Root: s.Root,
FromState: rootBase.ID,
Changed: changed,
}, nil
}
func (s *Service) prepareRootMaterialization(ctx context.Context, targetTree string) (State, error) {
rootState, matches, err := s.visibleRootState(ctx)
if err != nil {
return State{}, err
}
if !matches {
materialized, materializedErr := s.Store.MaterializedHead(ctx)
if materializedErr != nil {
return State{}, materializedErr
}
actual, snapshotErr := s.Repo.WorktreeTree(ctx, materialized.SourceTree)
if snapshotErr != nil {
return State{}, snapshotErr
}
paths, pathErr := s.Repo.ChangedPaths(ctx, materialized.SourceTree, actual)
if pathErr != nil {
return State{}, pathErr
}
if len(paths) == 0 {
paths = []string{"."}
}
return State{}, &RootConflictError{Paths: paths}
}
if err := s.Repo.CheckIndexSafe(ctx, rootState.SourceTree); err != nil {
return State{}, err
}
materialized, err := s.Store.MaterializedHead(ctx)
if err != nil {
return State{}, err
}
if rootState.ID != materialized.ID {
if err := s.Store.CASMaterializedHead(ctx, materialized.ID, rootState.ID); err != nil {
return State{}, fmt.Errorf("repair visible root projection marker: %w", err)
}
}
conflicts, err := s.Repo.MaterializationConflicts(ctx, rootState.SourceTree, targetTree)
if err != nil {
return State{}, err
}
if len(conflicts) > 0 {
return State{}, &RootConflictError{
Paths: conflicts,
Reason: "visible project root contains ignored or untracked paths that landing would overwrite",
}
}
return rootState, nil
}
func (s *Service) visibleRootState(ctx context.Context) (State, bool, error) {
materialized, err := s.Store.MaterializedHead(ctx)
if err != nil {
return State{}, false, err
}
actualTree, err := s.Repo.WorktreeTree(ctx, materialized.SourceTree)
if err != nil {
return State{}, false, err
}
if actualTree == materialized.SourceTree {
return materialized, true, nil
}
accepted, err := s.Store.AcceptedHead(ctx)
if err != nil {
return State{}, false, err
}
if accepted.ID == materialized.ID {
return State{}, false, nil
}
actualTree, err = s.Repo.WorktreeTree(ctx, accepted.SourceTree)
if err != nil {
return State{}, false, err
}
if actualTree == accepted.SourceTree {
return accepted, true, nil
}
return State{}, false, nil
}
func (s *Service) Undo(ctx context.Context) (State, error) {
release, err := acquireProjectLock(ctx, s.Root, "accept")
if err != nil {
@@ -684,6 +849,10 @@ func (s *Service) Undo(ctx context.Context) (State, error) {
if err != nil {
return State{}, err
}
rootBase, err := s.prepareRootMaterialization(ctx, previous.SourceTree)
if err != nil {
return State{}, err
}
undoID := newID("a")
commit, err := s.Repo.CommitTree(ctx, previous.SourceTree, []string{current.GitCommit}, fmt.Sprintf(
"Undo %s\n\nHop-State: %s\nHop-Reverts: %s\n", current.ID, undoID, current.ID))
@@ -717,8 +886,17 @@ func (s *Service) Undo(ctx context.Context) (State, error) {
if err != nil {
return State{}, mapHeadError(err)
}
var postCommitErrors []error
if err := s.Repo.UpdateHiddenRef(ctx, acceptedRef, undo.GitCommit); err != nil {
return undo, &CommittedStateError{State: undo, Err: err}
postCommitErrors = append(postCommitErrors, fmt.Errorf("repair refs/hop/accepted: %w", err))
}
if err := s.Repo.MaterializeTree(ctx, rootBase.SourceTree, undo.SourceTree); err != nil {
postCommitErrors = append(postCommitErrors, fmt.Errorf("visible root %s was not synchronized: %w", s.Root, err))
} else if err := s.Store.CASMaterializedHead(ctx, rootBase.ID, undo.ID); err != nil {
postCommitErrors = append(postCommitErrors, fmt.Errorf("record visible root synchronization: %w", err))
}
if len(postCommitErrors) > 0 {
return undo, &CommittedStateError{State: undo, Err: errors.Join(postCommitErrors...)}
}
return undo, nil
}
@@ -812,7 +990,33 @@ func (s *Service) History(ctx context.Context) ([]State, error) {
}
func (s *Service) Status(ctx context.Context) (Status, error) {
return s.Store.Status(ctx)
status, err := s.Store.Status(ctx)
if err != nil {
return Status{}, err
}
rootState, matches, err := s.visibleRootState(ctx)
if err != nil {
return Status{}, err
}
if !matches {
status.RootStatus = "diverged"
return status, nil
}
if err := s.Repo.CheckIndexSafe(ctx, rootState.SourceTree); err != nil {
var conflict *RootConflictError
if errors.As(err, &conflict) {
status.RootStatus = "diverged"
return status, nil
}
return Status{}, err
}
status.RootStateID = rootState.ID
if rootState.ID == status.AcceptedHead.ID {
status.RootStatus = "synchronized"
} else {
status.RootStatus = "stale"
}
return status, nil
}
func (s *Service) Diff(ctx context.Context, stateID string) (string, error) {
+388
View File
@@ -82,6 +82,7 @@ func TestInitRefusesAUserTrackedHopDirectory(t *testing.T) {
}
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()
@@ -112,15 +113,22 @@ func TestCLIJSONWorkflow(t *testing.T) {
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()
@@ -546,6 +554,364 @@ func TestOverlappingProposalAndFailedFinalCheckDoNotMoveHead(t *testing.T) {
}
}
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()
@@ -615,6 +981,17 @@ func runGitTest(t *testing.T, root string, args ...string) 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())
@@ -631,6 +1008,17 @@ func runCLIJSONTest(t *testing.T, args []string) map[string]any {
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())
+7
View File
@@ -33,6 +33,13 @@ func TestInstallSkillBundle(t *testing.T) {
if !strings.Contains(string(metadata), "allow_implicit_invocation: true") {
t.Fatal("installed skill does not permit Desktop implicit invocation")
}
skill, err := os.ReadFile(filepath.Join(result.Path, "SKILL.md"))
if err != nil {
t.Fatal(err)
}
if !strings.Contains(string(skill), "Auto-accept by default") {
t.Fatal("installed skill does not enable automatic acceptance")
}
if _, err := InstallSkill(base, false); err == nil || !strings.Contains(err.Error(), "already exists") {
t.Fatalf("second install error = %v, want existing-skill error", err)
}
+33 -8
View File
@@ -62,7 +62,7 @@ hop state <HOP_STATE_ID> --json
hop status --json
```
## Execute and submit
## Execute and auto-accept
1. Inspect and modify only the Hop workspace.
2. Keep the change scoped to the captured prompt.
@@ -79,7 +79,17 @@ hop status --json
hop propose --summary "<behavioral summary>" <HOP_STATE_ID>
```
6. Report the prompt state, proposal state, checks, and remaining risks.
6. Unless the user explicitly requested review-only mode, immediately accept
the proposal and validate the exact final tree:
```bash
hop land <proposal-state> -- <final-test-command> [args...]
```
7. Report the accepted result, validation, and remaining risks. Keep internal
state and evidence IDs out of the normal response unless they help explain a
failure or the user asks for them. Confirm that `hop land` reported the
selected visible project root as synchronized.
For a read-only or informational turn, the prompt state is sufficient; do not
invent a proposal when the workspace tree is unchanged.
@@ -88,16 +98,31 @@ Do not edit a frozen proposal. A user follow-up triggers this skill again;
run `hop begin` again before acting. Session binding selects the existing
attempt automatically, so the user never needs to carry state IDs.
## Land only with explicit authority
## Auto-accept by default
Capture the landing request with `hop begin` first. Then, only when the user
explicitly authorizes landing, run:
The captured task prompt authorizes accepting the local project changes needed
to complete that task. Do not ask for separate landing permission and do not
capture a second prompt merely to land. After checks pass and the proposal is
frozen, run `hop land` as part of the same turn.
```bash
hop land <proposal-state> -- <final-test-command> [args...]
```
Use the strongest relevant final validation command. If the task truly has no
runnable validation, `hop land <proposal-state>` is allowed and the final
response must say that acceptance was not validated by a command.
Stop before acceptance only when:
- the user explicitly says `review first`, `proposal only`, `do not land`, or
otherwise asks to approve the result before it is accepted;
- validation fails;
- Hop reports overlap, a changed accepted head, or visible-root divergence; or
- acceptance would require a destructive, external, or out-of-scope action not
authorized by the captured task.
On overlap or validation failure, preserve the proposal and report the block.
If visible-root synchronization is blocked, do not bypass it with `hop accept`,
force checkout, reset, or file copying. Preserve the proposal and identify the
user-owned paths that must be resolved. `hop accept` is reserved for an
explicitly controller-only workflow; Desktop work always uses `hop land`.
Use `hop undo` only after a separately captured, explicit user request.
Read [references/protocol.md](references/protocol.md) for state semantics, exit
+1 -1
View File
@@ -1,7 +1,7 @@
interface:
display_name: "Hop Version Control"
short_description: "Capture every coding prompt before project effects"
default_prompt: "Use $hop to capture this prompt first, complete the task in its isolated workspace, and submit a validated proposal."
default_prompt: "Use $hop to capture this prompt first, complete the task in its isolated workspace, validate it, and auto-accept the proposal unless I explicitly request review first."
policy:
allow_implicit_invocation: true
+19 -2
View File
@@ -48,7 +48,8 @@ hop init
hop start --agent <name> "<exact initial prompt>"
hop env <prompt-state>
hop prompt --from <state> "<exact follow-up prompt>"
hop land <proposal> -- <final validation command>
hop accept <proposal> -- <final validation command>
hop sync
hop undo
```
@@ -66,13 +67,25 @@ hop state "$HOP_STATE_ID" --json
hop status --json
hop check "$HOP_STATE_ID" -- <command>
hop propose --summary "<summary>" "$HOP_STATE_ID"
hop land <proposal-state> -- <final validation command>
```
`hop check` snapshots the attempt and runs the command in a detached worktree materialized from that exact checkpoint. Edits made concurrently in the live workspace do not change the tested tree.
`hop propose` freezes the current nonignored workspace tree. Later workspace edits cannot change the proposal.
`hop land` compares paths changed by the proposal with paths accepted since its base. Any shared changed path blocks landing. Disjoint proposals are composed with Git three-tree plumbing and may then be validated on the final tree.
The initial task prompt authorizes the agent to run `hop land` after successful
validation; a second user approval is not required. Manual review is an opt-in
mode: stop at the proposal only when the user explicitly asks to review or
approve before acceptance. Validation failure, overlap, a stale accepted head,
or newly required destructive/external scope also stops automatic acceptance.
`hop land` is the Desktop operation. It compares paths changed by the proposal with paths accepted since its base, validates and advances accepted state, then safely materializes that tree into the selected visible project root. The root must still match an accepted Hop ancestor, and ignored or untracked destination collisions block before acceptance. Materialization uses a disposable index and never moves HEAD, the active branch, or the user's real index.
`hop accept` is the controller/kernel operation. It advances SQLite and
`refs/hop/accepted` but intentionally leaves the visible root untouched.
`hop sync` safely catches a stale accepted-ancestor root up to the current
accepted state, including projects created with older Hop builds.
`hop begin` is the Codex Desktop entry point. It initializes Hop when necessary,
captures the current message before the agent performs project work, and uses
@@ -95,6 +108,7 @@ command, summary, output, or source file.
| `20` | Overlap or conservative conflict block |
| `21` | Accepted or attempt head changed during compare-and-swap |
| `22` | Validation command failed |
| `23` | Visible project root diverged or contains an overwrite collision |
A failed `hop check` or final landing check persists its evidence. A blocked or failed landing does not advance accepted state.
@@ -130,8 +144,11 @@ provide the same boundary inside compatible agent clients.
- **Missing Hop environment:** run `hop begin` before project work and use the returned state and workspace.
- **Check failure:** fix the live workspace, checkpoint/check again, then create a new proposal.
- **Review-only request:** preserve and report the proposal without landing it.
- **Frozen proposal needs changes:** record a follow-up prompt; never mutate the stored proposal.
- **Overlap on landing:** retain both lineages and reconcile through a new prompt against current accepted state.
- **Visible-root conflict:** preserve the proposal and the user's files. Do not substitute controller-only `hop accept`; resolve or capture the visible changes, then land again.
- **Controller-accepted root is stale:** run `hop sync`; it succeeds only from an accepted ancestor and never overwrites divergence.
- **Ref inconsistency:** run `hop doctor`; use `hop doctor --repair` only outside final validation.
- **Secrets:** Hop redacts high-confidence provider keys plus contextual tokens,
passwords, private keys, authorization headers, and credential-bearing URLs