Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -409,7 +409,7 @@ Or just start the session and type your request in the agent interface.
- [Session Management](docs/SESSIONS.md) — commands, lifecycle, code sync
- [Configuration](docs/CONFIGURATION.md) — defaults, network domains, GitHub CLI, custom environments
- [Security Model](docs/SECURITY.md) — attack vectors, `--yolo` safety, residual risks
- [Orchestration](docs/ORCHESTRATION.md) — fire-and-forget workflow, harvest (including containers holding multiple repos), PRs
- [Orchestration](docs/ORCHESTRATION.md) — fire-and-forget workflow, source-aware harvest (including containers holding multiple repos), PRs
- [Remote Hosts & Docker](docs/REMOTE.md) — SSH remotes, Docker backend, GPU passthrough

## How It Works
Expand Down
18 changes: 17 additions & 1 deletion docs/ORCHESTRATION.md
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,18 @@ paude harvest my-project -b feature/auth-refactor

This creates a local `feature/auth-refactor` branch with all of the agent's commits. Review the diff, run tests, and iterate as needed.

To harvest a branch that is not checked out in the container, select it with
`--from`. The local destination defaults to the same branch, so this is enough
for the common case:

```bash
paude harvest my-project --from feature/auth-refactor
```

Use `-b`/`--branch` to choose a different local destination. Without `--from`,
harvest retains its existing behavior and uses the branch currently checked out
in the container. A source branch that does not exist is reported as an error.

Protected branches (`main`, `master`, `release`, `release-*`, `release/*`) cannot be used as harvest targets.

