Publish immutable portable prompt records without exposing Hop runtime files
Hop-State: A_06FN6TVSHKAXPT37Z25BN38 Hop-Proposal: R_06FN6TSP5HXV3S320BV6TN8 Hop-Task: T_06FN6RMF4VPW419BR2ZD9Q0 Hop-Attempt: AT_06FN6RMF4V18KAM05RFZ8ER
This commit is contained in:
@@ -8,6 +8,7 @@ import (
|
|||||||
"fmt"
|
"fmt"
|
||||||
"io"
|
"io"
|
||||||
"os"
|
"os"
|
||||||
|
"path/filepath"
|
||||||
"runtime/debug"
|
"runtime/debug"
|
||||||
"strings"
|
"strings"
|
||||||
)
|
)
|
||||||
@@ -24,6 +25,7 @@ Usage:
|
|||||||
hop accept STATE [-- COMMAND [ARG...]]
|
hop accept STATE [-- COMMAND [ARG...]]
|
||||||
hop land STATE [-- COMMAND [ARG...]]
|
hop land STATE [-- COMMAND [ARG...]]
|
||||||
hop refresh PROPOSAL
|
hop refresh PROPOSAL
|
||||||
|
hop export [--output PATH]
|
||||||
hop sync
|
hop sync
|
||||||
hop push
|
hop push
|
||||||
hop status
|
hop status
|
||||||
@@ -204,6 +206,29 @@ func RunCLIWithInput(args []string, stdin io.Reader, stdout, stderr io.Writer) i
|
|||||||
fmt.Fprintln(stderr, "usage: hop checkpoint STATE")
|
fmt.Fprintln(stderr, "usage: hop checkpoint STATE")
|
||||||
return 2
|
return 2
|
||||||
}
|
}
|
||||||
|
|
||||||
|
case "export":
|
||||||
|
fs := flag.NewFlagSet("export", flag.ContinueOnError)
|
||||||
|
fs.SetOutput(stderr)
|
||||||
|
output := fs.String("output", "", "write the portable prompt ledger beneath this directory")
|
||||||
|
if err := fs.Parse(commandArgs); err != nil || len(fs.Args()) != 0 {
|
||||||
|
if err == nil {
|
||||||
|
fmt.Fprintln(stderr, "usage: hop export [--output PATH]")
|
||||||
|
}
|
||||||
|
return 2
|
||||||
|
}
|
||||||
|
ledger, err := service.ExportPromptLedger(ctx, *output)
|
||||||
|
if err != nil {
|
||||||
|
return printCLIError(err, jsonOutput, stdout, stderr)
|
||||||
|
}
|
||||||
|
value = ledger
|
||||||
|
if !jsonOutput {
|
||||||
|
root := *output
|
||||||
|
if root == "" {
|
||||||
|
root = service.Root
|
||||||
|
}
|
||||||
|
fmt.Fprintf(stdout, "Exported %d prompt records to %s\n", len(ledger.Prompts), filepath.Join(root, ".hop", "records", "prompts"))
|
||||||
|
}
|
||||||
state, err := service.Checkpoint(ctx, commandArgs[0])
|
state, err := service.Checkpoint(ctx, commandArgs[0])
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return printCLIError(err, jsonOutput, stdout, stderr)
|
return printCLIError(err, jsonOutput, stdout, stderr)
|
||||||
|
|||||||
+21
-20
@@ -5,7 +5,6 @@ import (
|
|||||||
"context"
|
"context"
|
||||||
"errors"
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
"io"
|
|
||||||
"os"
|
"os"
|
||||||
"os/exec"
|
"os/exec"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
@@ -110,8 +109,8 @@ func EnsureRepository(path string) (*Repository, error) {
|
|||||||
return NewGitStore().Ensure(context.Background(), path)
|
return NewGitStore().Ensure(context.Background(), path)
|
||||||
}
|
}
|
||||||
|
|
||||||
// OpenRepository opens the repository containing path and adds .hop/ to its
|
// OpenRepository opens the repository containing path and keeps Hop's local
|
||||||
// per-repository exclude file.
|
// runtime private while leaving its portable record ledger versionable.
|
||||||
func OpenRepository(path string) (*Repository, error) {
|
func OpenRepository(path string) (*Repository, error) {
|
||||||
return NewGitStore().Open(context.Background(), path)
|
return NewGitStore().Open(context.Background(), path)
|
||||||
}
|
}
|
||||||
@@ -255,8 +254,9 @@ func (r *Repository) GitDir() string { return r.gitDir }
|
|||||||
// worktrees.
|
// worktrees.
|
||||||
func (r *Repository) CommonGitDir() string { return r.commonGitDir }
|
func (r *Repository) CommonGitDir() string { return r.commonGitDir }
|
||||||
|
|
||||||
// EnsureHopExcluded adds .hop/ to .git/info/exclude without changing tracked
|
// EnsureHopExcluded keeps the SQLite cache and disposable workspaces private
|
||||||
// files or the repository's public .gitignore.
|
// without excluding .hop/records. The records directory is intentionally
|
||||||
|
// versionable so a repository can carry Hop's portable causal history.
|
||||||
func (r *Repository) EnsureHopExcluded() error {
|
func (r *Repository) EnsureHopExcluded() error {
|
||||||
infoDir := filepath.Join(r.commonGitDir, "info")
|
infoDir := filepath.Join(r.commonGitDir, "info")
|
||||||
if err := os.MkdirAll(infoDir, 0o755); err != nil {
|
if err := os.MkdirAll(infoDir, 0o755); err != nil {
|
||||||
@@ -267,26 +267,27 @@ func (r *Repository) EnsureHopExcluded() error {
|
|||||||
if err != nil && !errors.Is(err, os.ErrNotExist) {
|
if err != nil && !errors.Is(err, os.ErrNotExist) {
|
||||||
return fmt.Errorf("read Git exclude file: %w", err)
|
return fmt.Errorf("read Git exclude file: %w", err)
|
||||||
}
|
}
|
||||||
|
lines := make([]string, 0)
|
||||||
for _, line := range strings.Split(string(contents), "\n") {
|
for _, line := range strings.Split(string(contents), "\n") {
|
||||||
if strings.TrimSuffix(line, "\r") == ".hop/" {
|
if strings.TrimSuffix(line, "\r") == ".hop/" {
|
||||||
|
continue // Migrate the legacy blanket exclusion.
|
||||||
|
}
|
||||||
|
lines = append(lines, line)
|
||||||
|
}
|
||||||
|
updated := strings.Join(lines, "\n")
|
||||||
|
if !strings.Contains(updated, ".hop/*\n") {
|
||||||
|
if updated != "" && !strings.HasSuffix(updated, "\n") {
|
||||||
|
updated += "\n"
|
||||||
|
}
|
||||||
|
updated += "# Hop local runtime; .hop/records is intentionally versioned.\n.hop/*\n!.hop/records/\n!.hop/records/**\n"
|
||||||
|
}
|
||||||
|
if updated == string(contents) {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
if err := os.WriteFile(excludePath, []byte(updated), 0o644); err != nil {
|
||||||
|
return fmt.Errorf("write Git exclude file: %w", err)
|
||||||
}
|
}
|
||||||
|
return nil
|
||||||
file, err := os.OpenFile(excludePath, os.O_CREATE|os.O_WRONLY|os.O_APPEND, 0o644)
|
|
||||||
if err != nil {
|
|
||||||
return fmt.Errorf("open Git exclude file: %w", err)
|
|
||||||
}
|
|
||||||
defer file.Close()
|
|
||||||
if len(contents) > 0 && contents[len(contents)-1] != '\n' {
|
|
||||||
if _, err := io.WriteString(file, "\n"); err != nil {
|
|
||||||
return fmt.Errorf("complete Git exclude line: %w", err)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if _, err := io.WriteString(file, ".hop/\n"); err != nil {
|
|
||||||
return fmt.Errorf("exclude .hop directory: %w", err)
|
|
||||||
}
|
|
||||||
return file.Sync()
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Head returns the current HEAD commit. exists is false for an unborn branch.
|
// Head returns the current HEAD commit. exists is false for an unborn branch.
|
||||||
|
|||||||
@@ -0,0 +1,149 @@
|
|||||||
|
package hop
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
// PortablePromptLedger is the versioned, repository-safe subset of Hop's
|
||||||
|
// local state. It deliberately excludes SQLite, workspace paths, check output,
|
||||||
|
// and every other machine-local runtime detail.
|
||||||
|
type PortablePromptLedger struct {
|
||||||
|
SchemaVersion int `json:"schema_version"`
|
||||||
|
GeneratedAt time.Time `json:"generated_at"`
|
||||||
|
Prompts []PortablePromptRecord `json:"prompts"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type PortablePromptRecord struct {
|
||||||
|
ID string `json:"id"`
|
||||||
|
TaskID string `json:"task_id,omitempty"`
|
||||||
|
AttemptID string `json:"attempt_id,omitempty"`
|
||||||
|
StateID string `json:"state_id"`
|
||||||
|
Prompt string `json:"prompt"`
|
||||||
|
AgentName string `json:"agent_name,omitempty"`
|
||||||
|
Status string `json:"status"`
|
||||||
|
ResponseSummary string `json:"response_summary,omitempty"`
|
||||||
|
CreatedAt time.Time `json:"created_at"`
|
||||||
|
CompletedAt *time.Time `json:"completed_at,omitempty"`
|
||||||
|
Metadata PortablePromptMetadata `json:"metadata"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type PortablePromptMetadata struct {
|
||||||
|
SourceTree string `json:"source_tree"`
|
||||||
|
GitCommit string `json:"git_commit"`
|
||||||
|
AttemptHead string `json:"attempt_head,omitempty"`
|
||||||
|
AttemptHeadKind string `json:"attempt_head_kind,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// ExportPromptLedger writes immutable prompt files beneath
|
||||||
|
// .hop/records/prompts. Passing attemptIDs restricts the export to those
|
||||||
|
// attempts, which keeps concurrent proposals from writing the same files.
|
||||||
|
func (s *Service) ExportPromptLedger(ctx context.Context, destinationRoot string, attemptIDs ...string) (PortablePromptLedger, error) {
|
||||||
|
if destinationRoot == "" {
|
||||||
|
destinationRoot = s.Root
|
||||||
|
}
|
||||||
|
graph, err := s.Store.Graph(ctx, "")
|
||||||
|
if err != nil {
|
||||||
|
return PortablePromptLedger{}, err
|
||||||
|
}
|
||||||
|
ledger := PortablePromptLedger{
|
||||||
|
SchemaVersion: 1,
|
||||||
|
GeneratedAt: time.Now().UTC(),
|
||||||
|
Prompts: make([]PortablePromptRecord, 0),
|
||||||
|
}
|
||||||
|
wantedAttempts := make(map[string]struct{}, len(attemptIDs))
|
||||||
|
for _, attemptID := range attemptIDs {
|
||||||
|
wantedAttempts[attemptID] = struct{}{}
|
||||||
|
}
|
||||||
|
for _, row := range graph {
|
||||||
|
state := row.State
|
||||||
|
if state.Kind != StatePrompt || state.Prompt == "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if len(wantedAttempts) > 0 {
|
||||||
|
if _, wanted := wantedAttempts[state.AttemptID]; !wanted {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
}
|
||||||
|
record := PortablePromptRecord{
|
||||||
|
ID: state.ID,
|
||||||
|
TaskID: state.TaskID,
|
||||||
|
AttemptID: state.AttemptID,
|
||||||
|
StateID: state.ID,
|
||||||
|
Prompt: state.Prompt,
|
||||||
|
AgentName: state.Agent,
|
||||||
|
Status: "unknown",
|
||||||
|
CreatedAt: state.CreatedAt,
|
||||||
|
Metadata: PortablePromptMetadata{
|
||||||
|
SourceTree: state.SourceTree,
|
||||||
|
GitCommit: state.GitCommit,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
if state.AttemptID != "" {
|
||||||
|
attempt, err := s.Store.GetAttempt(ctx, state.AttemptID)
|
||||||
|
if err != nil {
|
||||||
|
return PortablePromptLedger{}, fmt.Errorf("read attempt for prompt %s: %w", state.ID, err)
|
||||||
|
}
|
||||||
|
record.Status = attempt.Status
|
||||||
|
record.Metadata.AttemptHead = attempt.HeadStateID
|
||||||
|
if attempt.HeadStateID != "" {
|
||||||
|
head, err := s.Store.GetState(ctx, attempt.HeadStateID)
|
||||||
|
if err != nil {
|
||||||
|
return PortablePromptLedger{}, fmt.Errorf("read attempt head for prompt %s: %w", state.ID, err)
|
||||||
|
}
|
||||||
|
record.Metadata.AttemptHeadKind = string(head.Kind)
|
||||||
|
record.ResponseSummary = head.Summary
|
||||||
|
if head.Kind == StateAccepted || head.Kind == StateFailed || head.Kind == StateCancelled {
|
||||||
|
completedAt := head.CreatedAt
|
||||||
|
record.CompletedAt = &completedAt
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
ledger.Prompts = append(ledger.Prompts, record)
|
||||||
|
}
|
||||||
|
|
||||||
|
outputDir := filepath.Join(destinationRoot, ".hop", "records", "prompts")
|
||||||
|
if err := os.MkdirAll(outputDir, 0o755); err != nil {
|
||||||
|
return PortablePromptLedger{}, fmt.Errorf("create prompt ledger directory: %w", err)
|
||||||
|
}
|
||||||
|
for _, record := range ledger.Prompts {
|
||||||
|
outputPath := filepath.Join(outputDir, record.ID+".json")
|
||||||
|
if _, err := os.Stat(outputPath); err == nil {
|
||||||
|
continue // Prompt records are immutable once published.
|
||||||
|
} else if !os.IsNotExist(err) {
|
||||||
|
return PortablePromptLedger{}, fmt.Errorf("inspect prompt record %s: %w", record.ID, err)
|
||||||
|
}
|
||||||
|
contents, err := json.MarshalIndent(record, "", " ")
|
||||||
|
if err != nil {
|
||||||
|
return PortablePromptLedger{}, fmt.Errorf("encode prompt record %s: %w", record.ID, err)
|
||||||
|
}
|
||||||
|
temporary, err := os.CreateTemp(outputDir, ".prompt-*.tmp")
|
||||||
|
if err != nil {
|
||||||
|
return PortablePromptLedger{}, fmt.Errorf("create prompt record %s: %w", record.ID, err)
|
||||||
|
}
|
||||||
|
temporaryPath := temporary.Name()
|
||||||
|
if _, err := temporary.Write(append(contents, '\n')); err != nil {
|
||||||
|
_ = temporary.Close()
|
||||||
|
_ = os.Remove(temporaryPath)
|
||||||
|
return PortablePromptLedger{}, fmt.Errorf("write prompt record %s: %w", record.ID, err)
|
||||||
|
}
|
||||||
|
if err := temporary.Chmod(0o644); err != nil {
|
||||||
|
_ = temporary.Close()
|
||||||
|
_ = os.Remove(temporaryPath)
|
||||||
|
return PortablePromptLedger{}, fmt.Errorf("set prompt record permissions for %s: %w", record.ID, err)
|
||||||
|
}
|
||||||
|
if err := temporary.Close(); err != nil {
|
||||||
|
_ = os.Remove(temporaryPath)
|
||||||
|
return PortablePromptLedger{}, fmt.Errorf("close prompt record %s: %w", record.ID, err)
|
||||||
|
}
|
||||||
|
if err := os.Rename(temporaryPath, outputPath); err != nil {
|
||||||
|
_ = os.Remove(temporaryPath)
|
||||||
|
return PortablePromptLedger{}, fmt.Errorf("publish prompt record %s: %w", record.ID, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return ledger, nil
|
||||||
|
}
|
||||||
+33
-2
@@ -33,8 +33,10 @@ func InitProject(ctx context.Context, path string) (*Service, State, error) {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, State{}, err
|
return nil, State{}, err
|
||||||
}
|
}
|
||||||
if len(trackedHopPaths) > 0 {
|
for _, trackedPath := range trackedHopPaths {
|
||||||
return nil, State{}, fmt.Errorf("cannot initialize Hop: .hop is already tracked as project source (for example %s)", trackedHopPaths[0])
|
if !strings.HasPrefix(filepath.ToSlash(trackedPath), ".hop/records/") {
|
||||||
|
return nil, State{}, fmt.Errorf("cannot initialize Hop: local .hop runtime path is already tracked (for example %s)", trackedPath)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
hopDir := filepath.Join(root, ".hop")
|
hopDir := filepath.Join(root, ".hop")
|
||||||
if err := os.MkdirAll(filepath.Join(hopDir, "workspaces"), 0o755); err != nil {
|
if err := os.MkdirAll(filepath.Join(hopDir, "workspaces"), 0o755); err != nil {
|
||||||
@@ -480,6 +482,19 @@ func (s *Service) checkpointAttempt(ctx context.Context, attempt Attempt) (State
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (s *Service) RunCheck(ctx context.Context, stateID string, argv []string) (Check, error) {
|
func (s *Service) RunCheck(ctx context.Context, stateID string, argv []string) (Check, error) {
|
||||||
|
state, err := s.Store.GetState(ctx, stateID)
|
||||||
|
if err != nil {
|
||||||
|
return Check{}, err
|
||||||
|
}
|
||||||
|
if state.AttemptID != "" {
|
||||||
|
attempt, err := s.Store.GetAttempt(ctx, state.AttemptID)
|
||||||
|
if err != nil {
|
||||||
|
return Check{}, err
|
||||||
|
}
|
||||||
|
if _, err := s.ExportPromptLedger(ctx, attempt.Workspace, attempt.ID); err != nil {
|
||||||
|
return Check{}, err
|
||||||
|
}
|
||||||
|
}
|
||||||
checkpoint, err := s.Checkpoint(ctx, stateID)
|
checkpoint, err := s.Checkpoint(ctx, stateID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return Check{}, err
|
return Check{}, err
|
||||||
@@ -565,6 +580,9 @@ func (s *Service) Propose(ctx context.Context, stateID, summary string) (Proposa
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return ProposalResult{}, err
|
return ProposalResult{}, err
|
||||||
}
|
}
|
||||||
|
if _, err := s.ExportPromptLedger(ctx, attempt.Workspace, attempt.ID); err != nil {
|
||||||
|
return ProposalResult{}, err
|
||||||
|
}
|
||||||
commit, tree, err := workspaceRepo.Snapshot(ctx, "hop: proposal\n")
|
commit, tree, err := workspaceRepo.Snapshot(ctx, "hop: proposal\n")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return ProposalResult{}, err
|
return ProposalResult{}, err
|
||||||
@@ -726,6 +744,8 @@ func (s *Service) accept(ctx context.Context, proposalID string, checkCommand []
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return AcceptResult{}, err
|
return AcceptResult{}, err
|
||||||
}
|
}
|
||||||
|
proposalPaths = withoutPortableHopRecords(proposalPaths)
|
||||||
|
currentPaths = withoutPortableHopRecords(currentPaths)
|
||||||
finalTree := proposal.SourceTree
|
finalTree := proposal.SourceTree
|
||||||
if current.SourceTree != base.SourceTree {
|
if current.SourceTree != base.SourceTree {
|
||||||
var mergeConflicts []string
|
var mergeConflicts []string
|
||||||
@@ -875,6 +895,17 @@ func (s *Service) accept(ctx context.Context, proposalID string, checkCommand []
|
|||||||
return result, nil
|
return result, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func withoutPortableHopRecords(paths []string) []string {
|
||||||
|
filtered := make([]string, 0, len(paths))
|
||||||
|
for _, path := range paths {
|
||||||
|
if strings.HasPrefix(path, ".hop/records/") {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
filtered = append(filtered, path)
|
||||||
|
}
|
||||||
|
return filtered
|
||||||
|
}
|
||||||
|
|
||||||
func (s *Service) attachAutomaticPush(ctx context.Context, result *AcceptResult) {
|
func (s *Service) attachAutomaticPush(ctx context.Context, result *AcceptResult) {
|
||||||
pushCtx, cancel := context.WithTimeout(ctx, 30*time.Second)
|
pushCtx, cancel := context.WithTimeout(ctx, 30*time.Second)
|
||||||
defer cancel()
|
defer cancel()
|
||||||
|
|||||||
@@ -73,8 +73,8 @@ func TestInitRefusesAUserTrackedHopDirectory(t *testing.T) {
|
|||||||
writeTestFile(t, filepath.Join(root, ".hop", "user-owned.txt"), "do not overwrite\n")
|
writeTestFile(t, filepath.Join(root, ".hop", "user-owned.txt"), "do not overwrite\n")
|
||||||
runGitTest(t, root, "add", "-f", ".hop/user-owned.txt")
|
runGitTest(t, root, "add", "-f", ".hop/user-owned.txt")
|
||||||
_, _, err := InitProject(context.Background(), root)
|
_, _, err := InitProject(context.Background(), root)
|
||||||
if err == nil || !strings.Contains(err.Error(), ".hop is already tracked") {
|
if err == nil || !strings.Contains(err.Error(), "local .hop runtime path is already tracked") {
|
||||||
t.Fatalf("InitProject error = %v, want tracked .hop refusal", err)
|
t.Fatalf("InitProject error = %v, want tracked local-runtime refusal", err)
|
||||||
}
|
}
|
||||||
contents, readErr := os.ReadFile(filepath.Join(root, ".hop", "user-owned.txt"))
|
contents, readErr := os.ReadFile(filepath.Join(root, ".hop", "user-owned.txt"))
|
||||||
if readErr != nil || string(contents) != "do not overwrite\n" {
|
if readErr != nil || string(contents) != "do not overwrite\n" {
|
||||||
@@ -82,6 +82,27 @@ func TestInitRefusesAUserTrackedHopDirectory(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestInitAllowsTrackedPortableHopRecords(t *testing.T) {
|
||||||
|
root := t.TempDir()
|
||||||
|
runGitTest(t, root, "init", "--quiet")
|
||||||
|
writeTestFile(t, filepath.Join(root, ".hop", "records", "prompts.json"), `{"schema_version":1,"prompts":[]}`+"\n")
|
||||||
|
runGitTest(t, root, "add", "-f", ".hop/records/prompts.json")
|
||||||
|
|
||||||
|
service, _, err := InitProject(context.Background(), root)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("InitProject error = %v", err)
|
||||||
|
}
|
||||||
|
t.Cleanup(func() { _ = service.Close() })
|
||||||
|
|
||||||
|
exclude, err := os.ReadFile(filepath.Join(root, ".git", "info", "exclude"))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if strings.Contains(string(exclude), "\n.hop/\n") || !strings.Contains(string(exclude), "!.hop/records/**") {
|
||||||
|
t.Fatalf("portable ledger is not allowed by exclude file:\n%s", exclude)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestConcurrentFirstBeginsInitializeExactlyOnce(t *testing.T) {
|
func TestConcurrentFirstBeginsInitializeExactlyOnce(t *testing.T) {
|
||||||
t.Setenv("HOP_ROOT", "")
|
t.Setenv("HOP_ROOT", "")
|
||||||
root := t.TempDir()
|
root := t.TempDir()
|
||||||
@@ -207,8 +228,39 @@ func TestConcurrentInitInExistingGitRepository(t *testing.T) {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
if count := strings.Count(string(exclude), ".hop/\n"); count != 1 {
|
if count := strings.Count(string(exclude), ".hop/*\n"); count != 1 {
|
||||||
t.Fatalf("concurrent initialization wrote .hop/ exclusion %d times", count)
|
t.Fatalf("concurrent initialization wrote .hop/* exclusion %d times", count)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestProposeExportsPortablePromptLedger(t *testing.T) {
|
||||||
|
ctx := context.Background()
|
||||||
|
service, _ := newTestProject(t, map[string]string{"base.txt": "base\n"})
|
||||||
|
result, err := service.CreatePrompt(ctx, "Publish a portable prompt ledger", "", "codex")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
writeTestFile(t, filepath.Join(result.Workspace, "feature.txt"), "done\n")
|
||||||
|
proposal, err := service.Propose(ctx, result.Prompt.ID, "Publish the ledger")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
materialized := filepath.Join(t.TempDir(), "proposal")
|
||||||
|
if _, err := service.Repo.AddDetachedWorktree(ctx, materialized, proposal.Proposal.GitCommit); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
t.Cleanup(func() { _ = service.Repo.RemoveWorktree(ctx, materialized, true) })
|
||||||
|
contents, err := os.ReadFile(filepath.Join(materialized, ".hop", "records", "prompts", result.Prompt.ID+".json"))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
var record PortablePromptRecord
|
||||||
|
if err := json.Unmarshal(contents, &record); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if record.ID != result.Prompt.ID || record.Prompt != "Publish a portable prompt ledger" {
|
||||||
|
t.Fatalf("unexpected portable prompt record: %#v", record)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -34,6 +34,7 @@ release.
|
|||||||
|---|---|
|
|---|---|
|
||||||
| `hop accept PROPOSAL [-- COMMAND...]` | Accept internally without changing visible files |
|
| `hop accept PROPOSAL [-- COMMAND...]` | Accept internally without changing visible files |
|
||||||
| `hop sync` | Materialize the current accepted tree from a safe accepted ancestor |
|
| `hop sync` | Materialize the current accepted tree from a safe accepted ancestor |
|
||||||
|
| `hop export [--output PATH]` | Write immutable, repository-safe prompt records to `.hop/records/prompts/` |
|
||||||
| `hop push` | Retry publishing the current accepted commit to its inferred upstream |
|
| `hop push` | Retry publishing the current accepted commit to its inferred upstream |
|
||||||
| `hop undo` | Create a forward-only acceptance that restores the previous accepted tree |
|
| `hop undo` | Create a forward-only acceptance that restores the previous accepted tree |
|
||||||
| `hop doctor [--repair]` | Validate database/object/ref consistency |
|
| `hop doctor [--repair]` | Validate database/object/ref consistency |
|
||||||
|
|||||||
Reference in New Issue
Block a user