Reconcile compatible remote advancement before publishing accepted states

Hop-State: A_06FN8BW3A4C9EV7XMN6Q0FR
Hop-Proposal: R_06FN8BSNQNQNKB9D7951MQR
Hop-Task: T_06FN8AWMR0HY1RJY8AN2A3R
Hop-Attempt: AT_06FN8AWMR0WHBQ7ZMDZ0NWG
This commit is contained in:
cyph3rasi
2026-07-11 19:37:07 -07:00
committed by Hop
7 changed files with 275 additions and 15 deletions
+112 -7
View File
@@ -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()
+9
View File
@@ -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)
+39 -1
View File
@@ -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
}
+64 -7
View File
@@ -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 <prompter@example.com>\nHop <hop@localhost>"
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)
}
}