Automatically push every accepted Hop transition
Hop-State: A_06FN6FF8HFSFD9ANGK6BY4G Hop-Proposal: R_06FN6FDTK9G17RXFFT82ZYR Hop-Task: T_06FN6CFVXVVAP1KZAJ2X62R Hop-Attempt: AT_06FN6CFVXTRZ8ZDG28QR5Z0
This commit is contained in:
@@ -25,6 +25,7 @@ Usage:
|
||||
hop land STATE [-- COMMAND [ARG...]]
|
||||
hop refresh PROPOSAL
|
||||
hop sync
|
||||
hop push
|
||||
hop status
|
||||
hop graph
|
||||
hop state STATE
|
||||
@@ -264,6 +265,7 @@ func RunCLIWithInput(args []string, stdin io.Reader, stdout, stderr io.Writer) i
|
||||
}
|
||||
if !jsonOutput {
|
||||
fmt.Fprintf(stdout, "Accepted internally as %s · tree %s · visible root unchanged\n", result.State.ID, shortHash(result.State.SourceTree))
|
||||
printRemotePush(stdout, result.RemotePush)
|
||||
if result.Check == nil {
|
||||
fmt.Fprintln(stdout, "No final-state validation command was supplied.")
|
||||
}
|
||||
@@ -308,6 +310,7 @@ func RunCLIWithInput(args []string, stdin io.Reader, stdout, stderr io.Writer) i
|
||||
if !jsonOutput {
|
||||
fmt.Fprintf(stdout, "Landed as %s · tree %s\n", result.State.ID, shortHash(result.State.SourceTree))
|
||||
fmt.Fprintf(stdout, "Synchronized visible root: %s\n", result.MaterializedRoot)
|
||||
printRemotePush(stdout, result.RemotePush)
|
||||
if result.Check == nil {
|
||||
fmt.Fprintln(stdout, "No final-state validation command was supplied.")
|
||||
}
|
||||
@@ -348,6 +351,20 @@ func RunCLIWithInput(args []string, stdin io.Reader, stdout, stderr io.Writer) i
|
||||
}
|
||||
}
|
||||
|
||||
case "push":
|
||||
if len(commandArgs) != 0 {
|
||||
fmt.Fprintln(stderr, "usage: hop push")
|
||||
return 2
|
||||
}
|
||||
result, err := service.Push(ctx)
|
||||
value = result
|
||||
if err != nil {
|
||||
return printCLIError(err, jsonOutput, stdout, stderr)
|
||||
}
|
||||
if !jsonOutput {
|
||||
printRemotePush(stdout, &result)
|
||||
}
|
||||
|
||||
case "status":
|
||||
status, err := service.Status(ctx)
|
||||
if err != nil {
|
||||
@@ -620,6 +637,13 @@ func printRefreshSummary(w io.Writer, result RefreshResult) {
|
||||
fmt.Fprintf(w, "Continue automatically with: hop check %s -- <test-command>, then propose and land again.\n", result.Prompt.ID)
|
||||
}
|
||||
|
||||
func printRemotePush(w io.Writer, result *RemotePushResult) {
|
||||
if result == nil {
|
||||
return
|
||||
}
|
||||
fmt.Fprintf(w, "Pushed accepted commit to %s/%s\n", result.Remote, strings.TrimPrefix(result.Ref, "refs/heads/"))
|
||||
}
|
||||
|
||||
func removeFlag(args []string, wanted string) (bool, []string) {
|
||||
found := false
|
||||
filtered := make([]string, 0, len(args))
|
||||
|
||||
@@ -301,6 +301,118 @@ func (r *Repository) Head(ctx context.Context) (oid string, exists bool, err err
|
||||
return trimLine(output), true, nil
|
||||
}
|
||||
|
||||
// PushAccepted publishes one accepted Hop commit to the repository's existing
|
||||
// branch destination. It never force-pushes and returns configured=false when
|
||||
// the repository has no unambiguous remote branch target.
|
||||
func (r *Repository) PushAccepted(ctx context.Context, commit string) (result RemotePushResult, configured bool, err error) {
|
||||
if err := validObjectName(commit); err != nil {
|
||||
return result, false, fmt.Errorf("invalid accepted commit: %w", err)
|
||||
}
|
||||
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)
|
||||
}
|
||||
if !exists || branch == "" {
|
||||
return result, false, nil
|
||||
}
|
||||
if _, err := r.run(ctx, nil, nil, "check-ref-format", "--branch", branch); err != nil {
|
||||
return result, false, fmt.Errorf("validate automatic push branch: %w", err)
|
||||
}
|
||||
|
||||
remoteNamesOutput, err := r.run(ctx, nil, nil, "remote")
|
||||
if err != nil {
|
||||
return result, false, fmt.Errorf("list Git remotes: %w", err)
|
||||
}
|
||||
remoteNames := nonemptyLines(remoteNamesOutput)
|
||||
if len(remoteNames) == 0 {
|
||||
return result, false, nil
|
||||
}
|
||||
|
||||
upstreamRemote, hasUpstreamRemote, err := r.optionalGitOutput(ctx, "config", "--get", "branch."+branch+".remote")
|
||||
if err != nil {
|
||||
return result, false, fmt.Errorf("read Git upstream remote for %s: %w", branch, err)
|
||||
}
|
||||
remote := ""
|
||||
for _, key := range []string{
|
||||
"branch." + branch + ".pushRemote",
|
||||
"remote.pushDefault",
|
||||
} {
|
||||
value, found, configErr := r.optionalGitOutput(ctx, "config", "--get", key)
|
||||
if configErr != nil {
|
||||
return result, false, fmt.Errorf("read Git config %s: %w", key, configErr)
|
||||
}
|
||||
if found && value != "" {
|
||||
remote = value
|
||||
break
|
||||
}
|
||||
}
|
||||
if remote == "" && hasUpstreamRemote {
|
||||
remote = upstreamRemote
|
||||
}
|
||||
if remote == "." {
|
||||
return result, false, nil
|
||||
}
|
||||
if remote == "" {
|
||||
if containsString(remoteNames, "origin") {
|
||||
remote = "origin"
|
||||
} else if len(remoteNames) == 1 {
|
||||
remote = remoteNames[0]
|
||||
} else {
|
||||
return result, false, nil
|
||||
}
|
||||
}
|
||||
if !containsString(remoteNames, remote) {
|
||||
return result, false, fmt.Errorf("configured automatic push remote %q does not exist", remote)
|
||||
}
|
||||
|
||||
ref := "refs/heads/" + branch
|
||||
if mergeRef, found, configErr := r.optionalGitOutput(ctx, "config", "--get", "branch."+branch+".merge"); configErr != nil {
|
||||
return result, false, fmt.Errorf("read upstream branch for %s: %w", branch, configErr)
|
||||
} else if found && remote == upstreamRemote && strings.HasPrefix(mergeRef, "refs/heads/") {
|
||||
ref = mergeRef
|
||||
}
|
||||
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
|
||||
}
|
||||
|
||||
func (r *Repository) optionalGitOutput(ctx context.Context, args ...string) (string, bool, error) {
|
||||
output, err := r.run(ctx, nil, nil, args...)
|
||||
if err != nil {
|
||||
if gitExitCode(err) == 1 {
|
||||
return "", false, nil
|
||||
}
|
||||
return "", false, err
|
||||
}
|
||||
return trimLine(output), true, nil
|
||||
}
|
||||
|
||||
func nonemptyLines(value string) []string {
|
||||
var lines []string
|
||||
for _, line := range strings.Split(value, "\n") {
|
||||
if line = strings.TrimSpace(strings.TrimSuffix(line, "\r")); line != "" {
|
||||
lines = append(lines, line)
|
||||
}
|
||||
}
|
||||
return lines
|
||||
}
|
||||
|
||||
func containsString(values []string, target string) bool {
|
||||
for _, value := range values {
|
||||
if value == target {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// Snapshot records all tracked files and all non-ignored untracked files in the
|
||||
// worktree. It uses a disposable index, preserving both the contents and staging
|
||||
// state of the user's real index. The synthetic commit is parented to HEAD when
|
||||
|
||||
+13
-6
@@ -81,12 +81,19 @@ type Status struct {
|
||||
}
|
||||
|
||||
type AcceptResult struct {
|
||||
State State `json:"state"`
|
||||
ProposalPaths []string `json:"proposal_paths"`
|
||||
CurrentPaths []string `json:"current_paths"`
|
||||
Check *Check `json:"check,omitempty"`
|
||||
MaterializedRoot string `json:"materialized_root,omitempty"`
|
||||
Warnings []string `json:"warnings,omitempty"`
|
||||
State State `json:"state"`
|
||||
ProposalPaths []string `json:"proposal_paths"`
|
||||
CurrentPaths []string `json:"current_paths"`
|
||||
Check *Check `json:"check,omitempty"`
|
||||
MaterializedRoot string `json:"materialized_root,omitempty"`
|
||||
RemotePush *RemotePushResult `json:"remote_push,omitempty"`
|
||||
Warnings []string `json:"warnings,omitempty"`
|
||||
}
|
||||
|
||||
type RemotePushResult struct {
|
||||
Remote string `json:"remote"`
|
||||
Ref string `json:"ref"`
|
||||
Commit string `json:"commit"`
|
||||
}
|
||||
|
||||
type SyncResult struct {
|
||||
|
||||
@@ -644,6 +644,32 @@ func (s *Service) Land(ctx context.Context, proposalID string, checkCommand []st
|
||||
return s.accept(ctx, proposalID, checkCommand, true)
|
||||
}
|
||||
|
||||
// Push publishes the current accepted commit to the repository's inferred
|
||||
// upstream branch. Land and Accept call the same operation automatically; this
|
||||
// explicit form is the retry/recovery surface for an agent after a warning.
|
||||
func (s *Service) Push(ctx context.Context) (RemotePushResult, error) {
|
||||
release, err := acquireProjectLock(ctx, s.Root, "accept")
|
||||
if err != nil {
|
||||
return RemotePushResult{}, err
|
||||
}
|
||||
defer release()
|
||||
accepted, err := s.Store.AcceptedHead(ctx)
|
||||
if err != nil {
|
||||
return RemotePushResult{}, err
|
||||
}
|
||||
pushCtx, cancel := context.WithTimeout(ctx, 30*time.Second)
|
||||
defer cancel()
|
||||
result, configured, err := s.Repo.PushAccepted(pushCtx, accepted.GitCommit)
|
||||
if err != nil {
|
||||
message, _ := RedactPromptSecrets(err.Error())
|
||||
return RemotePushResult{}, errors.New(message)
|
||||
}
|
||||
if !configured {
|
||||
return RemotePushResult{}, errors.New("hop: no unambiguous Git remote branch is configured for automatic push")
|
||||
}
|
||||
return result, nil
|
||||
}
|
||||
|
||||
func (s *Service) accept(ctx context.Context, proposalID string, checkCommand []string, materialize bool) (AcceptResult, error) {
|
||||
release, err := acquireProjectLock(ctx, s.Root, "accept")
|
||||
if err != nil {
|
||||
@@ -668,6 +694,7 @@ func (s *Service) accept(ctx context.Context, proposalID string, checkCommand []
|
||||
}
|
||||
result.MaterializedRoot = s.Root
|
||||
}
|
||||
s.attachAutomaticPush(ctx, &result)
|
||||
return result, nil
|
||||
}
|
||||
attempt, err := s.Store.GetAttempt(ctx, proposal.AttemptID)
|
||||
@@ -844,9 +871,24 @@ func (s *Service) accept(ctx context.Context, proposalID string, checkCommand []
|
||||
}
|
||||
result.MaterializedRoot = s.Root
|
||||
}
|
||||
s.attachAutomaticPush(ctx, &result)
|
||||
return result, nil
|
||||
}
|
||||
|
||||
func (s *Service) attachAutomaticPush(ctx context.Context, result *AcceptResult) {
|
||||
pushCtx, cancel := context.WithTimeout(ctx, 30*time.Second)
|
||||
defer cancel()
|
||||
pushed, configured, err := s.Repo.PushAccepted(pushCtx, result.State.GitCommit)
|
||||
if err != nil {
|
||||
message, _ := RedactPromptSecrets(err.Error())
|
||||
result.Warnings = append(result.Warnings, "accepted state is local, but automatic push failed: "+message)
|
||||
return
|
||||
}
|
||||
if configured {
|
||||
result.RemotePush = &pushed
|
||||
}
|
||||
}
|
||||
|
||||
// Sync projects the current accepted tree into a visible root that still
|
||||
// matches its durable materialized head. It is useful after controller-only accepts or
|
||||
// when upgrading a project created before automatic materialization existed.
|
||||
|
||||
@@ -1448,6 +1448,103 @@ func TestLandMaterializesVisibleRootWithoutMovingGitState(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestLandAutomaticallyPushesAcceptedCommit(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
service, _ := newTestProject(t, map[string]string{"base.txt": "base\n"})
|
||||
remote := filepath.Join(t.TempDir(), "remote.git")
|
||||
runGitTest(t, service.Root, "init", "--quiet", "--bare", remote)
|
||||
runGitTest(t, service.Root, "remote", "add", "origin", remote)
|
||||
branch := runGitTest(t, service.Root, "symbolic-ref", "--short", "HEAD")
|
||||
|
||||
started, err := service.CreatePrompt(ctx, "Publish accepted work", "", "agent")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
writeTestFile(t, filepath.Join(started.Workspace, "published.txt"), "published\n")
|
||||
proposal, err := service.Propose(ctx, started.Prompt.ID, "Published change")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
result, err := service.Land(ctx, proposal.Proposal.ID, nil)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if result.RemotePush == nil {
|
||||
t.Fatal("land did not report an automatic remote push")
|
||||
}
|
||||
wantRef := "refs/heads/" + branch
|
||||
if result.RemotePush.Remote != "origin" || result.RemotePush.Ref != wantRef || result.RemotePush.Commit != result.State.GitCommit {
|
||||
t.Fatalf("automatic push = %#v", result.RemotePush)
|
||||
}
|
||||
if got := runGitTest(t, service.Root, "--git-dir", remote, "rev-parse", wantRef); got != result.State.GitCommit {
|
||||
t.Fatalf("remote branch = %s, want accepted commit %s", got, result.State.GitCommit)
|
||||
}
|
||||
retried, err := service.Push(ctx)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if retried.Commit != result.State.GitCommit || retried.Remote != "origin" || retried.Ref != wantRef {
|
||||
t.Fatalf("explicit push retry = %#v", retried)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAutomaticPushNeverForcesDivergedRemote(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
service, _ := newTestProject(t, map[string]string{"base.txt": "base\n"})
|
||||
remote := filepath.Join(t.TempDir(), "remote.git")
|
||||
runGitTest(t, service.Root, "init", "--quiet", "--bare", remote)
|
||||
runGitTest(t, service.Root, "remote", "add", "origin", remote)
|
||||
branch := runGitTest(t, service.Root, "symbolic-ref", "--short", "HEAD")
|
||||
|
||||
first, err := service.CreatePrompt(ctx, "Publish first work", "", "agent")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
writeTestFile(t, filepath.Join(first.Workspace, "first.txt"), "first\n")
|
||||
firstProposal, err := service.Propose(ctx, first.Prompt.ID, "First published change")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
firstLanded, err := service.Land(ctx, firstProposal.Proposal.ID, nil)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
other := filepath.Join(t.TempDir(), "other")
|
||||
runGitTest(t, service.Root, "clone", "--quiet", "--branch", branch, remote, other)
|
||||
writeTestFile(t, filepath.Join(other, "remote.txt"), "remote\n")
|
||||
runGitTest(t, other, "add", "remote.txt")
|
||||
runGitTest(t, other, "-c", "user.name=Remote", "-c", "user.email=remote@example.com", "commit", "--quiet", "-m", "remote change")
|
||||
runGitTest(t, other, "push", "--quiet", "origin", branch)
|
||||
remoteTip := runGitTest(t, service.Root, "--git-dir", remote, "rev-parse", "refs/heads/"+branch)
|
||||
if remoteTip == firstLanded.State.GitCommit {
|
||||
t.Fatal("test did not advance the remote independently")
|
||||
}
|
||||
|
||||
second, err := service.CreatePrompt(ctx, "Publish local work", "", "agent")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
writeTestFile(t, filepath.Join(second.Workspace, "local.txt"), "local\n")
|
||||
secondProposal, err := service.Propose(ctx, second.Prompt.ID, "Local accepted change")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
secondLanded, err := service.Land(ctx, secondProposal.Proposal.ID, nil)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if secondLanded.RemotePush != nil {
|
||||
t.Fatalf("diverged remote unexpectedly reported a successful push: %#v", secondLanded.RemotePush)
|
||||
}
|
||||
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 != remoteTip {
|
||||
t.Fatalf("automatic push rewrote diverged remote from %s to %s", remoteTip, got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLandMaterializesEmptyUnbornRootAcrossRepeatedLands(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
root := t.TempDir()
|
||||
|
||||
Reference in New Issue
Block a user