diff --git a/README.md b/README.md index 9a3fe07..65b3d35 100644 --- a/README.md +++ b/README.md @@ -21,6 +21,8 @@ and safe multi-agent integration. exact final tree before it becomes accepted. - **Accepted work is visible.** Successful results appear in the selected project folder without moving your active Git branch or index. +- **Publishing is automatic.** When an upstream branch exists, each accepted + transition is pushed without moving the local branch or force-pushing. - **History stays local by default.** Detected credentials are redacted before prompts and evidence are persisted. @@ -68,6 +70,8 @@ verification, see the That is the full user workflow. You do not run `hop init`, route prompts through a terminal, or work inside `.hop` yourself. After a task, `hop status` shows the accepted state and whether the visible project folder is synchronized. +When the repository has an unambiguous Git upstream, Hop also pushes the +accepted commit automatically; users do not run `git push` after each task. For example, Codex Desktop users restart Codex after installation, select a Git project, and prompt normally. Other compatible runtimes can read the shared diff --git a/docs/product-blueprint.md b/docs/product-blueprint.md index 46249e2..44b370c 100644 --- a/docs/product-blueprint.md +++ b/docs/product-blueprint.md @@ -342,7 +342,8 @@ The smallest complete product is a **parallel-agent landing queue** backed by Gi 10. Run configured checks on that exact final state. 11. Auto-accept successful ordinary local work without another user prompt, then advance the accepted head atomically, export a normal Git-compatible - commit, and safely synchronize the visible Desktop root. + commit, safely synchronize the visible root, and non-force push an existing + unambiguous Git upstream. 12. Undo an accepted state through a compensating prompt/integration state. 13. Generate a small `PROJECT.md` from accepted facts. 14. Install a vendor-neutral agent skill and expose stable JSON CLI output. @@ -370,7 +371,9 @@ The smallest complete product is a **parallel-agent landing queue** backed by Gi - Deduplicate roots so a prompt that changes one file—or no files—adds only a small manifest plus new chunks rather than copying the repository. - Use a temporary integration worktree for final checks. - Emit an ordinary Git commit for each accepted source transition, with `Hop-State`, `Hop-Task`, and `Hop-Attempt` trailers. -- Preserve metadata in a Hop receipt referenced by an internal Git ref; add remote synchronization later. +- Preserve metadata in a Hop receipt referenced by an internal Git ref. Push + accepted Git commits to existing upstream branches now; distributed Hop-state + synchronization remains later work. - Shell out to the installed Git CLI before taking on the complexity of a custom Git implementation. - Assign each attempt isolated temp/cache paths and, where possible, a port range; filesystem isolation alone does not isolate development servers and local services. diff --git a/internal/hop/cli.go b/internal/hop/cli.go index 51ba97c..bd1afde 100644 --- a/internal/hop/cli.go +++ b/internal/hop/cli.go @@ -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 -- , 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)) diff --git a/internal/hop/git.go b/internal/hop/git.go index 6e6e327..20bf6f6 100644 --- a/internal/hop/git.go +++ b/internal/hop/git.go @@ -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 diff --git a/internal/hop/model.go b/internal/hop/model.go index 2187784..208c18c 100644 --- a/internal/hop/model.go +++ b/internal/hop/model.go @@ -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 { diff --git a/internal/hop/service.go b/internal/hop/service.go index b339a86..0aed030 100644 --- a/internal/hop/service.go +++ b/internal/hop/service.go @@ -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. diff --git a/internal/hop/service_smoke_test.go b/internal/hop/service_smoke_test.go index 5644439..8c83725 100644 --- a/internal/hop/service_smoke_test.go +++ b/internal/hop/service_smoke_test.go @@ -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() diff --git a/skills/hop/SKILL.md b/skills/hop/SKILL.md index 5c0d06f..4d6950e 100644 --- a/skills/hop/SKILL.md +++ b/skills/hop/SKILL.md @@ -71,7 +71,7 @@ variable or secret-manager name instead. - Use absolute paths beneath that workspace for file reads and edits. - Never edit the selected canonical project root. - Do not run `git commit`, `git checkout`, `git switch`, `git branch`, - `git rebase`, `git reset`, `git stash`, or `git worktree`. + `git rebase`, `git reset`, `git stash`, `git worktree`, or `git push`. - Do not stage files. Hop captures every nonignored workspace change. - Give a subagent project-changing work only after creating a distinct Hop prompt/attempt for that delegation. @@ -130,7 +130,9 @@ hop status --json 8. Report the accepted result, validation, and remaining risks. Keep internal state and evidence IDs out of the normal response unless they help explain a failure or the user asks for them. Confirm that `hop land` reported the - selected visible project root as synchronized. + selected visible project root as synchronized. When it reports an automatic + push warning, retry once with `hop push`; never force-push or ask the user to + perform routine source-control mechanics. For a read-only or informational turn, the prompt state is sufficient; do not invent a proposal when the workspace tree is unchanged. @@ -147,6 +149,11 @@ to complete that task. Do not ask for separate landing permission and do not capture a second prompt merely to land. After checks pass and the proposal is frozen, run `hop land` as part of the same turn. +An existing unambiguous Git upstream is standing project configuration for +non-forced publication of accepted states. Hop pushes accepted commits +automatically after landing; prompts, checkpoints, proposals, and `.hop/` state +remain local. Do not run raw `git push`. + Use the strongest relevant final validation command. If the task truly has no runnable validation, `hop land ` is allowed and the final response must say that acceptance was not validated by a command. diff --git a/skills/hop/agents/openai.yaml b/skills/hop/agents/openai.yaml index 0089343..d57f0b8 100644 --- a/skills/hop/agents/openai.yaml +++ b/skills/hop/agents/openai.yaml @@ -1,7 +1,7 @@ interface: display_name: "Hop Version Control" short_description: "Capture every coding prompt before project effects" - default_prompt: "Use $hop to capture this prompt first, complete and validate the task in its isolated workspace, auto-land it, and automatically reconcile ordinary merge conflicts unless I explicitly request review first." + default_prompt: "Use $hop to capture this prompt first, complete and validate the task in its isolated workspace, auto-land and publish accepted work, and automatically reconcile ordinary merge conflicts unless I explicitly request review first." policy: allow_implicit_invocation: true diff --git a/skills/hop/references/protocol.md b/skills/hop/references/protocol.md index c06b8ae..b227566 100644 --- a/skills/hop/references/protocol.md +++ b/skills/hop/references/protocol.md @@ -91,6 +91,7 @@ hop check "$HOP_STATE_ID" -- hop propose --summary "" "$HOP_STATE_ID" hop land -- hop refresh +hop push ``` An adapter may set `HOP_AGENT=` or pass `--agent `. If it has @@ -135,6 +136,12 @@ explicit form of the same preparation step. `hop sync` safely catches a stale accepted-ancestor root up to the current accepted state, including projects created with older Hop builds. +After every successful `hop land` or `hop accept`, Hop automatically performs a +non-forced push of the accepted commit when the active branch has an +unambiguous upstream. No remote is a normal local-only mode. Push failure does +not undo acceptance; `hop push` retries the current accepted commit. Agents +must never replace a non-fast-forward rejection with a force-push. + `hop begin` is the interactive-agent entry point. It initializes Hop when necessary and captures the current message before the agent performs project work. Runtime adapters identify themselves through `HOP_AGENT` or `--agent` @@ -206,6 +213,7 @@ provide the same boundary inside compatible agent clients. product ambiguity, not ordinary textual overlap. - **Visible-root conflict:** preserve the proposal and the user's files. Do not substitute controller-only `hop accept`; resolve or capture the visible changes, then land again. - **Controller-accepted root is stale:** run `hop sync`; it succeeds only from an accepted ancestor and never overwrites divergence. +- **Automatic push warning:** retry once with `hop push`; preserve a diverged remote and never force-push it. - **Ref inconsistency:** run `hop doctor`; use `hop doctor --repair` only outside final validation. - **Secrets:** Hop redacts high-confidence provider keys plus contextual tokens, passwords, private keys, authorization headers, and credential-bearing URLs diff --git a/wiki/Agent-Workflow.md b/wiki/Agent-Workflow.md index 048e8f1..90d55a0 100644 --- a/wiki/Agent-Workflow.md +++ b/wiki/Agent-Workflow.md @@ -34,7 +34,9 @@ hop land R_... -- go test ./... ``` No second landing authorization is requested unless the user explicitly asks -for review-first behavior. +for review-first behavior. After acceptance, Hop automatically pushes the +accepted commit when the repository has an unambiguous upstream. The agent does +not ask the user to run `git push`. Skill-based capture stores the agent's verbatim transcription of the visible message and its attachment references. Because the skill runs after the client @@ -84,6 +86,8 @@ can only guarantee capture before project effects. - Never edit the canonical project root directly. - Never mutate a frozen proposal. +- Inspect landing warnings. If automatic push failed transiently, retry once + with `hop push`; never force-push a diverged remote. - Do not bypass `hop land` with Git reset, checkout, worktree, or manual copying. - Run validation against immutable checkpoints and the final integrated tree. - Let Hop merge compatible concurrent work. diff --git a/wiki/Architecture.md b/wiki/Architecture.md index c80e00b..0089eb2 100644 --- a/wiki/Architecture.md +++ b/wiki/Architecture.md @@ -50,5 +50,10 @@ derived Git refs can be repaired by `hop doctor --repair`. Visible-root landing also tracks which accepted state is physically visible, allowing safe catch-up with `hop sync` without treating a divergent folder as disposable. +After that local transaction succeeds, Hop attempts a non-forced push of the +accepted commit to the inferred upstream branch. Remote publication is derived +and retryable: its failure cannot roll back or corrupt the durable local +acceptance. + For the full product direction, read the [product blueprint](https://githop.xyz/GnosysLabs/Hop/src/branch/main/docs/product-blueprint.md). diff --git a/wiki/CLI-Reference.md b/wiki/CLI-Reference.md index 21267c6..c5b7e41 100644 --- a/wiki/CLI-Reference.md +++ b/wiki/CLI-Reference.md @@ -34,6 +34,7 @@ release. |---|---| | `hop accept PROPOSAL [-- COMMAND...]` | Accept internally without changing visible files | | `hop sync` | Materialize the current accepted tree from a safe accepted ancestor | +| `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 doctor [--repair]` | Validate database/object/ref consistency | diff --git a/wiki/Core-Concepts.md b/wiki/Core-Concepts.md index 4e53f31..6884511 100644 --- a/wiki/Core-Concepts.md +++ b/wiki/Core-Concepts.md @@ -59,3 +59,13 @@ The visible root is the project directory selected in an agent client or passed as the controller's working directory. Hop only materializes into it when it still matches an accepted Hop ancestor. Untracked, ignored, staged, or ordinary file divergence that could be overwritten causes a fail-closed error. + +## Automatic upstream push + +Every successful accepted transition is automatically pushed to the active +branch's configured Git upstream. If no upstream is set, Hop uses `origin`, or a +single unambiguous remote, with the active branch name. It pushes only accepted +commits—not prompts, checkpoints, proposals, SQLite history, or workspaces—and +never force-pushes. A network, authentication, or non-fast-forward failure +leaves the accepted local state intact and is returned as a warning for the +agent to handle. diff --git a/wiki/Getting-Started.md b/wiki/Getting-Started.md index 62cd559..a6b7991 100644 --- a/wiki/Getting-Started.md +++ b/wiki/Getting-Started.md @@ -48,6 +48,11 @@ hop doctor A normal interactive result reports `Root: synchronized`. +If the active Git branch has an upstream—or the repository has one unambiguous +`origin`/single-remote destination—landing also fast-forward pushes the accepted +commit automatically. Hop never force-pushes. Repositories without a remote +remain local without treating that as an error. + ## Ask for review before landing Automatic landing is the default because the original task authorizes the diff --git a/wiki/Home.md b/wiki/Home.md index 3d8b554..3a4ede4 100644 --- a/wiki/Home.md +++ b/wiki/Home.md @@ -3,7 +3,8 @@ Hop is prompt-native version control for coding agents. It stores each prompt as an immutable project state, gives agent work an isolated workspace, validates the exact tree being accepted, and safely materializes accepted results into the -visible project folder. +visible project folder. When a Git upstream exists, accepted commits are pushed +automatically. ## Start here diff --git a/wiki/Security-and-Privacy.md b/wiki/Security-and-Privacy.md index bf6b918..ac26378 100644 --- a/wiki/Security-and-Privacy.md +++ b/wiki/Security-and-Privacy.md @@ -46,6 +46,11 @@ Hop does not use `reset --hard`, move the active branch, or write the user's real Git index. Visible-root synchronization fails closed when files, ignored destinations, or staged state could be overwritten. +Automatic push delegates authentication to the user's existing Git transport +and credential configuration. Hop does not store remote passwords, SSH private +keys, or access tokens. It disables terminal credential prompting in the +background push path and redacts detected credentials from returned errors. + ## Reporting a vulnerability Before the public security contact is configured, disclose vulnerabilities diff --git a/wiki/Troubleshooting.md b/wiki/Troubleshooting.md index 72f16dd..988fa7b 100644 --- a/wiki/Troubleshooting.md +++ b/wiki/Troubleshooting.md @@ -73,6 +73,18 @@ hop doctor --repair Do not repair while a final validation command is running. +## Automatic push failed + +The accepted state remains safe locally. Hop never force-pushes. Retry a +transient network or authentication failure with: + +```bash +hop push +``` + +If the remote branch moved independently, preserve both histories and resolve +the divergence intentionally; do not replace this with a force-push. + ## A secret was pasted Rotate it. Hop redaction reduces durable exposure but cannot prove that every