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:
+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)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user