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
+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)
}