### Harvesting a repo at a non-default path
Expand All @@ -57,7 +69,7 @@ paude harvest my-project -b fix/foo \
--repo ~/src/api # host repo to harvest into
```

`--container-path` selects which repo inside the container to fetch from, `--remote` names the git remote (default `paude-<session>`; use a non-`paude-` name so `paude remote cleanup` leaves it alone), and `--repo` chooses which host checkout to harvest into (default: the session's recorded workspace). Set up a matching remote up front with `paude remote add my-project --container-path <path> --remote <remote>` if you prefer, though harvest adds it automatically when missing.
`--container-path` selects which repo inside the container to fetch from, `--remote` names the git remote (default `paude-<session>`; use a non-`paude-` name so `paude remote cleanup` leaves it alone), and `--repo` chooses which host checkout to harvest into (default: the session's recorded workspace). When these flags are omitted, harvest reuses a matching `paude ext::` remote in the current host checkout and infers its encoded container path. If more than one remote targets the session, specify `--remote` or `--repo`; harvest never chooses ambiguously. Set up a matching remote up front with `paude remote add my-project --container-path <path> --remote <remote>` if you prefer, though harvest adds it automatically when missing.

## Open a PR

Expand All @@ -72,6 +84,10 @@ paude harvest my-project -b feature/auth-refactor --pr --pr-title "Refactor auth

This pushes `feature/auth-refactor` to origin (force-with-lease) and runs `gh pr create`. If an open PR already exists for that branch, it just prints the URL instead of creating a duplicate.

Harvest performs an exact reset to the selected remote source ref. If you
intentionally want to combine it with the current local history, use ordinary
git fetch and merge commands instead.

## Reset and Repeat

After the PR merges, reset the remote session to prepare for the next task:
Expand Down
8 changes: 8 additions & 0 deletions src/paude/cli/help.py
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,14 @@ class HelpSection:
"Create, start, push code+tags, set origin",
),
("paude connect my-project", "Connect to running session"),
(
"paude harvest my-project --from feature/foo",
"Harvest a named container branch (destination defaults to it)",
),
(
"paude harvest my-project -b fix/foo",
"Harvest the container's checked-out branch",
),
("", ""),
("Manual workflow:", ""),
("paude create my-project", "Create and start session"),
Expand Down
35 changes: 26 additions & 9 deletions src/paude/cli/status.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,6 @@
import typer

from paude.cli.app import app
from paude.constants import CONTAINER_WORKSPACE


@app.command("status")
Expand Down Expand Up @@ -61,9 +60,21 @@ def reset_cmd(
def harvest_cmd(
session: Annotated[str, typer.Argument(help="Session name to harvest from.")],
branch: Annotated[
str,
typer.Option("--branch", "-b", help="Local branch name to create."),
],
str | None,
typer.Option(
"--branch",
"-b",
help="Local branch name to create (defaults to --from).",
),
] = None,
source_branch: Annotated[
str | None,
typer.Option(
"--from",
"--source-branch",
help="Branch or ref to harvest from the container.",
),
] = None,
pr: Annotated[
bool,
typer.Option("--pr", help="Create a PR after harvesting."),
Expand All @@ -73,20 +84,24 @@ def harvest_cmd(
typer.Option("--pr-title", help="PR title (defaults to branch name)."),
] = None,
container_path: Annotated[
str,
str | None,
typer.Option(
"--container-path",
help=(
"Path of the repo inside the container to harvest from "
"(default: the session workspace)."
"(default: inferred from a matching remote or the session "
"workspace)."
),
),
] = CONTAINER_WORKSPACE,
] = None,
remote: Annotated[
str | None,
typer.Option(
"--remote",
help="Git remote name to use (default: paude-<session>).",
help=(
"Git remote name to use (default: matching remote in the "
"current checkout or paude-<session>)."
),
),
] = None,
repo: Annotated[
Expand All @@ -95,7 +110,8 @@ def harvest_cmd(
"--repo",
help=(
"Host git repo to harvest into "
"(default: the session's recorded workspace)."
"(default: current checkout with a matching remote or the "
"session's recorded workspace)."
),
),
] = None,
Expand All @@ -111,4 +127,5 @@ def harvest_cmd(
container_path=container_path,
remote_name=remote,
repo=repo,
source_branch=source_branch,
)
2 changes: 2 additions & 0 deletions src/paude/git_remote/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,7 @@
is_container_running_podman,
is_ext_protocol_allowed,
is_git_repository,
list_git_remotes,
list_paude_remotes,
resolve_local_git_identity,
resolve_origin_cmd,
Expand Down Expand Up @@ -75,6 +76,7 @@
"is_container_running_podman",
"is_ext_protocol_allowed",
"is_git_repository",
"list_git_remotes",
"list_paude_remotes",
"podman_exec_builder",
"resolve_local_git_identity",
Expand Down
26 changes: 21 additions & 5 deletions src/paude/git_remote/utils.py
Original file line number Diff line number Diff line change
Expand Up @@ -211,6 +211,15 @@ def git_remote_remove(remote_name: str, cwd: Path | None = None) -> bool:

def list_paude_remotes(cwd: Path | None = None) -> list[tuple[str, str]]:
"""List all paude git remotes."""
return [
(name, url)
for name, url in list_git_remotes(cwd=cwd)
if name.startswith("paude-")
]


def list_git_remotes(cwd: Path | None = None) -> list[tuple[str, str]]:
"""List each git remote and its URL once."""
result = subprocess.run(
["git", "remote", "-v"],
capture_output=True,
Expand All @@ -230,8 +239,8 @@ def list_paude_remotes(cwd: Path | None = None) -> list[tuple[str, str]]:
parts = line.split("\t", 1)
if len(parts) >= 2:
name = parts[0]
url_part = parts[1].rsplit(" ", 1)[0] if " " in parts[1] else parts[1]
if name.startswith("paude-") and name not in seen:
url_part = parts[1].removesuffix(" (fetch)").removesuffix(" (push)")
if name not in seen:
remotes.append((name, url_part))
seen.add(name)

Expand Down Expand Up @@ -335,10 +344,17 @@ def git_push_tags_to_remote(remote_name: str) -> bool:
return result.returncode == 0


def git_fetch_from_remote(remote_name: str, cwd: Path | None = None) -> bool:
"""Fetch from a git remote."""
def git_fetch_from_remote(
remote_name: str,
cwd: Path | None = None,
source_ref: str | None = None,
) -> bool:
"""Fetch from a git remote, optionally limiting the fetch to one ref."""
command = ["git", "fetch", remote_name]
if source_ref is not None:
command.append(source_ref)
result = subprocess.run(
["git", "fetch", remote_name],
command,
capture_output=True,
text=True,
cwd=cwd,
Expand Down
Loading