Initial Hop alpha

This commit is contained in:
cyph3rasi
2026-07-11 07:33:41 -07:00
commit 675135b77e
19 changed files with 5612 additions and 0 deletions
+466
View File
@@ -0,0 +1,466 @@
package hop
import (
"context"
"encoding/json"
"errors"
"flag"
"fmt"
"io"
"strings"
)
const usageText = `Hop — prompt-native version control
Usage:
hop init [path]
hop prompt [--from STATE] [--agent NAME] "instruction"
hop checkpoint STATE
hop check STATE -- COMMAND [ARG...]
hop propose [--summary TEXT] STATE
hop accept STATE [-- COMMAND [ARG...]]
hop status
hop graph
hop state STATE
hop env STATE
hop diff STATE
hop history
hop undo
hop doctor [--repair]
hop skill install [--path SKILLS_DIR] [--force]
hop skill print
hop version
Add --json anywhere for machine-readable output.
`
const Version = "0.1.0-alpha.1"
func RunCLI(args []string, stdout, stderr io.Writer) int {
jsonOutput, args := removeFlag(args, "--json")
if len(args) == 0 || args[0] == "help" || args[0] == "--help" || args[0] == "-h" {
fmt.Fprint(stdout, usageText)
return 0
}
ctx := context.Background()
command := args[0]
commandArgs := args[1:]
if command == "version" || command == "--version" {
if jsonOutput {
writeJSON(stdout, map[string]any{"ok": true, "version": Version})
} else {
fmt.Fprintf(stdout, "hop %s\n", Version)
}
return 0
}
if command == "skill" {
return runSkillCLI(commandArgs, jsonOutput, stdout, stderr)
}
if command == "init" {
path := "."
if len(commandArgs) > 1 {
fmt.Fprintln(stderr, "hop init accepts at most one path")
return 2
}
if len(commandArgs) == 1 {
path = commandArgs[0]
}
service, state, err := InitProject(ctx, path)
if err != nil {
return printCLIError(err, jsonOutput, stdout, stderr)
}
defer service.Close()
if jsonOutput {
writeJSON(stdout, map[string]any{"ok": true, "root": service.Root, "state": state})
} else {
fmt.Fprintf(stdout, "Initialized Hop at %s\nAccepted %s · tree %s\n", service.Root, state.ID, shortHash(state.SourceTree))
}
return 0
}
service, err := OpenProject(".")
if err != nil {
return printCLIError(err, jsonOutput, stdout, stderr)
}
defer service.Close()
var value any
switch command {
case "prompt", "start":
fs := flag.NewFlagSet("prompt", flag.ContinueOnError)
fs.SetOutput(stderr)
from := fs.String("from", "", "continue an existing attempt")
agent := fs.String("agent", "", "agent or harness name")
if err := fs.Parse(commandArgs); err != nil {
return 2
}
if len(fs.Args()) != 1 || strings.TrimSpace(fs.Args()[0]) == "" {
fmt.Fprintln(stderr, "exactly one non-empty prompt argument is required; quote prompts containing spaces")
return 2
}
message := fs.Args()[0]
result, err := service.CreatePrompt(ctx, message, *from, *agent)
if err != nil {
return printCLIError(err, jsonOutput, stdout, stderr)
}
value = result
if !jsonOutput {
if result.Checkpoint != nil {
fmt.Fprintf(stdout, "Checkpointed %s before the follow-up\n", result.Checkpoint.ID)
}
fmt.Fprintf(stdout, "Created prompt state %s before delivery\nWorkspace: %s\n", result.Prompt.ID, result.Workspace)
fmt.Fprintf(stdout, "Environment: HOP_ROOT=%s HOP_STATE_ID=%s HOP_TASK_ID=%s HOP_ATTEMPT_ID=%s\n", service.Root, result.Prompt.ID, result.Task.ID, result.Attempt.ID)
}
case "checkpoint":
if len(commandArgs) != 1 {
fmt.Fprintln(stderr, "usage: hop checkpoint STATE")
return 2
}
state, err := service.Checkpoint(ctx, commandArgs[0])
if err != nil {
return printCLIError(err, jsonOutput, stdout, stderr)
}
value = state
if !jsonOutput {
fmt.Fprintf(stdout, "Created checkpoint %s · tree %s\n", state.ID, shortHash(state.SourceTree))
}
case "check":
stateID, argv, ok := splitCommand(commandArgs)
if !ok {
fmt.Fprintln(stderr, "usage: hop check STATE -- COMMAND [ARG...]")
return 2
}
check, err := service.RunCheck(ctx, stateID, argv)
value = check
if !jsonOutput {
fmt.Fprintf(stdout, "$ %s\n%s", shellQuote(check.Command), check.Output)
if check.Output != "" && !strings.HasSuffix(check.Output, "\n") {
fmt.Fprintln(stdout)
}
fmt.Fprintf(stdout, "Check %s · exit %d · tree %s\n", check.ID, check.ExitCode, shortHash(check.TreeHash))
}
if err != nil {
return printCLIError(err, jsonOutput, stdout, stderr)
}
case "propose", "submit":
fs := flag.NewFlagSet("propose", flag.ContinueOnError)
fs.SetOutput(stderr)
summary := fs.String("summary", "", "result summary")
if err := fs.Parse(commandArgs); err != nil {
return 2
}
if len(fs.Args()) != 1 {
fmt.Fprintln(stderr, "usage: hop propose [--summary TEXT] STATE")
return 2
}
result, err := service.Propose(ctx, fs.Args()[0], *summary)
if err != nil {
return printCLIError(err, jsonOutput, stdout, stderr)
}
value = result
if !jsonOutput {
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":
stateID, argv, ok := splitOptionalCommand(commandArgs)
if !ok {
fmt.Fprintln(stderr, "usage: hop accept STATE [-- COMMAND [ARG...]]")
return 2
}
result, err := service.Accept(ctx, stateID, argv)
value = result
if err != nil {
return printCLIError(err, jsonOutput, stdout, stderr)
}
if !jsonOutput {
fmt.Fprintf(stdout, "Accepted as %s · tree %s\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.")
}
for _, warning := range result.Warnings {
fmt.Fprintf(stderr, "Warning: %s\n", warning)
}
}
case "status":
status, err := service.Status(ctx)
if err != nil {
return printCLIError(err, jsonOutput, stdout, stderr)
}
value = status
if !jsonOutput {
fmt.Fprintf(stdout, "Accepted: %s · tree %s\n", status.AcceptedHead.ID, shortHash(status.AcceptedHead.SourceTree))
if len(status.Attempts) == 0 {
fmt.Fprintln(stdout, "No attempts yet.")
}
for _, attempt := range status.Attempts {
fmt.Fprintf(stdout, "%s %-10s head=%s %s\n", attempt.ID, attempt.Status, attempt.HeadStateID, attempt.Workspace)
}
}
case "graph":
rows, err := service.Graph(ctx)
if err != nil {
return printCLIError(err, jsonOutput, stdout, stderr)
}
value = rows
if !jsonOutput {
for _, row := range rows {
parents := make([]string, 0, len(row.Parents))
for _, parent := range row.Parents {
parents = append(parents, parent.Role+"="+parent.StateID)
}
label := row.State.Summary
if label == "" {
label = row.State.Prompt
}
fmt.Fprintf(stdout, "%-10s %-28s %-50s %s\n", row.State.Kind, row.State.ID, strings.Join(parents, " "), label)
}
}
case "state", "inspect":
if len(commandArgs) != 1 {
fmt.Fprintln(stderr, "usage: hop state STATE")
return 2
}
state, err := service.State(ctx, commandArgs[0])
if err != nil {
return printCLIError(err, jsonOutput, stdout, stderr)
}
value = state
if !jsonOutput {
fmt.Fprintf(stdout, "%s %s\nTree: %s\nCommit: %s\nDigest: %s\n", state.Kind, state.ID, state.SourceTree, state.GitCommit, state.Digest)
if state.Prompt != "" {
fmt.Fprintf(stdout, "Prompt: %s\n", state.Prompt)
}
if state.Summary != "" {
fmt.Fprintf(stdout, "Summary: %s\n", state.Summary)
}
for _, parent := range state.Parents {
fmt.Fprintf(stdout, "Parent: %-18s %s\n", parent.Role, parent.StateID)
}
}
case "env":
if len(commandArgs) != 1 {
fmt.Fprintln(stderr, "usage: hop env STATE")
return 2
}
result, err := service.EnvironmentForState(ctx, commandArgs[0])
if err != nil {
return printCLIError(err, jsonOutput, stdout, stderr)
}
value = result
if !jsonOutput {
for _, name := range []string{"HOP_ROOT", "HOP_STATE_ID", "HOP_TASK_ID", "HOP_ATTEMPT_ID", "HOP_WORKSPACE"} {
fmt.Fprintf(stdout, "export %s=%s\n", name, shellQuote([]string{result.Variables[name]}))
}
fmt.Fprintf(stdout, "cd %s\n", shellQuote([]string{result.Workspace}))
}
case "diff":
if len(commandArgs) != 1 {
fmt.Fprintln(stderr, "usage: hop diff STATE")
return 2
}
diff, err := service.Diff(ctx, commandArgs[0])
if err != nil {
return printCLIError(err, jsonOutput, stdout, stderr)
}
value = map[string]string{"state": commandArgs[0], "diff": diff}
if !jsonOutput {
fmt.Fprint(stdout, diff)
}
case "history":
states, err := service.History(ctx)
if err != nil {
return printCLIError(err, jsonOutput, stdout, stderr)
}
value = states
if !jsonOutput {
for _, state := range states {
fmt.Fprintf(stdout, "%s %s %s\n", state.ID, shortHash(state.SourceTree), state.Summary)
}
}
case "undo":
if len(commandArgs) != 0 {
fmt.Fprintln(stderr, "usage: hop undo")
return 2
}
state, err := service.Undo(ctx)
var committed *CommittedStateError
if err != nil && !errors.As(err, &committed) {
return printCLIError(err, jsonOutput, stdout, stderr)
}
if committed != nil {
state = committed.State
value = map[string]any{"state": state, "warnings": []string{committed.Error()}}
} else {
value = state
}
if !jsonOutput {
fmt.Fprintf(stdout, "Created forward undo state %s · tree %s\n", state.ID, shortHash(state.SourceTree))
if committed != nil {
fmt.Fprintf(stderr, "Warning: %s\n", committed.Error())
}
}
case "doctor":
fs := flag.NewFlagSet("doctor", flag.ContinueOnError)
fs.SetOutput(stderr)
repair := fs.Bool("repair", false, "repair the internal accepted ref")
if err := fs.Parse(commandArgs); err != nil || len(fs.Args()) != 0 {
return 2
}
report, err := service.Doctor(ctx, *repair)
if err != nil {
return printCLIError(err, jsonOutput, stdout, stderr)
}
value = report
if !jsonOutput {
if report.OK {
fmt.Fprintf(stdout, "Hop project is healthy · %s\n", report.AcceptedState)
} else {
for _, problem := range report.Problems {
fmt.Fprintf(stdout, "Problem: %s\n", problem)
}
}
}
default:
fmt.Fprintf(stderr, "unknown command %q\n\n%s", command, usageText)
return 2
}
if jsonOutput {
writeJSON(stdout, map[string]any{"ok": true, "data": value})
}
return 0
}
func runSkillCLI(args []string, jsonOutput bool, stdout, stderr io.Writer) int {
if len(args) == 0 {
fmt.Fprintln(stderr, "usage: hop skill install [--path SKILLS_DIR] [--force] | hop skill print")
return 2
}
switch args[0] {
case "install":
fs := flag.NewFlagSet("skill install", flag.ContinueOnError)
fs.SetOutput(stderr)
path := fs.String("path", "", "parent skills directory")
force := fs.Bool("force", false, "update an existing Hop skill")
if err := fs.Parse(args[1:]); err != nil || len(fs.Args()) != 0 {
return 2
}
result, err := InstallSkill(*path, *force)
if err != nil {
return printCLIError(err, jsonOutput, stdout, stderr)
}
if jsonOutput {
writeJSON(stdout, map[string]any{"ok": true, "data": result})
} else {
fmt.Fprintf(stdout, "Installed Hop skill at %s\n", result.Path)
}
return 0
case "print":
if len(args) != 1 {
fmt.Fprintln(stderr, "usage: hop skill print")
return 2
}
contents, err := EmbeddedSkillText()
if err != nil {
return printCLIError(err, jsonOutput, stdout, stderr)
}
if jsonOutput {
writeJSON(stdout, map[string]any{"ok": true, "data": map[string]string{"skill": contents}})
} else {
fmt.Fprint(stdout, contents)
}
return 0
default:
fmt.Fprintf(stderr, "unknown skill command %q\n", args[0])
return 2
}
}
func printCLIError(err error, jsonOutput bool, stdout, stderr io.Writer) int {
code := 1
var conflict *ConflictError
var stale *StaleHeadError
var failed *CheckFailedError
switch {
case errors.As(err, &conflict):
code = 20
case errors.As(err, &stale):
code = 21
case errors.As(err, &failed):
code = 22
}
if jsonOutput {
writeJSON(stdout, map[string]any{"ok": false, "error": err.Error(), "exit_code": code})
} else {
fmt.Fprintf(stderr, "hop: %v\n", err)
if conflict != nil && len(conflict.Paths) > 0 {
fmt.Fprintln(stderr, "Overlapping paths:")
for _, path := range conflict.Paths {
fmt.Fprintf(stderr, " %s\n", path)
}
}
}
return code
}
func removeFlag(args []string, wanted string) (bool, []string) {
found := false
filtered := make([]string, 0, len(args))
for i, arg := range args {
if arg == "--" {
filtered = append(filtered, args[i:]...)
break
}
if arg == wanted {
found = true
continue
}
filtered = append(filtered, arg)
}
return found, filtered
}
func splitCommand(args []string) (string, []string, bool) {
if len(args) < 3 || args[1] != "--" {
return "", nil, false
}
return args[0], args[2:], len(args[2:]) > 0
}
func splitOptionalCommand(args []string) (string, []string, bool) {
if len(args) == 1 {
return args[0], nil, true
}
if len(args) >= 3 && args[1] == "--" {
return args[0], args[2:], true
}
return "", nil, false
}
func writeJSON(w io.Writer, value any) {
encoder := json.NewEncoder(w)
encoder.SetIndent("", " ")
_ = encoder.Encode(value)
}
func shortHash(hash string) string {
if len(hash) <= 12 {
return hash
}
return hash[:12]
}
+1498
View File
File diff suppressed because it is too large Load Diff
+1033
View File
File diff suppressed because it is too large Load Diff
+28
View File
@@ -0,0 +1,28 @@
package hop
import (
"crypto/rand"
"encoding/base32"
"fmt"
"strings"
"time"
)
var idEncoding = base32.NewEncoding("0123456789ABCDEFGHJKMNPQRSTVWXYZ").WithPadding(base32.NoPadding)
func newID(prefix string) string {
var entropy [8]byte
if _, err := rand.Read(entropy[:]); err != nil {
panic(fmt.Sprintf("generate id: %v", err))
}
stamp := uint64(time.Now().UTC().UnixMilli())
var raw [14]byte
raw[0] = byte(stamp >> 40)
raw[1] = byte(stamp >> 32)
raw[2] = byte(stamp >> 24)
raw[3] = byte(stamp >> 16)
raw[4] = byte(stamp >> 8)
raw[5] = byte(stamp)
copy(raw[6:], entropy[:])
return strings.ToUpper(prefix) + "_" + idEncoding.EncodeToString(raw[:])
}
+155
View File
@@ -0,0 +1,155 @@
package hop
import (
"fmt"
"time"
)
type StateKind string
const (
StatePrompt StateKind = "prompt"
StateCheckpoint StateKind = "checkpoint"
StateProposal StateKind = "proposal"
StateAccepted StateKind = "accepted"
StateFailed StateKind = "failed"
StateCancelled StateKind = "cancelled"
)
type Parent struct {
StateID string `json:"state_id"`
Role string `json:"role"`
Order int `json:"order"`
}
type State struct {
ID string `json:"id"`
Kind StateKind `json:"kind"`
TaskID string `json:"task_id,omitempty"`
AttemptID string `json:"attempt_id,omitempty"`
CanonicalAnchorID string `json:"canonical_anchor_id,omitempty"`
SourceTree string `json:"source_tree"`
GitCommit string `json:"git_commit"`
Prompt string `json:"prompt,omitempty"`
Summary string `json:"summary,omitempty"`
Agent string `json:"agent,omitempty"`
Digest string `json:"digest"`
CreatedAt time.Time `json:"created_at"`
Parents []Parent `json:"parents,omitempty"`
}
type Task struct {
ID string `json:"id"`
Title string `json:"title"`
Status string `json:"status"`
CreatedAt time.Time `json:"created_at"`
}
type Attempt struct {
ID string `json:"id"`
TaskID string `json:"task_id"`
Agent string `json:"agent,omitempty"`
Workspace string `json:"workspace"`
BaseStateID string `json:"base_state_id"`
HeadStateID string `json:"head_state_id"`
Status string `json:"status"`
CreatedAt time.Time `json:"created_at"`
}
type Check struct {
ID string `json:"id"`
AttemptID string `json:"attempt_id"`
StateID string `json:"state_id,omitempty"`
TreeHash string `json:"tree_hash"`
Command []string `json:"command"`
ExitCode int `json:"exit_code"`
Output string `json:"output"`
CreatedAt time.Time `json:"created_at"`
}
type GraphRow struct {
State State `json:"state"`
Parents []Parent `json:"parents"`
}
type Status struct {
Root string `json:"root"`
AcceptedHead State `json:"accepted_head"`
Attempts []Attempt `json:"attempts"`
}
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"`
}
type PromptResult struct {
Prompt State `json:"prompt"`
Checkpoint *State `json:"checkpoint,omitempty"`
Task Task `json:"task"`
Attempt Attempt `json:"attempt"`
Workspace string `json:"workspace"`
Deliver []string `json:"deliver"`
}
type EnvironmentResult struct {
State State `json:"state"`
Attempt Attempt `json:"attempt"`
Workspace string `json:"workspace"`
Variables map[string]string `json:"variables"`
}
type ProposalResult struct {
Proposal State `json:"proposal"`
Checks []Check `json:"checks"`
}
type DoctorReport struct {
OK bool `json:"ok"`
AcceptedState string `json:"accepted_state"`
AcceptedCommit string `json:"accepted_commit"`
RefCommit string `json:"ref_commit,omitempty"`
Repaired bool `json:"repaired"`
Problems []string `json:"problems,omitempty"`
}
type StaleHeadError struct {
Expected string
Actual string
}
func (e *StaleHeadError) Error() string {
return "accepted head changed while the operation was running"
}
type CheckFailedError struct {
Check Check
}
func (e *CheckFailedError) Error() string {
return "validation command failed"
}
// CommittedStateError means the authoritative SQLite transition succeeded but
// a derived Git ref 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)
}
func (e *CommittedStateError) Unwrap() error { return e.Err }
type ConflictError struct {
Paths []string
}
func (e *ConflictError) Error() string {
return "proposal overlaps changes accepted since its canonical anchor"
}
File diff suppressed because it is too large Load Diff
+578
View File
@@ -0,0 +1,578 @@
package hop
import (
"bytes"
"context"
"encoding/json"
"errors"
"os"
"os/exec"
"path/filepath"
"strings"
"sync"
"testing"
"time"
)
func TestInitPreservesGitBranchIndexAndWorkingTree(t *testing.T) {
ctx := context.Background()
root := t.TempDir()
runGitTest(t, root, "init", "--quiet")
writeTestFile(t, filepath.Join(root, "tracked.txt"), "committed\n")
runGitTest(t, root, "add", "tracked.txt")
runGitTest(t, root, "-c", "user.name=Test", "-c", "user.email=test@example.com", "commit", "--quiet", "-m", "initial")
writeTestFile(t, filepath.Join(root, "tracked.txt"), "staged\n")
runGitTest(t, root, "add", "tracked.txt")
writeTestFile(t, filepath.Join(root, "tracked.txt"), "working\n")
writeTestFile(t, filepath.Join(root, "untracked.txt"), "untracked\n")
beforeHead := runGitTest(t, root, "rev-parse", "HEAD")
beforeBranch := runGitTest(t, root, "symbolic-ref", "--short", "HEAD")
beforeIndex := runGitTest(t, root, "diff", "--cached", "--binary")
beforeStatus := runGitTest(t, root, "status", "--porcelain=v1")
service, initial, err := InitProject(ctx, root)
if err != nil {
t.Fatal(err)
}
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, "diff", "--cached", "--binary"); got != beforeIndex {
t.Fatal("Hop init changed the user's index")
}
if got := runGitTest(t, root, "status", "--porcelain=v1"); got != beforeStatus {
t.Fatalf("working status changed:\nwant %q\n got %q", beforeStatus, got)
}
assertTreeFiles(t, service, initial.GitCommit, map[string]string{
"tracked.txt": "working\n",
"untracked.txt": "untracked\n",
})
if err := service.Close(); err != nil {
t.Fatal(err)
}
service, again, err := InitProject(ctx, root)
if err != nil {
t.Fatal(err)
}
t.Cleanup(func() { _ = service.Close() })
if again.ID != initial.ID {
t.Fatalf("idempotent init created %s, want existing %s", again.ID, initial.ID)
}
}
func TestInitRefusesAUserTrackedHopDirectory(t *testing.T) {
root := t.TempDir()
runGitTest(t, root, "init", "--quiet")
writeTestFile(t, filepath.Join(root, ".hop", "user-owned.txt"), "do not overwrite\n")
runGitTest(t, root, "add", "-f", ".hop/user-owned.txt")
_, _, err := InitProject(context.Background(), root)
if err == nil || !strings.Contains(err.Error(), ".hop is already tracked") {
t.Fatalf("InitProject error = %v, want tracked .hop refusal", err)
}
contents, readErr := os.ReadFile(filepath.Join(root, ".hop", "user-owned.txt"))
if readErr != nil || string(contents) != "do not overwrite\n" {
t.Fatalf("tracked .hop content changed: %q, %v", string(contents), readErr)
}
}
func TestCLIJSONWorkflow(t *testing.T) {
root := t.TempDir()
writeTestFile(t, filepath.Join(root, "base.txt"), "base\n")
previousDirectory, err := os.Getwd()
if err != nil {
t.Fatal(err)
}
if err := os.Chdir(root); err != nil {
t.Fatal(err)
}
t.Cleanup(func() { _ = os.Chdir(previousDirectory) })
runCLIJSONTest(t, []string{"init", "--json"})
started := runCLIJSONTest(t, []string{"start", "--agent", "fake", "--json", "Add CLI file"})
data := objectField(t, started, "data")
prompt := objectField(t, data, "prompt")
promptID := stringField(t, prompt, "id")
workspace := stringField(t, data, "workspace")
if promptID == "" || workspace == "" {
t.Fatal("start JSON omitted prompt ID or workspace")
}
writeTestFile(t, filepath.Join(workspace, "cli.txt"), "cli\n")
proposed := runCLIJSONTest(t, []string{"propose", "--summary", "CLI change", "--json", promptID})
proposal := objectField(t, objectField(t, proposed, "data"), "proposal")
proposalID := stringField(t, proposal, "id")
accepted := runCLIJSONTest(t, []string{"land", proposalID, "--json", "--", "sh", "-c", "test -f cli.txt"})
acceptedState := objectField(t, objectField(t, accepted, "data"), "state")
if kind := stringField(t, acceptedState, "kind"); kind != string(StateAccepted) {
t.Fatalf("landed kind = %q, want %q", kind, StateAccepted)
}
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")
}
}
func TestDoctorRejectsCommitTreeAndDigestMismatch(t *testing.T) {
ctx := context.Background()
service, initial := newTestProject(t, map[string]string{"base.txt": "base\n"})
emptyTree, err := service.Repo.EmptyTree(ctx)
if err != nil {
t.Fatal(err)
}
if _, err := service.Store.db.ExecContext(ctx,
`UPDATE states SET source_tree = ?, digest = 'tampered' WHERE id = ?`, emptyTree, initial.ID); err != nil {
t.Fatal(err)
}
report, err := service.Doctor(ctx, false)
if err != nil {
t.Fatal(err)
}
if report.OK {
t.Fatal("doctor approved a state whose commit tree and digest were tampered")
}
joined := strings.Join(report.Problems, "\n")
if !strings.Contains(joined, "records tree") || !strings.Contains(joined, "digest mismatch") {
t.Fatalf("doctor problems did not explain both mismatches: %s", joined)
}
}
func TestOpenProjectInsideFinalValidationDoesNotReacquireAcceptanceLock(t *testing.T) {
ctx := context.Background()
service, _ := newTestProject(t, map[string]string{"base.txt": "base\n"})
release, err := acquireProjectLock(ctx, service.Root, "accept")
if err != nil {
t.Fatal(err)
}
defer release()
previous, hadPrevious := os.LookupEnv("HOP_ACCEPTANCE_LOCK_HELD")
if err := os.Setenv("HOP_ACCEPTANCE_LOCK_HELD", "1"); err != nil {
t.Fatal(err)
}
t.Cleanup(func() {
if hadPrevious {
_ = os.Setenv("HOP_ACCEPTANCE_LOCK_HELD", previous)
} else {
_ = os.Unsetenv("HOP_ACCEPTANCE_LOCK_HELD")
}
})
opened := make(chan error, 1)
go func() {
nested, openErr := OpenProject(service.Root)
if openErr == nil {
openErr = nested.Close()
}
opened <- openErr
}()
select {
case openErr := <-opened:
if openErr != nil {
t.Fatal(openErr)
}
case <-time.After(2 * time.Second):
t.Fatal("OpenProject deadlocked by reacquiring the acceptance lock")
}
}
func TestPromptFollowupAndProposalAreImmutable(t *testing.T) {
ctx := context.Background()
service, initial := newTestProject(t, map[string]string{"app.txt": "initial\n"})
exactPrompt := " Change the app\nwithout normalizing this text. "
started, err := service.CreatePrompt(ctx, exactPrompt, "", "test-agent")
if err != nil {
t.Fatal(err)
}
if started.Prompt.SourceTree != initial.SourceTree {
t.Fatalf("prompt tree = %s, want accepted tree %s", started.Prompt.SourceTree, initial.SourceTree)
}
if started.Prompt.Prompt != exactPrompt {
t.Fatalf("stored prompt = %q, want exact %q", started.Prompt.Prompt, exactPrompt)
}
writeTestFile(t, filepath.Join(started.Workspace, "app.txt"), "first change\n")
followup, err := service.CreatePrompt(ctx, "Use the other approach", started.Prompt.ID, "")
if err != nil {
t.Fatal(err)
}
if followup.Checkpoint == nil {
t.Fatal("follow-up did not create a checkpoint")
}
if followup.Prompt.SourceTree != followup.Checkpoint.SourceTree {
t.Fatal("follow-up prompt did not preserve the checkpoint tree")
}
if followup.Prompt.SourceTree == initial.SourceTree {
t.Fatal("checkpoint failed to capture workspace edits")
}
parent, err := service.Store.ParentByRole(ctx, followup.Prompt.ID, "run_parent")
if err != nil {
t.Fatal(err)
}
if parent.StateID != followup.Checkpoint.ID {
t.Fatalf("follow-up parent = %s, want checkpoint %s", parent.StateID, followup.Checkpoint.ID)
}
writeTestFile(t, filepath.Join(started.Workspace, "app.txt"), "proposed\n")
proposed, err := service.Propose(ctx, followup.Prompt.ID, "Changed the app")
if err != nil {
t.Fatal(err)
}
frozenTree := proposed.Proposal.SourceTree
writeTestFile(t, filepath.Join(started.Workspace, "app.txt"), "changed after proposal\n")
stored, err := service.State(ctx, proposed.Proposal.ID)
if err != nil {
t.Fatal(err)
}
if stored.SourceTree != frozenTree {
t.Fatal("proposal tree changed after later workspace edits")
}
}
func TestCheckRunsAgainstTheRecordedCheckpointTree(t *testing.T) {
ctx := context.Background()
service, _ := newTestProject(t, map[string]string{"app.txt": "initial\n"})
started, err := service.CreatePrompt(ctx, "Check exact state", "", "test")
if err != nil {
t.Fatal(err)
}
writeTestFile(t, filepath.Join(started.Workspace, "app.txt"), "checkpointed\n")
signal := filepath.Join(t.TempDir(), "check-started")
type outcome struct {
check Check
err error
}
result := make(chan outcome, 1)
go func() {
check, checkErr := service.RunCheck(ctx, started.Prompt.ID, []string{
"sh", "-c", `touch "$1"; sleep 0.2; cat app.txt`, "hop-check", signal,
})
result <- outcome{check: check, err: checkErr}
}()
deadline := time.Now().Add(5 * time.Second)
for {
if _, err := os.Stat(signal); err == nil {
break
}
if time.Now().After(deadline) {
t.Fatal("validation command did not start")
}
time.Sleep(10 * time.Millisecond)
}
writeTestFile(t, filepath.Join(started.Workspace, "app.txt"), "raced edit\n")
finished := <-result
if finished.err != nil {
t.Fatal(finished.err)
}
if finished.check.Output != "checkpointed\n" {
t.Fatalf("check observed %q, want checkpointed tree", finished.check.Output)
}
}
func TestDisjointProposalsLandAndUndoMovesForward(t *testing.T) {
ctx := context.Background()
service, initial := newTestProject(t, map[string]string{"base.txt": "base\n"})
first, err := service.CreatePrompt(ctx, "Add one", "", "agent-one")
if err != nil {
t.Fatal(err)
}
second, err := service.CreatePrompt(ctx, "Add two", "", "agent-two")
if err != nil {
t.Fatal(err)
}
writeTestFile(t, filepath.Join(first.Workspace, "one.txt"), "one\n")
writeTestFile(t, filepath.Join(second.Workspace, "two.txt"), "two\n")
proposalOne, err := service.Propose(ctx, first.Prompt.ID, "Add one")
if err != nil {
t.Fatal(err)
}
proposalTwo, err := service.Propose(ctx, second.Prompt.ID, "Add two")
if err != nil {
t.Fatal(err)
}
acceptedOne, err := service.Accept(ctx, proposalOne.Proposal.ID, nil)
if err != nil {
t.Fatal(err)
}
acceptedTwo, err := service.Accept(ctx, proposalTwo.Proposal.ID, nil)
if err != nil {
t.Fatal(err)
}
if acceptedTwo.State.SourceTree == acceptedOne.State.SourceTree {
t.Fatal("second disjoint acceptance did not change the accepted tree")
}
assertTreeFiles(t, service, acceptedTwo.State.GitCommit, map[string]string{
"base.txt": "base\n",
"one.txt": "one\n",
"two.txt": "two\n",
})
undo, err := service.Undo(ctx)
if err != nil {
t.Fatal(err)
}
if undo.ID == acceptedOne.State.ID || undo.ID == acceptedTwo.State.ID {
t.Fatal("undo rewrote an old state instead of creating a new one")
}
if undo.SourceTree != acceptedOne.State.SourceTree {
t.Fatalf("undo tree = %s, want previous accepted tree %s", undo.SourceTree, acceptedOne.State.SourceTree)
}
if undo.SourceTree == initial.SourceTree {
t.Fatal("undo erased more than the latest accepted transition")
}
assertTreeFiles(t, service, undo.GitCommit, map[string]string{
"base.txt": "base\n",
"one.txt": "one\n",
})
assertTreeMissing(t, service, undo.GitCommit, "two.txt")
report, err := service.Doctor(ctx, false)
if err != nil {
t.Fatal(err)
}
if !report.OK {
t.Fatalf("doctor reported problems: %#v", report.Problems)
}
}
func TestConcurrentDisjointAcceptancesSerialize(t *testing.T) {
ctx := context.Background()
service, _ := newTestProject(t, map[string]string{"base.txt": "base\n"})
first, err := service.CreatePrompt(ctx, "Add alpha", "", "alpha")
if err != nil {
t.Fatal(err)
}
second, err := service.CreatePrompt(ctx, "Add beta", "", "beta")
if err != nil {
t.Fatal(err)
}
writeTestFile(t, filepath.Join(first.Workspace, "alpha.txt"), "alpha\n")
writeTestFile(t, filepath.Join(second.Workspace, "beta.txt"), "beta\n")
alpha, err := service.Propose(ctx, first.Prompt.ID, "Alpha")
if err != nil {
t.Fatal(err)
}
beta, err := service.Propose(ctx, second.Prompt.ID, "Beta")
if err != nil {
t.Fatal(err)
}
proposalIDs := []string{alpha.Proposal.ID, beta.Proposal.ID}
errs := make(chan error, len(proposalIDs))
var wg sync.WaitGroup
for _, id := range proposalIDs {
wg.Add(1)
go func() {
defer wg.Done()
_, acceptErr := service.Accept(ctx, id, nil)
errs <- acceptErr
}()
}
wg.Wait()
close(errs)
for acceptErr := range errs {
if acceptErr != nil {
t.Fatalf("concurrent acceptance: %v", acceptErr)
}
}
head, err := service.Store.AcceptedHead(ctx)
if err != nil {
t.Fatal(err)
}
assertTreeFiles(t, service, head.GitCommit, map[string]string{
"base.txt": "base\n",
"alpha.txt": "alpha\n",
"beta.txt": "beta\n",
})
history, err := service.History(ctx)
if err != nil {
t.Fatal(err)
}
if len(history) != 3 {
t.Fatalf("canonical history has %d states, want initial plus two acceptances", len(history))
}
}
func TestOverlappingProposalAndFailedFinalCheckDoNotMoveHead(t *testing.T) {
ctx := context.Background()
service, _ := newTestProject(t, map[string]string{"shared.txt": "base\n"})
first, err := service.CreatePrompt(ctx, "First edit", "", "one")
if err != nil {
t.Fatal(err)
}
second, err := service.CreatePrompt(ctx, "Second edit", "", "two")
if err != nil {
t.Fatal(err)
}
writeTestFile(t, filepath.Join(first.Workspace, "shared.txt"), "first\n")
writeTestFile(t, filepath.Join(second.Workspace, "shared.txt"), "second\n")
firstProposal, err := service.Propose(ctx, first.Prompt.ID, "First")
if err != nil {
t.Fatal(err)
}
secondProposal, err := service.Propose(ctx, second.Prompt.ID, "Second")
if err != nil {
t.Fatal(err)
}
firstAccepted, err := service.Accept(ctx, firstProposal.Proposal.ID, nil)
if err != nil {
t.Fatal(err)
}
_, err = service.Accept(ctx, secondProposal.Proposal.ID, nil)
var conflict *ConflictError
if !errors.As(err, &conflict) {
t.Fatalf("second acceptance error = %v, want ConflictError", err)
}
if len(conflict.Paths) != 1 || conflict.Paths[0] != "shared.txt" {
t.Fatalf("conflict paths = %#v", conflict.Paths)
}
head, err := service.Store.AcceptedHead(ctx)
if err != nil {
t.Fatal(err)
}
if head.ID != firstAccepted.State.ID {
t.Fatalf("blocked proposal moved accepted head to %s", head.ID)
}
third, err := service.CreatePrompt(ctx, "Add safe file", "", "three")
if err != nil {
t.Fatal(err)
}
writeTestFile(t, filepath.Join(third.Workspace, "safe.txt"), "safe\n")
thirdProposal, err := service.Propose(ctx, third.Prompt.ID, "Safe")
if err != nil {
t.Fatal(err)
}
_, err = service.Accept(ctx, thirdProposal.Proposal.ID, []string{"sh", "-c", "exit 9"})
var checkFailed *CheckFailedError
if !errors.As(err, &checkFailed) {
t.Fatalf("failed validation error = %v, want CheckFailedError", err)
}
if checkFailed.Check.StateID == "" {
t.Fatal("failed final-tree validation was not attached to a durable state")
}
failedState, err := service.Store.GetState(ctx, checkFailed.Check.StateID)
if err != nil {
t.Fatal(err)
}
if failedState.Kind != StateFailed || failedState.SourceTree != checkFailed.Check.TreeHash {
t.Fatalf("failed state = %#v, check tree = %s", failedState, checkFailed.Check.TreeHash)
}
head, err = service.Store.AcceptedHead(ctx)
if err != nil {
t.Fatal(err)
}
if head.ID != firstAccepted.State.ID {
t.Fatal("failed final-tree validation moved accepted head")
}
}
func newTestProject(t *testing.T, files map[string]string) (*Service, State) {
t.Helper()
root := t.TempDir()
for path, contents := range files {
writeTestFile(t, filepath.Join(root, path), contents)
}
service, initial, err := InitProject(context.Background(), root)
if err != nil {
t.Fatal(err)
}
t.Cleanup(func() { _ = service.Close() })
return service, initial
}
func writeTestFile(t *testing.T, path, contents string) {
t.Helper()
if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(path, []byte(contents), 0o644); err != nil {
t.Fatal(err)
}
}
func assertTreeFiles(t *testing.T, service *Service, commit string, expected map[string]string) {
t.Helper()
path := filepath.Join(t.TempDir(), "materialized")
if _, err := service.Repo.AddDetachedWorktree(context.Background(), path, commit); err != nil {
t.Fatal(err)
}
t.Cleanup(func() { _ = service.Repo.RemoveWorktree(context.Background(), path, true) })
for name, want := range expected {
contents, err := os.ReadFile(filepath.Join(path, name))
if err != nil {
t.Fatalf("read %s: %v", name, err)
}
if string(contents) != want {
t.Fatalf("%s = %q, want %q", name, string(contents), want)
}
}
}
func assertTreeMissing(t *testing.T, service *Service, commit string, name string) {
t.Helper()
path := filepath.Join(t.TempDir(), "materialized")
if _, err := service.Repo.AddDetachedWorktree(context.Background(), path, commit); err != nil {
t.Fatal(err)
}
t.Cleanup(func() { _ = service.Repo.RemoveWorktree(context.Background(), path, true) })
if _, err := os.Stat(filepath.Join(path, name)); !errors.Is(err, os.ErrNotExist) {
t.Fatalf("expected %s to be absent, stat error = %v", name, err)
}
}
func runGitTest(t *testing.T, root string, args ...string) string {
t.Helper()
cmd := exec.Command("git", args...)
cmd.Dir = root
var stdout, stderr bytes.Buffer
cmd.Stdout = &stdout
cmd.Stderr = &stderr
if err := cmd.Run(); err != nil {
t.Fatalf("git %s: %v\n%s", strings.Join(args, " "), err, stderr.String())
}
return strings.TrimSpace(stdout.String())
}
func runCLIJSONTest(t *testing.T, args []string) map[string]any {
t.Helper()
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())
}
var result map[string]any
if err := json.Unmarshal(stdout.Bytes(), &result); err != nil {
t.Fatalf("decode JSON from hop %s: %v\n%s", strings.Join(args, " "), err, stdout.String())
}
if ok, _ := result["ok"].(bool); !ok {
t.Fatalf("hop %s returned non-ok JSON: %#v", strings.Join(args, " "), result)
}
return result
}
func objectField(t *testing.T, object map[string]any, key string) map[string]any {
t.Helper()
value, ok := object[key].(map[string]any)
if !ok {
t.Fatalf("field %q is not an object: %#v", key, object[key])
}
return value
}
func stringField(t *testing.T, object map[string]any, key string) string {
t.Helper()
value, ok := object[key].(string)
if !ok {
t.Fatalf("field %q is not a string: %#v", key, object[key])
}
return value
}
+102
View File
@@ -0,0 +1,102 @@
package hop
import (
"fmt"
"io/fs"
"os"
"path/filepath"
"strings"
hopskill "github.com/hop-vcs/hop/skills/hop"
)
type SkillInstallResult struct {
Path string `json:"path"`
Files []string `json:"files"`
}
func DefaultSkillBase() (string, error) {
home := os.Getenv("CODEX_HOME")
if home == "" {
userHome, err := os.UserHomeDir()
if err != nil {
return "", fmt.Errorf("find home directory: %w", err)
}
home = filepath.Join(userHome, ".codex")
}
return filepath.Join(home, "skills"), nil
}
func EmbeddedSkillText() (string, error) {
contents, err := hopskill.Files.ReadFile("SKILL.md")
if err != nil {
return "", fmt.Errorf("read embedded Hop skill: %w", err)
}
return string(contents), nil
}
// InstallSkill writes the embedded skill below a skills directory. Existing
// bundles require force; known files are overwritten without deleting unknown
// user files from the destination.
func InstallSkill(base string, force bool) (SkillInstallResult, error) {
if strings.TrimSpace(base) == "" {
var err error
base, err = DefaultSkillBase()
if err != nil {
return SkillInstallResult{}, err
}
}
absBase, err := filepath.Abs(base)
if err != nil {
return SkillInstallResult{}, fmt.Errorf("resolve skills directory: %w", err)
}
target := filepath.Join(absBase, "hop")
if info, err := os.Lstat(target); err == nil {
if info.Mode()&os.ModeSymlink != 0 {
return SkillInstallResult{}, fmt.Errorf("refusing to install through symlink %s", target)
}
if !info.IsDir() {
return SkillInstallResult{}, fmt.Errorf("skill target exists and is not a directory: %s", target)
}
if !force {
return SkillInstallResult{}, fmt.Errorf("Hop skill already exists at %s; pass --force to update it", target)
}
} else if !os.IsNotExist(err) {
return SkillInstallResult{}, fmt.Errorf("inspect skill target: %w", err)
}
if err := os.MkdirAll(target, 0o755); err != nil {
return SkillInstallResult{}, fmt.Errorf("create skill target: %w", err)
}
result := SkillInstallResult{Path: target}
err = fs.WalkDir(hopskill.Files, ".", func(path string, entry fs.DirEntry, walkErr error) error {
if walkErr != nil {
return walkErr
}
if path == "." {
return nil
}
destination := filepath.Join(target, filepath.FromSlash(path))
if entry.IsDir() {
return os.MkdirAll(destination, 0o755)
}
if info, statErr := os.Lstat(destination); statErr == nil && info.Mode()&os.ModeSymlink != 0 {
return fmt.Errorf("refusing to overwrite symlink %s", destination)
} else if statErr != nil && !os.IsNotExist(statErr) {
return statErr
}
contents, readErr := hopskill.Files.ReadFile(path)
if readErr != nil {
return readErr
}
if writeErr := os.WriteFile(destination, contents, 0o644); writeErr != nil {
return writeErr
}
result.Files = append(result.Files, path)
return nil
})
if err != nil {
return SkillInstallResult{}, fmt.Errorf("install Hop skill: %w", err)
}
return result, nil
}
+63
View File
@@ -0,0 +1,63 @@
package hop
import (
"os"
"path/filepath"
"strings"
"testing"
)
func TestInstallSkillBundle(t *testing.T) {
base := t.TempDir()
result, err := InstallSkill(base, false)
if err != nil {
t.Fatal(err)
}
if result.Path != filepath.Join(base, "hop") {
t.Fatalf("skill path = %s", result.Path)
}
wantFiles := []string{"SKILL.md", "agents/openai.yaml", "references/protocol.md"}
for _, relative := range wantFiles {
contents, err := os.ReadFile(filepath.Join(result.Path, relative))
if err != nil {
t.Fatalf("read installed %s: %v", relative, err)
}
if len(contents) == 0 {
t.Fatalf("installed %s is empty", relative)
}
}
if _, err := InstallSkill(base, false); err == nil || !strings.Contains(err.Error(), "already exists") {
t.Fatalf("second install error = %v, want existing-skill error", err)
}
if err := os.WriteFile(filepath.Join(result.Path, "SKILL.md"), []byte("tampered"), 0o644); err != nil {
t.Fatal(err)
}
if _, err := InstallSkill(base, true); err != nil {
t.Fatal(err)
}
contents, err := os.ReadFile(filepath.Join(result.Path, "SKILL.md"))
if err != nil {
t.Fatal(err)
}
if string(contents) == "tampered" || !strings.Contains(string(contents), "name: hop") {
t.Fatal("forced skill update did not restore the embedded bundle")
}
}
func TestSkillCLIWorksOutsideHopProject(t *testing.T) {
var stdout, stderr strings.Builder
base := t.TempDir()
code := RunCLI([]string{"skill", "install", "--path", base, "--json"}, &stdout, &stderr)
if code != 0 {
t.Fatalf("skill install exited %d: %s", code, stderr.String())
}
if !strings.Contains(stdout.String(), filepath.Join(base, "hop")) {
t.Fatalf("skill install JSON omitted target: %s", stdout.String())
}
stdout.Reset()
stderr.Reset()
code = RunCLI([]string{"skill", "print"}, &stdout, &stderr)
if code != 0 || !strings.Contains(stdout.String(), "Require a durable Hop prompt state") {
t.Fatalf("skill print exited %d\nstdout: %s\nstderr: %s", code, stdout.String(), stderr.String())
}
}
+177
View File
@@ -0,0 +1,177 @@
package hop
import (
"bytes"
"context"
"crypto/sha256"
"encoding/hex"
"encoding/json"
"errors"
"fmt"
"io/fs"
"os"
"os/exec"
"path/filepath"
"sort"
"strings"
"sync"
"time"
)
const maxRecordedOutput = 128 * 1024
func FindHopRoot(start string) (string, error) {
if configured := os.Getenv("HOP_ROOT"); configured != "" {
return requireHopRoot(configured)
}
abs, err := filepath.Abs(start)
if err != nil {
return "", err
}
info, err := os.Stat(abs)
if err != nil {
return "", err
}
if !info.IsDir() {
abs = filepath.Dir(abs)
}
for dir := abs; ; dir = filepath.Dir(dir) {
if _, err := os.Stat(filepath.Join(dir, ".hop", "hop.db")); err == nil {
return dir, nil
} else if !errors.Is(err, fs.ErrNotExist) {
return "", err
}
parent := filepath.Dir(dir)
if parent == dir {
break
}
}
return "", fmt.Errorf("not inside a Hop project; run 'hop init' first")
}
func requireHopRoot(root string) (string, error) {
abs, err := filepath.Abs(root)
if err != nil {
return "", err
}
if _, err := os.Stat(filepath.Join(abs, ".hop", "hop.db")); err != nil {
return "", fmt.Errorf("HOP_ROOT does not contain .hop/hop.db: %s", abs)
}
return abs, nil
}
func digestState(state State, parents []Parent) (string, error) {
copyState := state
copyState.Digest = ""
copyState.Parents = append([]Parent(nil), parents...)
sort.Slice(copyState.Parents, func(i, j int) bool {
if copyState.Parents[i].Order != copyState.Parents[j].Order {
return copyState.Parents[i].Order < copyState.Parents[j].Order
}
if copyState.Parents[i].Role != copyState.Parents[j].Role {
return copyState.Parents[i].Role < copyState.Parents[j].Role
}
return copyState.Parents[i].StateID < copyState.Parents[j].StateID
})
payload, err := json.Marshal(copyState)
if err != nil {
return "", err
}
sum := sha256.Sum256(payload)
return "sha256:" + hex.EncodeToString(sum[:]), nil
}
type commandResult struct {
ExitCode int
Output string
}
func runWorkspaceCommand(ctx context.Context, workspace string, env []string, argv []string) (commandResult, error) {
if len(argv) == 0 {
return commandResult{}, fmt.Errorf("no command provided")
}
cmd := exec.CommandContext(ctx, argv[0], argv[1:]...)
cmd.Dir = workspace
cmd.Env = append(os.Environ(), env...)
var output bytes.Buffer
cmd.Stdout = &output
cmd.Stderr = &output
err := cmd.Run()
result := commandResult{Output: truncateRecordedOutput(output.String())}
if err == nil {
return result, nil
}
var exitErr *exec.ExitError
if errors.As(err, &exitErr) {
result.ExitCode = exitErr.ExitCode()
return result, nil
}
return result, err
}
func truncateRecordedOutput(output string) string {
if len(output) <= maxRecordedOutput {
return output
}
const marker = "\n… output truncated by Hop …\n"
keep := maxRecordedOutput - len(marker)
return output[:keep/2] + marker + output[len(output)-(keep-keep/2):]
}
func acquireProjectLock(ctx context.Context, root, name string) (func(), error) {
path := filepath.Join(root, ".hop", name+".lock")
for {
file, err := os.OpenFile(path, os.O_CREATE|os.O_EXCL|os.O_WRONLY, 0o600)
if err == nil {
_, _ = file.WriteString(fmt.Sprintf("pid=%d\n", os.Getpid()))
_ = file.Close()
stopHeartbeat := make(chan struct{})
go func() {
ticker := time.NewTicker(30 * time.Second)
defer ticker.Stop()
for {
select {
case <-ticker.C:
now := time.Now()
_ = os.Chtimes(path, now, now)
case <-stopHeartbeat:
return
}
}
}()
var once sync.Once
return func() {
once.Do(func() {
close(stopHeartbeat)
_ = os.Remove(path)
})
}, nil
}
if !errors.Is(err, fs.ErrExist) {
return nil, err
}
if info, statErr := os.Stat(path); statErr == nil && time.Since(info.ModTime()) > 5*time.Minute {
_ = os.Remove(path)
continue
}
select {
case <-ctx.Done():
return nil, fmt.Errorf("wait for Hop %s lock: %w", name, ctx.Err())
case <-time.After(50 * time.Millisecond):
}
}
}
func shellQuote(argv []string) string {
quoted := make([]string, len(argv))
for i, arg := range argv {
if arg != "" && !strings.ContainsAny(arg, " \t\n\"'\\$`;&|<>()[]{}*?!") {
quoted[i] = arg
continue
}
quoted[i] = "'" + strings.ReplaceAll(arg, "'", "'\"'\"'") + "'"
}
return strings.Join(quoted, " ")
}