Compare commits
2 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 7139b9e1a7 | |||
| 9a3a6d152b |
@@ -0,0 +1,21 @@
|
|||||||
|
MIT License
|
||||||
|
|
||||||
|
Copyright (c) 2026 Gnosys Labs LLC
|
||||||
|
|
||||||
|
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||||
|
of this software and associated documentation files (the "Software"), to deal
|
||||||
|
in the Software without restriction, including without limitation the rights
|
||||||
|
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||||
|
copies of the Software, and to permit persons to whom the Software is
|
||||||
|
furnished to do so, subject to the following conditions:
|
||||||
|
|
||||||
|
The above copyright notice and this permission notice shall be included in all
|
||||||
|
copies or substantial portions of the Software.
|
||||||
|
|
||||||
|
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||||
|
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||||
|
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||||
|
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||||
|
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||||
|
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||||
|
SOFTWARE.
|
||||||
@@ -21,6 +21,8 @@ and safe multi-agent integration.
|
|||||||
exact final tree before it becomes accepted.
|
exact final tree before it becomes accepted.
|
||||||
- **Accepted work is visible.** Successful results appear in the selected
|
- **Accepted work is visible.** Successful results appear in the selected
|
||||||
project folder without moving your active Git branch or index.
|
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
|
- **History stays local by default.** Detected credentials are redacted before
|
||||||
prompts and evidence are persisted.
|
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
|
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
|
a terminal, or work inside `.hop` yourself. After a task, `hop status` shows the
|
||||||
accepted state and whether the visible project folder is synchronized.
|
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
|
For example, Codex Desktop users restart Codex after installation, select a Git
|
||||||
project, and prompt normally. Other compatible runtimes can read the shared
|
project, and prompt normally. Other compatible runtimes can read the shared
|
||||||
@@ -87,3 +91,7 @@ Hop is currently an early alpha. Expect its state model and CLI to evolve before
|
|||||||
- [Security and privacy](https://githop.xyz/GnosysLabs/Hop/wiki/Security-and-Privacy)
|
- [Security and privacy](https://githop.xyz/GnosysLabs/Hop/wiki/Security-and-Privacy)
|
||||||
- [Architecture](https://githop.xyz/GnosysLabs/Hop/wiki/Architecture)
|
- [Architecture](https://githop.xyz/GnosysLabs/Hop/wiki/Architecture)
|
||||||
- [Product blueprint](docs/product-blueprint.md)
|
- [Product blueprint](docs/product-blueprint.md)
|
||||||
|
|
||||||
|
## License
|
||||||
|
|
||||||
|
[MIT](LICENSE) © 2026 Gnosys Labs LLC.
|
||||||
|
|||||||
@@ -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.
|
10. Run configured checks on that exact final state.
|
||||||
11. Auto-accept successful ordinary local work without another user prompt,
|
11. Auto-accept successful ordinary local work without another user prompt,
|
||||||
then advance the accepted head atomically, export a normal Git-compatible
|
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.
|
12. Undo an accepted state through a compensating prompt/integration state.
|
||||||
13. Generate a small `PROJECT.md` from accepted facts.
|
13. Generate a small `PROJECT.md` from accepted facts.
|
||||||
14. Install a vendor-neutral agent skill and expose stable JSON CLI output.
|
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.
|
- 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.
|
- 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.
|
- 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.
|
- 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.
|
- Assign each attempt isolated temp/cache paths and, where possible, a port range; filesystem isolation alone does not isolate development servers and local services.
|
||||||
|
|
||||||
|
|||||||
@@ -25,6 +25,7 @@ Usage:
|
|||||||
hop land STATE [-- COMMAND [ARG...]]
|
hop land STATE [-- COMMAND [ARG...]]
|
||||||
hop refresh PROPOSAL
|
hop refresh PROPOSAL
|
||||||
hop sync
|
hop sync
|
||||||
|
hop push
|
||||||
hop status
|
hop status
|
||||||
hop graph
|
hop graph
|
||||||
hop state STATE
|
hop state STATE
|
||||||
@@ -264,6 +265,7 @@ func RunCLIWithInput(args []string, stdin io.Reader, stdout, stderr io.Writer) i
|
|||||||
}
|
}
|
||||||
if !jsonOutput {
|
if !jsonOutput {
|
||||||
fmt.Fprintf(stdout, "Accepted internally as %s · tree %s · visible root unchanged\n", result.State.ID, shortHash(result.State.SourceTree))
|
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 {
|
if result.Check == nil {
|
||||||
fmt.Fprintln(stdout, "No final-state validation command was supplied.")
|
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 {
|
if !jsonOutput {
|
||||||
fmt.Fprintf(stdout, "Landed as %s · tree %s\n", result.State.ID, shortHash(result.State.SourceTree))
|
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)
|
fmt.Fprintf(stdout, "Synchronized visible root: %s\n", result.MaterializedRoot)
|
||||||
|
printRemotePush(stdout, result.RemotePush)
|
||||||
if result.Check == nil {
|
if result.Check == nil {
|
||||||
fmt.Fprintln(stdout, "No final-state validation command was supplied.")
|
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":
|
case "status":
|
||||||
status, err := service.Status(ctx)
|
status, err := service.Status(ctx)
|
||||||
if err != nil {
|
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)
|
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) {
|
func removeFlag(args []string, wanted string) (bool, []string) {
|
||||||
found := false
|
found := false
|
||||||
filtered := make([]string, 0, len(args))
|
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
|
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
|
// 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
|
// 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
|
// state of the user's real index. The synthetic commit is parented to HEAD when
|
||||||
|
|||||||
@@ -86,9 +86,16 @@ type AcceptResult struct {
|
|||||||
CurrentPaths []string `json:"current_paths"`
|
CurrentPaths []string `json:"current_paths"`
|
||||||
Check *Check `json:"check,omitempty"`
|
Check *Check `json:"check,omitempty"`
|
||||||
MaterializedRoot string `json:"materialized_root,omitempty"`
|
MaterializedRoot string `json:"materialized_root,omitempty"`
|
||||||
|
RemotePush *RemotePushResult `json:"remote_push,omitempty"`
|
||||||
Warnings []string `json:"warnings,omitempty"`
|
Warnings []string `json:"warnings,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type RemotePushResult struct {
|
||||||
|
Remote string `json:"remote"`
|
||||||
|
Ref string `json:"ref"`
|
||||||
|
Commit string `json:"commit"`
|
||||||
|
}
|
||||||
|
|
||||||
type SyncResult struct {
|
type SyncResult struct {
|
||||||
State State `json:"state"`
|
State State `json:"state"`
|
||||||
Root string `json:"root"`
|
Root string `json:"root"`
|
||||||
|
|||||||
@@ -644,6 +644,32 @@ func (s *Service) Land(ctx context.Context, proposalID string, checkCommand []st
|
|||||||
return s.accept(ctx, proposalID, checkCommand, true)
|
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) {
|
func (s *Service) accept(ctx context.Context, proposalID string, checkCommand []string, materialize bool) (AcceptResult, error) {
|
||||||
release, err := acquireProjectLock(ctx, s.Root, "accept")
|
release, err := acquireProjectLock(ctx, s.Root, "accept")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -668,6 +694,7 @@ func (s *Service) accept(ctx context.Context, proposalID string, checkCommand []
|
|||||||
}
|
}
|
||||||
result.MaterializedRoot = s.Root
|
result.MaterializedRoot = s.Root
|
||||||
}
|
}
|
||||||
|
s.attachAutomaticPush(ctx, &result)
|
||||||
return result, nil
|
return result, nil
|
||||||
}
|
}
|
||||||
attempt, err := s.Store.GetAttempt(ctx, proposal.AttemptID)
|
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
|
result.MaterializedRoot = s.Root
|
||||||
}
|
}
|
||||||
|
s.attachAutomaticPush(ctx, &result)
|
||||||
return result, nil
|
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
|
// 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
|
// matches its durable materialized head. It is useful after controller-only accepts or
|
||||||
// when upgrading a project created before automatic materialization existed.
|
// 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) {
|
func TestLandMaterializesEmptyUnbornRootAcrossRepeatedLands(t *testing.T) {
|
||||||
ctx := context.Background()
|
ctx := context.Background()
|
||||||
root := t.TempDir()
|
root := t.TempDir()
|
||||||
|
|||||||
+9
-2
@@ -71,7 +71,7 @@ variable or secret-manager name instead.
|
|||||||
- Use absolute paths beneath that workspace for file reads and edits.
|
- Use absolute paths beneath that workspace for file reads and edits.
|
||||||
- Never edit the selected canonical project root.
|
- Never edit the selected canonical project root.
|
||||||
- Do not run `git commit`, `git checkout`, `git switch`, `git branch`,
|
- 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.
|
- Do not stage files. Hop captures every nonignored workspace change.
|
||||||
- Give a subagent project-changing work only after creating a distinct Hop
|
- Give a subagent project-changing work only after creating a distinct Hop
|
||||||
prompt/attempt for that delegation.
|
prompt/attempt for that delegation.
|
||||||
@@ -130,7 +130,9 @@ hop status --json
|
|||||||
8. Report the accepted result, validation, and remaining risks. Keep internal
|
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
|
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
|
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
|
For a read-only or informational turn, the prompt state is sufficient; do not
|
||||||
invent a proposal when the workspace tree is unchanged.
|
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
|
capture a second prompt merely to land. After checks pass and the proposal is
|
||||||
frozen, run `hop land` as part of the same turn.
|
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
|
Use the strongest relevant final validation command. If the task truly has no
|
||||||
runnable validation, `hop land <proposal-state>` is allowed and the final
|
runnable validation, `hop land <proposal-state>` is allowed and the final
|
||||||
response must say that acceptance was not validated by a command.
|
response must say that acceptance was not validated by a command.
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
interface:
|
interface:
|
||||||
display_name: "Hop Version Control"
|
display_name: "Hop Version Control"
|
||||||
short_description: "Capture every coding prompt before project effects"
|
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:
|
policy:
|
||||||
allow_implicit_invocation: true
|
allow_implicit_invocation: true
|
||||||
|
|||||||
@@ -91,6 +91,7 @@ hop check "$HOP_STATE_ID" -- <command>
|
|||||||
hop propose --summary "<summary>" "$HOP_STATE_ID"
|
hop propose --summary "<summary>" "$HOP_STATE_ID"
|
||||||
hop land <proposal-state> -- <final validation command>
|
hop land <proposal-state> -- <final validation command>
|
||||||
hop refresh <proposal-state>
|
hop refresh <proposal-state>
|
||||||
|
hop push
|
||||||
```
|
```
|
||||||
|
|
||||||
An adapter may set `HOP_AGENT=<runtime>` or pass `--agent <runtime>`. If it has
|
An adapter may set `HOP_AGENT=<runtime>` or pass `--agent <runtime>`. 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
|
`hop sync` safely catches a stale accepted-ancestor root up to the current
|
||||||
accepted state, including projects created with older Hop builds.
|
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
|
`hop begin` is the interactive-agent entry point. It initializes Hop when
|
||||||
necessary and captures the current message before the agent performs project
|
necessary and captures the current message before the agent performs project
|
||||||
work. Runtime adapters identify themselves through `HOP_AGENT` or `--agent`
|
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.
|
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.
|
- **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.
|
- **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.
|
- **Ref inconsistency:** run `hop doctor`; use `hop doctor --repair` only outside final validation.
|
||||||
- **Secrets:** Hop redacts high-confidence provider keys plus contextual tokens,
|
- **Secrets:** Hop redacts high-confidence provider keys plus contextual tokens,
|
||||||
passwords, private keys, authorization headers, and credential-bearing URLs
|
passwords, private keys, authorization headers, and credential-bearing URLs
|
||||||
|
|||||||
@@ -34,7 +34,9 @@ hop land R_... -- go test ./...
|
|||||||
```
|
```
|
||||||
|
|
||||||
No second landing authorization is requested unless the user explicitly asks
|
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
|
Skill-based capture stores the agent's verbatim transcription of the visible
|
||||||
message and its attachment references. Because the skill runs after the client
|
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 edit the canonical project root directly.
|
||||||
- Never mutate a frozen proposal.
|
- 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.
|
- Do not bypass `hop land` with Git reset, checkout, worktree, or manual copying.
|
||||||
- Run validation against immutable checkpoints and the final integrated tree.
|
- Run validation against immutable checkpoints and the final integrated tree.
|
||||||
- Let Hop merge compatible concurrent work.
|
- Let Hop merge compatible concurrent work.
|
||||||
|
|||||||
@@ -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
|
also tracks which accepted state is physically visible, allowing safe catch-up
|
||||||
with `hop sync` without treating a divergent folder as disposable.
|
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
|
For the full product direction, read the
|
||||||
[product blueprint](https://githop.xyz/GnosysLabs/Hop/src/branch/main/docs/product-blueprint.md).
|
[product blueprint](https://githop.xyz/GnosysLabs/Hop/src/branch/main/docs/product-blueprint.md).
|
||||||
|
|||||||
@@ -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 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 |
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
as the controller's working directory. Hop only materializes into it when it
|
||||||
still matches an accepted Hop ancestor. Untracked, ignored, staged, or ordinary
|
still matches an accepted Hop ancestor. Untracked, ignored, staged, or ordinary
|
||||||
file divergence that could be overwritten causes a fail-closed error.
|
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.
|
||||||
|
|||||||
@@ -48,6 +48,11 @@ hop doctor
|
|||||||
|
|
||||||
A normal interactive result reports `Root: synchronized`.
|
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
|
## Ask for review before landing
|
||||||
|
|
||||||
Automatic landing is the default because the original task authorizes the
|
Automatic landing is the default because the original task authorizes the
|
||||||
|
|||||||
+2
-1
@@ -3,7 +3,8 @@
|
|||||||
Hop is prompt-native version control for coding agents. It stores each prompt
|
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
|
as an immutable project state, gives agent work an isolated workspace, validates
|
||||||
the exact tree being accepted, and safely materializes accepted results into the
|
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
|
## Start here
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
real Git index. Visible-root synchronization fails closed when files, ignored
|
||||||
destinations, or staged state could be overwritten.
|
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
|
## Reporting a vulnerability
|
||||||
|
|
||||||
Before the public security contact is configured, disclose vulnerabilities
|
Before the public security contact is configured, disclose vulnerabilities
|
||||||
|
|||||||
@@ -73,6 +73,18 @@ hop doctor --repair
|
|||||||
|
|
||||||
Do not repair while a final validation command is running.
|
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
|
## A secret was pasted
|
||||||
|
|
||||||
Rotate it. Hop redaction reduces durable exposure but cannot prove that every
|
Rotate it. Hop redaction reduces durable exposure but cannot prove that every
|
||||||
|
|||||||
Reference in New Issue
Block a user