diff --git a/.hop/records/prompts/P_06FN83DN7R0B9X5TEV3YDC0.json b/.hop/records/prompts/P_06FN83DN7R0B9X5TEV3YDC0.json new file mode 100644 index 0000000..84dee3f --- /dev/null +++ b/.hop/records/prompts/P_06FN83DN7R0B9X5TEV3YDC0.json @@ -0,0 +1,17 @@ +{ + "id": "P_06FN83DN7R0B9X5TEV3YDC0", + "task_id": "T_06FN83DN7SMZB9WJRZNZMTR", + "attempt_id": "AT_06FN83DN7VHVF5BVW6DEJ8R", + "state_id": "P_06FN83DN7R0B9X5TEV3YDC0", + "prompt": "# Files mentioned by the user:\n\n## Screenshot 2026-07-11 at 6.58.29 PM.png: /var/folders/58/b6yn15tx3jb7_6lw9m67f89m0000gn/T/TemporaryItems/NSIRD_screencaptureui_vyA0oe/Screenshot 2026-07-11 at 6.58.29 PM.png\n\n## My request for Codex:\nonce again, there are prompts here that arent mine!", + "agent_name": "codex", + "status": "proposed", + "response_summary": "Purge internal reconciliation prompts from portable records", + "created_at": "2026-07-12T02:00:12.350322Z", + "metadata": { + "source_tree": "27e94b6d19eb7e4107b127e3cec0ca41718053dc", + "git_commit": "74ba06de917236754f6146aca0e2d6e10438e455", + "attempt_head": "C_06FN83MVSDWQH772HR663SR", + "attempt_head_kind": "checkpoint" + } +} diff --git a/.hop/records/prompts/P_06FN892CVDG36AN981MGF00.json b/.hop/records/prompts/P_06FN892CVDG36AN981MGF00.json new file mode 100644 index 0000000..a65fde0 --- /dev/null +++ b/.hop/records/prompts/P_06FN892CVDG36AN981MGF00.json @@ -0,0 +1,17 @@ +{ + "id": "P_06FN892CVDG36AN981MGF00", + "task_id": "T_06FN892CVE210TZR2ZQQ9JG", + "attempt_id": "AT_06FN892CVDGHFG3F889Q3YR", + "state_id": "P_06FN892CVDG36AN981MGF00", + "prompt": "implement that", + "agent_name": "codex", + "status": "proposed", + "response_summary": "Attribute accepted commits to the prompting user's configured Git identity", + "created_at": "2026-07-12T02:24:52.955741Z", + "metadata": { + "source_tree": "5895f21f33fd3d6d04b764c84dfeb6e0c4738c1f", + "git_commit": "787acb2330d8662320477d022da37ee4029697ac", + "attempt_head": "C_06FN89NDDXYFZK3XRRBQPVR", + "attempt_head_kind": "checkpoint" + } +} diff --git a/.hop/records/prompts/P_06FN8AWMR1EMWPB5787YS60.json b/.hop/records/prompts/P_06FN8AWMR1EMWPB5787YS60.json new file mode 100644 index 0000000..79b5904 --- /dev/null +++ b/.hop/records/prompts/P_06FN8AWMR1EMWPB5787YS60.json @@ -0,0 +1,17 @@ +{ + "id": "P_06FN8AWMR1EMWPB5787YS60", + "task_id": "T_06FN8AWMR0HY1RJY8AN2A3R", + "attempt_id": "AT_06FN8AWMR0WHBQ7ZMDZ0NWG", + "state_id": "P_06FN8AWMR1EMWPB5787YS60", + "prompt": "HOP IS SUPPOSED TO INTELLIGENTLY RECONCILE THIS!", + "agent_name": "codex", + "status": "proposed", + "response_summary": "Reconcile compatible remote advancement before publishing accepted states", + "created_at": "2026-07-12T02:32:50.112254Z", + "metadata": { + "source_tree": "b24e0fc4185ee2df0b827cbcddf7712e8f14ebff", + "git_commit": "59b8ca7c05edbdd50535a61ae9ca732516ce7f31", + "attempt_head": "C_06FN8BEYCESTJ4ZDR0XRNK0", + "attempt_head_kind": "checkpoint" + } +} diff --git a/internal/hop/git.go b/internal/hop/git.go index d8e059a..a7ffeac 100644 --- a/internal/hop/git.go +++ b/internal/hop/git.go @@ -67,7 +67,7 @@ func (e *GitError) Error() string { func (e *GitError) Unwrap() error { return e.Err } // CommitOptions controls a synthetic commit. Empty identities and timestamps -// use the GitStore defaults; user Git identity configuration is never used. +// use the GitStore defaults. type CommitOptions struct { Message string Parents []string @@ -309,6 +309,25 @@ func (r *Repository) PushAccepted(ctx context.Context, commit string) (result Re if err := validObjectName(commit); err != nil { return result, false, fmt.Errorf("invalid accepted commit: %w", err) } + destination, configured, err := r.pushDestination(ctx) + if err != nil || !configured { + return result, configured, err + } + if _, err := r.run(ctx, []string{"GIT_TERMINAL_PROMPT=0"}, nil, + "push", "--porcelain", destination.remote, commit+":"+destination.ref); err != nil { + return result, true, fmt.Errorf("push accepted commit to %s/%s: %w", destination.remote, strings.TrimPrefix(destination.ref, "refs/heads/"), err) + } + return RemotePushResult{Remote: destination.safeRemote, Ref: destination.ref, Commit: commit}, true, nil +} + +type pushDestination struct { + remote string + safeRemote string + ref string +} + +func (r *Repository) pushDestination(ctx context.Context) (pushDestination, bool, error) { + var result pushDestination branch, exists, err := r.optionalGitOutput(ctx, "symbolic-ref", "--quiet", "--short", "HEAD") if err != nil { return result, false, fmt.Errorf("discover automatic push branch: %w", err) @@ -375,13 +394,60 @@ func (r *Repository) PushAccepted(ctx context.Context, commit string) (result Re if err := r.validateRef(ctx, ref); err != nil { return result, false, fmt.Errorf("validate automatic push destination: %w", err) } - - if _, err := r.run(ctx, []string{"GIT_TERMINAL_PROMPT=0"}, nil, - "push", "--porcelain", remote, commit+":"+ref); err != nil { - return result, true, fmt.Errorf("push accepted commit to %s/%s: %w", remote, strings.TrimPrefix(ref, "refs/heads/"), err) - } safeRemote, _ := RedactPromptSecrets(remote) - return RemotePushResult{Remote: safeRemote, Ref: ref, Commit: commit}, true, nil + return pushDestination{remote: remote, safeRemote: safeRemote, ref: ref}, true, nil +} + +// FetchPushTip fetches the configured destination branch without changing the +// user's branch, index, or working tree. exists is false for a new remote branch. +func (r *Repository) FetchPushTip(ctx context.Context) (tip string, configured, exists bool, err error) { + destination, configured, err := r.pushDestination(ctx) + if err != nil || !configured { + return "", configured, false, err + } + output, err := r.run(ctx, []string{"GIT_TERMINAL_PROMPT=0"}, nil, + "ls-remote", "--refs", destination.remote, destination.ref) + if err != nil { + return "", true, false, fmt.Errorf("inspect remote branch %s/%s: %w", destination.remote, strings.TrimPrefix(destination.ref, "refs/heads/"), err) + } + fields := strings.Fields(output) + if len(fields) == 0 { + return "", true, false, nil + } + if len(fields) != 2 || fields[1] != destination.ref { + return "", true, false, fmt.Errorf("inspect remote branch %s/%s: unexpected ls-remote response", destination.remote, strings.TrimPrefix(destination.ref, "refs/heads/")) + } + if err := validObjectName(fields[0]); err != nil { + return "", true, false, fmt.Errorf("invalid remote branch tip: %w", err) + } + if _, err := r.run(ctx, []string{"GIT_TERMINAL_PROMPT=0"}, nil, + "fetch", "--no-tags", destination.remote, destination.ref); err != nil { + return "", true, false, fmt.Errorf("fetch remote branch %s/%s: %w", destination.remote, strings.TrimPrefix(destination.ref, "refs/heads/"), err) + } + fetched, err := r.run(ctx, nil, nil, "rev-parse", "--verify", "FETCH_HEAD^{commit}") + if err != nil { + return "", true, false, fmt.Errorf("resolve fetched remote branch: %w", err) + } + fetched = trimLine(fetched) + if fetched != fields[0] { + return "", true, false, errors.New("fetched remote branch changed while reconciling; retry") + } + return fetched, true, true, nil +} + +// MergeBase returns the best common ancestor of two commits. +func (r *Repository) MergeBase(ctx context.Context, left, right string) (string, error) { + if err := validObjectName(left); err != nil { + return "", fmt.Errorf("invalid left commit: %w", err) + } + if err := validObjectName(right); err != nil { + return "", fmt.Errorf("invalid right commit: %w", err) + } + output, err := r.run(ctx, nil, nil, "merge-base", left, right) + if err != nil { + return "", fmt.Errorf("find merge base: %w", err) + } + return trimLine(output), nil } func (r *Repository) optionalGitOutput(ctx context.Context, args ...string) (string, bool, error) { @@ -478,6 +544,32 @@ func (r *Repository) CommitTree(ctx context.Context, tree string, parents []stri }) } +// ConfiguredUserIdentity returns the Git identity configured for the user in +// this repository. Both user.name and user.email must be present. +func (r *Repository) ConfiguredUserIdentity(ctx context.Context) (GitIdentity, bool, error) { + name, nameSet, err := r.configValue(ctx, "user.name") + if err != nil { + return GitIdentity{}, false, err + } + email, emailSet, err := r.configValue(ctx, "user.email") + if err != nil { + return GitIdentity{}, false, err + } + if !nameSet || !emailSet { + return GitIdentity{}, false, nil + } + identity := GitIdentity{Name: name, Email: email} + if err := validateIdentity(identity); err != nil { + return GitIdentity{}, false, fmt.Errorf("invalid configured user identity: %w", err) + } + return identity, true, nil +} + +// SyntheticIdentity is Hop's controlled committer identity. +func (r *Repository) SyntheticIdentity() GitIdentity { + return r.store.identity(GitIdentity{}) +} + // CommitTreeWithOptions creates a synthetic commit without invoking hooks or // consulting the user's Git identity. It does not update any ref. func (r *Repository) CommitTreeWithOptions(ctx context.Context, tree string, options CommitOptions) (string, error) { @@ -1303,6 +1395,19 @@ func (s *GitStore) identity(override GitIdentity) GitIdentity { return identity } +func (r *Repository) configValue(ctx context.Context, key string) (string, bool, error) { + output, err := r.run(ctx, nil, nil, "config", "--get", key) + if err != nil { + var gitErr *GitError + if errors.As(err, &gitErr) && gitErr.ExitCode == 1 { + return "", false, nil + } + return "", false, fmt.Errorf("read git config %s: %w", key, err) + } + value := trimLine(output) + return value, value != "", nil +} + func (s *GitStore) now() time.Time { if s.Now != nil { return s.Now() diff --git a/internal/hop/ledger.go b/internal/hop/ledger.go index 7f08b3f..e28cd5d 100644 --- a/internal/hop/ledger.go +++ b/internal/hop/ledger.go @@ -81,6 +81,7 @@ func (s *Service) exportPromptLedger(ctx context.Context, destinationRoot string wantedAttempts[attemptID] = struct{}{} } lastPromptByAttempt := make(map[string]string) + excludedPromptIDs := make(map[string]struct{}) for _, row := range graph { if row.State.Kind == StatePrompt && row.State.Prompt != "" && row.State.AttemptID != "" { lastPromptByAttempt[row.State.AttemptID] = row.State.ID @@ -92,9 +93,11 @@ func (s *Service) exportPromptLedger(ctx context.Context, destinationRoot string continue } if _, hidden := suppressed[state.ID]; hidden { + excludedPromptIDs[state.ID] = struct{}{} continue } if _, reconciliation := decodeReconciliationConflicts(state.Summary); reconciliation || strings.HasPrefix(state.Prompt, "Resolve proposal ") { + excludedPromptIDs[state.ID] = struct{}{} continue } if len(wantedAttempts) > 0 { @@ -156,6 +159,12 @@ func (s *Service) exportPromptLedger(ctx context.Context, destinationRoot string if err := os.MkdirAll(outputDir, 0o755); err != nil { return PortablePromptLedger{}, fmt.Errorf("create prompt ledger directory: %w", err) } + for promptID := range excludedPromptIDs { + outputPath := filepath.Join(outputDir, promptID+".json") + if err := os.Remove(outputPath); err != nil && !os.IsNotExist(err) { + return PortablePromptLedger{}, fmt.Errorf("remove excluded prompt record %s: %w", promptID, err) + } + } for _, record := range ledger.Prompts { outputPath := filepath.Join(outputDir, record.ID+".json") _, statErr := os.Stat(outputPath) diff --git a/internal/hop/service.go b/internal/hop/service.go index 4ddffb6..dce3237 100644 --- a/internal/hop/service.go +++ b/internal/hop/service.go @@ -750,6 +750,32 @@ func (s *Service) accept(ctx context.Context, proposalID string, checkCommand [] return AcceptResult{}, &ConflictError{Paths: mergeConflicts} } } + commitParents := []string{current.GitCommit} + remoteCtx, cancelRemote := context.WithTimeout(ctx, 30*time.Second) + remoteTip, _, remoteExists, err := s.Repo.FetchPushTip(remoteCtx) + cancelRemote() + if err != nil { + return AcceptResult{}, err + } + if remoteExists && remoteTip != current.GitCommit { + candidate, candidateErr := s.Repo.CommitTree(ctx, finalTree, []string{current.GitCommit}, "Hop remote reconciliation candidate\n") + if candidateErr != nil { + return AcceptResult{}, candidateErr + } + mergeBase, mergeErr := s.Repo.MergeBase(ctx, candidate, remoteTip) + if mergeErr != nil { + return AcceptResult{}, mergeErr + } + var remoteConflicts []string + finalTree, remoteConflicts, err = s.Repo.ComposeTrees(ctx, mergeBase, remoteTip, candidate) + if err != nil { + return AcceptResult{}, err + } + if len(remoteConflicts) > 0 { + return AcceptResult{}, fmt.Errorf("remote accepted-state reconciliation requires agent resolution: %s", strings.Join(remoteConflicts, ", ")) + } + commitParents = []string{remoteTip, current.GitCommit} + } acceptedID := newID("a") message := proposal.Summary @@ -757,7 +783,19 @@ func (s *Service) accept(ctx context.Context, proposalID string, checkCommand [] message = "Accept " + proposal.ID } message += fmt.Sprintf("\n\nHop-State: %s\nHop-Proposal: %s\nHop-Task: %s\nHop-Attempt: %s\n", acceptedID, proposal.ID, proposal.TaskID, proposal.AttemptID) - commit, err := s.Repo.CommitTree(ctx, finalTree, []string{current.GitCommit}, message) + author, configured, err := s.Repo.ConfiguredUserIdentity(ctx) + if err != nil { + return AcceptResult{}, err + } + if !configured { + author = s.Repo.SyntheticIdentity() + } + commit, err := s.Repo.CommitTreeWithOptions(ctx, finalTree, CommitOptions{ + Message: message, + Parents: commitParents, + Author: author, + Committer: s.Repo.SyntheticIdentity(), + }) if err != nil { return AcceptResult{}, err } diff --git a/internal/hop/service_smoke_test.go b/internal/hop/service_smoke_test.go index 240b98e..c77448a 100644 --- a/internal/hop/service_smoke_test.go +++ b/internal/hop/service_smoke_test.go @@ -279,6 +279,29 @@ func TestExportOmitsActiveInternalAttempt(t *testing.T) { } } +func TestExportRemovesPreviouslyPublishedReconciliationPrompt(t *testing.T) { + ctx := context.Background() + service, _ := newTestProject(t, map[string]string{"base.txt": "base\n"}) + result, err := service.CreatePrompt(ctx, "Resolve proposal R_TEST against accepted state A_TEST", "", "codex") + if err != nil { + t.Fatal(err) + } + recordDir := filepath.Join(service.Root, ".hop", "records", "prompts") + if err := os.MkdirAll(recordDir, 0o755); err != nil { + t.Fatal(err) + } + recordPath := filepath.Join(recordDir, result.Prompt.ID+".json") + if err := os.WriteFile(recordPath, []byte("{}\n"), 0o644); err != nil { + t.Fatal(err) + } + if _, err := service.ExportPromptLedger(ctx, service.Root); err != nil { + t.Fatal(err) + } + if _, err := os.Stat(recordPath); !errors.Is(err, os.ErrNotExist) { + t.Fatalf("internal reconciliation record still exists: %v", err) + } +} + func TestProposeRespectsSuppressedPromptManifest(t *testing.T) { ctx := context.Background() service, _ := newTestProject(t, map[string]string{"base.txt": "base\n"}) @@ -1036,6 +1059,33 @@ func TestDisjointProposalsLandAndUndoMovesForward(t *testing.T) { } } +func TestAcceptedCommitAttributesPromptingUserAndRetainsHopCommitter(t *testing.T) { + ctx := context.Background() + service, _ := newTestProject(t, map[string]string{"base.txt": "base\n"}) + runGitTest(t, service.Root, "config", "user.name", "Prompting User") + runGitTest(t, service.Root, "config", "user.email", "prompter@example.com") + + attempt, err := service.CreatePrompt(ctx, "Add authored change", "", "codex") + if err != nil { + t.Fatal(err) + } + writeTestFile(t, filepath.Join(attempt.Workspace, "authored.txt"), "authored\n") + proposal, err := service.Propose(ctx, attempt.Prompt.ID, "Add authored change") + if err != nil { + t.Fatal(err) + } + accepted, err := service.Accept(ctx, proposal.Proposal.ID, nil) + if err != nil { + t.Fatal(err) + } + + metadata := runGitTest(t, service.Root, "show", "-s", "--format=%an <%ae>%n%cn <%ce>", accepted.State.GitCommit) + want := "Prompting User \nHop " + if metadata != want { + t.Fatalf("accepted commit identity = %q, want %q", metadata, want) + } +} + func TestConcurrentDisjointAcceptancesSerialize(t *testing.T) { ctx := context.Background() service, _ := newTestProject(t, map[string]string{"base.txt": "base\n"}) @@ -1601,7 +1651,7 @@ func TestLandAutomaticallyPushesAcceptedCommit(t *testing.T) { } } -func TestAutomaticPushNeverForcesDivergedRemote(t *testing.T) { +func TestAutomaticPushReconcilesCompatibleDivergedRemote(t *testing.T) { ctx := context.Background() service, _ := newTestProject(t, map[string]string{"base.txt": "base\n"}) remote := filepath.Join(t.TempDir(), "remote.git") @@ -1647,14 +1697,21 @@ func TestAutomaticPushNeverForcesDivergedRemote(t *testing.T) { if err != nil { t.Fatal(err) } - if secondLanded.RemotePush != nil { - t.Fatalf("diverged remote unexpectedly reported a successful push: %#v", secondLanded.RemotePush) + if secondLanded.RemotePush == nil { + t.Fatal("compatible remote advancement was not reconciled and pushed") } - if len(secondLanded.Warnings) == 0 || !strings.Contains(strings.Join(secondLanded.Warnings, "\n"), "automatic push failed") { - t.Fatalf("diverged remote warnings = %#v", secondLanded.Warnings) + if got := runGitTest(t, service.Root, "--git-dir", remote, "rev-parse", "refs/heads/"+branch); got != secondLanded.State.GitCommit { + t.Fatalf("remote branch = %s, want reconciled accepted commit %s", got, secondLanded.State.GitCommit) } - if got := runGitTest(t, service.Root, "--git-dir", remote, "rev-parse", "refs/heads/"+branch); got != remoteTip { - t.Fatalf("automatic push rewrote diverged remote from %s to %s", remoteTip, got) + assertTreeFiles(t, service, secondLanded.State.GitCommit, map[string]string{ + "base.txt": "base\n", + "first.txt": "first\n", + "local.txt": "local\n", + "remote.txt": "remote\n", + }) + parents := strings.Fields(runGitTest(t, service.Root, "show", "-s", "--format=%P", secondLanded.State.GitCommit)) + if len(parents) != 2 || parents[0] != remoteTip || parents[1] != firstLanded.State.GitCommit { + t.Fatalf("reconciled parents = %v, want [%s %s]", parents, remoteTip, firstLanded.State.GitCommit) } }