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:
@@ -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 user’s 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 user’s 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 repository’s `.git/info/exclude` receives `.hop/`; the public `.gitignore`, current branch, and real Git index are left alone.
|
||||
The repository’s `.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.
|
||||
|
||||
|
||||
@@ -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
@@ -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
@@ -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)
|
||||
|
||||
@@ -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
@@ -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
@@ -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) {
|
||||
|
||||
@@ -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())
|
||||
|
||||
@@ -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
@@ -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,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
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user