diff --git a/README.md b/README.md index bec79cf..d5ba6f1 100644 --- a/README.md +++ b/README.md @@ -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 diff --git a/docs/ORCHESTRATION.md b/docs/ORCHESTRATION.md index 8564909..cf87495 100644 --- a/docs/ORCHESTRATION.md +++ b/docs/ORCHESTRATION.md @@ -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 @@ -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-`; 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 --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-`; 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 --remote ` if you prefer, though harvest adds it automatically when missing. ## Open a PR @@ -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: diff --git a/src/paude/cli/help.py b/src/paude/cli/help.py index 462a22c..a8ffffd 100644 --- a/src/paude/cli/help.py +++ b/src/paude/cli/help.py @@ -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"), diff --git a/src/paude/cli/status.py b/src/paude/cli/status.py index dc51063..5a88448 100644 --- a/src/paude/cli/status.py +++ b/src/paude/cli/status.py @@ -7,7 +7,6 @@ import typer from paude.cli.app import app -from paude.constants import CONTAINER_WORKSPACE @app.command("status") @@ -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."), @@ -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-).", + help=( + "Git remote name to use (default: matching remote in the " + "current checkout or paude-)." + ), ), ] = None, repo: Annotated[ @@ -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, @@ -111,4 +127,5 @@ def harvest_cmd( container_path=container_path, remote_name=remote, repo=repo, + source_branch=source_branch, ) diff --git a/src/paude/git_remote/__init__.py b/src/paude/git_remote/__init__.py index ed168fc..c6c117e 100644 --- a/src/paude/git_remote/__init__.py +++ b/src/paude/git_remote/__init__.py @@ -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, @@ -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", diff --git a/src/paude/git_remote/utils.py b/src/paude/git_remote/utils.py index fd77aca..d7fb66c 100644 --- a/src/paude/git_remote/utils.py +++ b/src/paude/git_remote/utils.py @@ -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, @@ -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) @@ -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, diff --git a/src/paude/workflow.py b/src/paude/workflow.py index e73a489..1ac9a66 100644 --- a/src/paude/workflow.py +++ b/src/paude/workflow.py @@ -72,9 +72,115 @@ def _remote_targets_container_path(remote_url: str, container_path: str) -> bool paude ext:: remotes; treat those as a match so a manually configured remote isn't blocked. """ - if "%S " not in remote_url: - return True - return remote_url.rsplit(" ", 1)[-1] == container_path + parsed = _parse_paude_remote_url(remote_url) + if parsed is None: + return "%S" not in remote_url + return parsed[1] == container_path + + +def _parse_paude_remote_url(remote_url: str) -> tuple[str, str] | None: + """Return the container name and path encoded in a paude ext URL.""" + if not remote_url.startswith("ext::"): + return None + + command, marker, container_path = remote_url[6:].partition(" %S ") + if not marker or not container_path: + return None + + try: + command_parts = shlex.split(command) + exec_index = command_parts.index("exec") + except ValueError: + return None + + container_index = exec_index + 1 + while container_index < len(command_parts) and command_parts[ + container_index + ].startswith("-"): + container_index += 1 + if container_index >= len(command_parts): + return None + return command_parts[container_index], container_path + + +def _session_remote_candidates( + session_name: str, + cwd: Path, + container_path: str | None, +) -> list[tuple[str, str]]: + """Find current-repository ext remotes targeting a session.""" + from paude.git_remote import list_git_remotes + + candidates: list[tuple[str, str]] = [] + for remote_name, remote_url in list_git_remotes(cwd=cwd): + parsed = _parse_paude_remote_url(remote_url) + if parsed is None or parsed[0] != resource_name(session_name): + continue + if container_path is None or parsed[1] == container_path: + candidates.append((remote_name, parsed[1])) + return candidates + + +def _current_git_repo() -> Path | None: + """Return the repository containing the current working directory.""" + current = Path.cwd().resolve() + for candidate in (current, *current.parents): + if (candidate / ".git").exists(): + return candidate + return None + + +def _resolve_harvest_target( + session_name: str, + session: Session, + remote_name: str | None, + container_path: str | None, + repo: str | None, +) -> tuple[Path, str | None, str]: + """Resolve host repo, remote, and container path for harvest.""" + from paude.git_remote import git_remote_exists, git_remote_get_url + + current_repo = _current_git_repo() + workspace = Path(repo).expanduser().resolve() if repo else session.workspace + selected_remote = remote_name + selected_path = container_path + + if not repo and current_repo is not None: + if remote_name and git_remote_exists(remote_name, cwd=current_repo): + workspace = current_repo + elif remote_name is None: + candidates = _session_remote_candidates( + session_name, current_repo, container_path + ) + if len(candidates) > 1: + details = ", ".join( + f"{name} ({path})" for name, path in candidates + ) + typer.echo( + f"Error: Multiple remotes in the current repository target " + f"session '{session_name}': {details}.", + err=True, + ) + typer.echo( + " Use --remote to choose one, or --repo to choose a checkout.", + err=True, + ) + raise typer.Exit(1) + if candidates: + workspace = current_repo + selected_remote, selected_path = candidates[0] + + if selected_path is None: + candidate_name = selected_remote or resource_name(session_name) + if (workspace / ".git").exists() and git_remote_exists( + candidate_name, cwd=workspace + ): + remote_url = git_remote_get_url(candidate_name, cwd=workspace) + parsed = _parse_paude_remote_url(remote_url or "") + if parsed is not None: + selected_path = parsed[1] + + return workspace, selected_remote, selected_path or CONTAINER_WORKSPACE def _ensure_remote_exists( @@ -193,31 +299,52 @@ def _get_container_branch( def harvest_session( session_name: str, - branch_name: str, + branch_name: str | None = None, create_pr: bool = False, pr_title: str | None = None, - container_path: str = CONTAINER_WORKSPACE, + container_path: str | None = None, remote_name: str | None = None, repo: str | None = None, + source_branch: str | None = None, ) -> None: """Harvest changes from a running session into a local branch. + ``source_branch`` selects a branch or ref in the container without requiring + it to be checked out. When omitted, the checked-out container branch is + used. ``branch_name`` defaults to the source branch when one is supplied. ``container_path`` selects which repo path inside the container to harvest from, ``remote_name`` the git remote to use, and ``repo`` the host repo to - harvest into. All default to the single-repo behavior (the session's - ``/pvc/workspace`` and recorded host workspace). + harvest into. Existing matching remotes in the current host checkout can + supply the latter two defaults. """ from paude.git_remote import git_diff_stat, git_fetch_from_remote + if branch_name is None: + if source_branch is None: + typer.echo( + "Error: --branch is required unless --from is supplied.", err=True + ) + raise typer.Exit(1) + if source_branch.startswith("refs/heads/"): + branch_name = source_branch.removeprefix("refs/heads/") + else: + branch_name = source_branch.removeprefix("refs/") + _validate_harvest_branch(branch_name) backend_type, backend, session = _find_backend_and_session(session_name) - workspace = Path(repo).expanduser().resolve() if repo else session.workspace - if not (workspace / ".git").is_dir(): + workspace, remote_name, container_path = _resolve_harvest_target( + session_name, + session, + remote_name, + container_path, + repo, + ) + if not (workspace / ".git").exists(): typer.echo( f"Error: Workspace '{workspace}' is not a git repository " - f"(missing or no .git directory).", + f"(missing .git entry).", err=True, ) raise typer.Exit(1) @@ -232,15 +359,27 @@ def harvest_session( cwd=workspace, ) - container_branch = _get_container_branch(backend, session_name, container_path) - typer.echo(f"Container is on branch '{container_branch}'.", err=True) + if source_branch is None: + source_ref = _get_container_branch(backend, session_name, container_path) + typer.echo(f"Container is on branch '{source_ref}'.", err=True) + else: + source_ref = source_branch + typer.echo(f"Using container source ref '{source_branch}'.", err=True) + + if source_ref.startswith("refs/"): + fetch_ref = source_ref + else: + fetch_ref = f"refs/heads/{source_ref}" + remote_ref = "FETCH_HEAD" typer.echo(f"Fetching from '{remote_name}'...", err=True) - if not git_fetch_from_remote(remote_name, cwd=workspace): + fetch_succeeded = git_fetch_from_remote( + remote_name, cwd=workspace, source_ref=fetch_ref + ) + if not fetch_succeeded: typer.echo("Error: Failed to fetch from remote.", err=True) raise typer.Exit(1) - remote_ref = f"{remote_name}/{container_branch}" typer.echo(f"Resetting '{branch_name}' to '{remote_ref}'...", err=True) result = subprocess.run( ["git", "checkout", "-B", branch_name, remote_ref], @@ -250,7 +389,8 @@ def harvest_session( ) if result.returncode != 0: typer.echo( - f"Error: Failed to reset branch: {result.stderr.strip()}", + f"Error: Failed to harvest source ref '{source_ref}' from " + f"remote '{remote_name}': {result.stderr.strip()}", err=True, ) raise typer.Exit(1) diff --git a/tests/test_cli.py b/tests/test_cli.py index e1e1ba5..6042e0c 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -1121,19 +1121,36 @@ def test_harvest_passes_new_flags(self, mock_harvest): container_path="/pvc/workspace/rigs/vllm", remote_name="rig-vllm", repo="/host/vllm", + source_branch=None, ) @patch("paude.workflow.harvest_session") def test_harvest_defaults_preserved(self, mock_harvest): - """Omitting the new flags keeps the single-repo defaults.""" + """Omitting the new flags lets workflow apply single-repo defaults.""" result = runner.invoke(app, ["harvest", "my-session", "-b", "fix/foo"]) assert result.exit_code == 0 _args, kwargs = mock_harvest.call_args - assert kwargs["container_path"] == "/pvc/workspace" + assert kwargs["container_path"] is None assert kwargs["remote_name"] is None assert kwargs["repo"] is None + @patch("paude.workflow.harvest_session") + def test_harvest_source_defaults_branch(self, mock_harvest): + result = runner.invoke(app, ["harvest", "my-session", "--from", "feature/foo"]) + + assert result.exit_code == 0 + mock_harvest.assert_called_once_with( + session_name="my-session", + branch_name=None, + create_pr=False, + pr_title=None, + container_path=None, + remote_name=None, + repo=None, + source_branch="feature/foo", + ) + def test_subcommand_runs_without_main_execution(): """Subcommands run without triggering main execution logic.""" diff --git a/tests/test_git_remote.py b/tests/test_git_remote.py index 401080f..71727c3 100644 --- a/tests/test_git_remote.py +++ b/tests/test_git_remote.py @@ -26,6 +26,7 @@ initialize_container_workspace, is_ext_protocol_allowed, is_git_repository, + list_git_remotes, list_paude_remotes, podman_exec_builder, resolve_local_git_identity, @@ -243,6 +244,25 @@ def test_git_remote_fails(self, mock_run) -> None: assert remotes == [] +class TestListGitRemotes: + """Tests for listing arbitrary remotes used by harvest inference.""" + + @patch("paude.git_remote.subprocess.run") + def test_preserves_ext_url_path_and_custom_names(self, mock_run) -> None: + mock_run.return_value.returncode = 0 + mock_run.return_value.stdout = ( + "api\text::podman exec -i paude-session %S /pvc/repos/api (fetch)\n" + "api\text::podman exec -i paude-session %S /pvc/repos/api (push)\n" + ) + + assert list_git_remotes() == [ + ( + "api", + "ext::podman exec -i paude-session %S /pvc/repos/api", + ) + ] + + class TestIsGitRepository: """Tests for is_git_repository.""" @@ -779,6 +799,24 @@ def test_fetch_with_cwd(self, mock_run) -> None: assert mock_run.call_args[1]["cwd"] == Path("/tmp/workspace") + @patch("paude.git_remote.subprocess.run") + def test_fetch_specific_ref(self, mock_run) -> None: + """Append an explicit source ref to the fetch command.""" + mock_run.return_value.returncode = 0 + mock_run.return_value.stderr = "" + + result = git_fetch_from_remote( + "paude-test", source_ref="refs/tags/v1.0" + ) + + assert result is True + assert mock_run.call_args[0][0] == [ + "git", + "fetch", + "paude-test", + "refs/tags/v1.0", + ] + @patch("paude.git_remote.subprocess.run") def test_fetch_failure(self, mock_run) -> None: """Return False on failed fetch.""" diff --git a/tests/test_workflow.py b/tests/test_workflow.py index 5cb6e53..d52cf25 100644 --- a/tests/test_workflow.py +++ b/tests/test_workflow.py @@ -11,6 +11,7 @@ from paude.workflow import ( _get_container_branch, + _parse_paude_remote_url, _remote_targets_container_path, _validate_harvest_branch, _verify_container_repo, @@ -57,6 +58,23 @@ def test_uses_custom_container_path(self) -> None: assert cmd == ("git -C /pvc/workspace/rigs/vllm rev-parse --abbrev-ref HEAD") +class TestParsePaudeRemoteUrl: + """Tests for parsing session remotes.""" + + def test_parses_local_remote(self) -> None: + assert _parse_paude_remote_url( + "ext::podman exec -i paude-test %S /pvc/workspace/rigs/api" + ) == ("paude-test", "/pvc/workspace/rigs/api") + + def test_parses_ssh_remote(self) -> None: + assert _parse_paude_remote_url( + "ext::ssh user@host docker exec -i paude-test %S /pvc/workspace" + ) == ("paude-test", "/pvc/workspace") + + def test_rejects_non_paude_url(self) -> None: + assert _parse_paude_remote_url("https://github.com/example/repo") is None + + class TestValidateHarvestBranch: """Tests for _validate_harvest_branch.""" @@ -120,11 +138,243 @@ def test_harvest_success( harvest_session("test", "my-branch") mock_fetch.assert_called_once() - # Single checkout -B call - assert mock_run.call_count == 1 - args = mock_run.call_args[0][0] + # Remote inference lists the current repository before checkout. + assert mock_run.call_count == 2 + args = mock_run.call_args_list[-1][0][0] assert args[:3] == ["git", "checkout", "-B"] + @patch("paude.workflow.subprocess.run") + @patch("paude.git_remote.git_diff_stat") + @patch("paude.git_remote.git_fetch_from_remote") + @patch("paude.git_remote.git_remote_get_url") + @patch("paude.git_remote.git_remote_exists") + @patch("paude.cli.find_session_backend") + def test_harvests_explicit_source_and_defaults_destination( + self, + mock_find: MagicMock, + mock_exists: MagicMock, + mock_get_url: MagicMock, + mock_fetch: MagicMock, + mock_diff: MagicMock, + mock_run: MagicMock, + tmp_path: Path, + ) -> None: + backend = self._setup_mocks(mock_find, tmp_path) + mock_exists.return_value = True + mock_get_url.return_value = "ext::podman exec -i paude-test %S /pvc/workspace" + mock_fetch.return_value = True + mock_diff.return_value = "" + mock_run.return_value = CompletedProcess( + args=[], returncode=0, stdout="", stderr="" + ) + + harvest_session("test", source_branch="feature/foo") + + assert backend.exec_in_session.call_count == 1 + assert mock_run.call_args[0][0] == [ + "git", + "checkout", + "-B", + "feature/foo", + "FETCH_HEAD", + ] + mock_fetch.assert_called_once_with( + "paude-test", cwd=tmp_path, source_ref="refs/heads/feature/foo" + ) + + @pytest.mark.parametrize( + ("source_ref", "expected_branch"), + [ + ("refs/heads/feature/foo", "feature/foo"), + ("refs/tags/v1.0", "tags/v1.0"), + ("refs/pull/42/head", "pull/42/head"), + ], + ) + @patch("paude.workflow.subprocess.run") + @patch("paude.git_remote.git_diff_stat") + @patch("paude.git_remote.git_fetch_from_remote") + @patch("paude.git_remote.git_remote_get_url") + @patch("paude.git_remote.git_remote_exists") + @patch("paude.cli.find_session_backend") + def test_harvests_explicit_full_ref_via_fetch_head( + self, + mock_find: MagicMock, + mock_exists: MagicMock, + mock_get_url: MagicMock, + mock_fetch: MagicMock, + mock_diff: MagicMock, + mock_run: MagicMock, + tmp_path: Path, + source_ref: str, + expected_branch: str, + ) -> None: + self._setup_mocks(mock_find, tmp_path) + mock_exists.return_value = True + mock_get_url.return_value = "ext::podman exec -i paude-test %S /pvc/workspace" + mock_fetch.return_value = True + mock_diff.return_value = "" + mock_run.return_value = CompletedProcess( + args=[], returncode=0, stdout="", stderr="" + ) + + harvest_session("test", source_branch=source_ref) + + mock_fetch.assert_called_once_with( + "paude-test", cwd=tmp_path, source_ref=source_ref + ) + assert mock_run.call_args[0][0] == [ + "git", + "checkout", + "-B", + expected_branch, + "FETCH_HEAD", + ] + + @patch("paude.workflow.subprocess.run") + @patch("paude.git_remote.git_diff_stat") + @patch("paude.git_remote.git_fetch_from_remote") + @patch("paude.git_remote.git_remote_get_url") + @patch("paude.git_remote.git_remote_exists") + @patch("paude.cli.find_session_backend") + def test_branch_named_like_remote_is_not_misclassified( + self, + mock_find: MagicMock, + mock_exists: MagicMock, + mock_get_url: MagicMock, + mock_fetch: MagicMock, + mock_diff: MagicMock, + mock_run: MagicMock, + tmp_path: Path, + ) -> None: + self._setup_mocks(mock_find, tmp_path) + mock_exists.return_value = True + mock_get_url.return_value = "ext::podman exec -i paude-test %S /pvc/workspace" + mock_fetch.return_value = True + mock_diff.return_value = "" + mock_run.return_value = CompletedProcess( + args=[], returncode=0, stdout="", stderr="" + ) + + harvest_session("test", source_branch="paude-test/feature") + + mock_fetch.assert_called_once_with( + "paude-test", + cwd=tmp_path, + source_ref="refs/heads/paude-test/feature", + ) + assert mock_run.call_args[0][0][-1] == "FETCH_HEAD" + + @patch("paude.workflow.Path.cwd") + @patch("paude.workflow.subprocess.run") + @patch("paude.git_remote.list_git_remotes") + @patch("paude.git_remote.git_diff_stat") + @patch("paude.git_remote.git_fetch_from_remote") + @patch("paude.git_remote.git_remote_exists") + @patch("paude.cli.find_session_backend") + def test_infers_custom_remote_and_container_path_from_current_repo( + self, + mock_find: MagicMock, + mock_exists: MagicMock, + mock_fetch: MagicMock, + mock_diff: MagicMock, + mock_remotes: MagicMock, + mock_run: MagicMock, + mock_cwd: MagicMock, + tmp_path: Path, + ) -> None: + host_repo = tmp_path / "api" + (host_repo / "src").mkdir(parents=True) + (host_repo / ".git").mkdir() + mock_cwd.return_value = host_repo / "src" + backend = self._setup_mocks(mock_find, tmp_path) + backend.get_session.return_value.workspace = tmp_path / "recorded" + (tmp_path / "recorded" / ".git").mkdir(parents=True) + mock_remotes.return_value = [ + ( + "rig-api", + "ext::podman exec -i paude-test %S /pvc/workspace/rigs/api", + ) + ] + mock_exists.side_effect = lambda name, cwd=None: name == "rig-api" + mock_fetch.return_value = True + mock_diff.return_value = "" + mock_run.return_value = CompletedProcess( + args=[], returncode=0, stdout="", stderr="" + ) + + harvest_session("test", source_branch="feature/foo") + + assert backend.exec_in_session.call_args[0][1] == ( + "git -C /pvc/workspace/rigs/api rev-parse --is-inside-work-tree" + ) + mock_fetch.assert_called_once_with( + "rig-api", cwd=host_repo, source_ref="refs/heads/feature/foo" + ) + assert mock_run.call_args.kwargs["cwd"] == host_repo + + @patch("paude.workflow.subprocess.run") + @patch("paude.git_remote.git_diff_stat") + @patch("paude.git_remote.git_fetch_from_remote") + @patch("paude.git_remote.git_remote_get_url") + @patch("paude.git_remote.git_remote_exists") + @patch("paude.cli.find_session_backend") + def test_accepts_linked_git_worktree( + self, + mock_find: MagicMock, + mock_exists: MagicMock, + mock_get_url: MagicMock, + mock_fetch: MagicMock, + mock_diff: MagicMock, + mock_run: MagicMock, + tmp_path: Path, + ) -> None: + self._setup_mocks(mock_find, tmp_path) + (tmp_path / ".git").rmdir() + (tmp_path / ".git").write_text("gitdir: /tmp/common/worktrees/linked\n") + mock_exists.return_value = True + mock_get_url.return_value = "ext::podman exec -i paude-test %S /pvc/workspace" + mock_fetch.return_value = True + mock_diff.return_value = "" + mock_run.return_value = CompletedProcess( + args=[], returncode=0, stdout="", stderr="" + ) + + harvest_session("test", source_branch="feature/foo") + + assert mock_run.call_args[0][0][-1] == "FETCH_HEAD" + + @patch("paude.workflow.Path.cwd") + @patch("paude.git_remote.list_git_remotes") + @patch("paude.git_remote.git_remote_exists") + @patch("paude.cli.find_session_backend") + def test_rejects_ambiguous_current_repo_remotes( + self, + mock_find: MagicMock, + mock_exists: MagicMock, + mock_remotes: MagicMock, + mock_cwd: MagicMock, + tmp_path: Path, + capsys: pytest.CaptureFixture[str], + ) -> None: + host_repo = tmp_path / "api" + (host_repo / ".git").mkdir(parents=True) + mock_cwd.return_value = host_repo + self._setup_mocks(mock_find, tmp_path) + mock_find.return_value[1].get_session.return_value.workspace = ( + tmp_path / "recorded" + ) + (tmp_path / "recorded" / ".git").mkdir(parents=True) + mock_exists.return_value = False + mock_remotes.return_value = [ + ("api-one", "ext::podman exec -i paude-test %S /pvc/one"), + ("api-two", "ext::podman exec -i paude-test %S /pvc/two"), + ] + + with pytest.raises(click.exceptions.Exit): + harvest_session("test", source_branch="feature/foo") + + assert "Multiple remotes" in capsys.readouterr().err + @patch("paude.cli.find_session_backend") def test_harvest_session_not_found(self, mock_find: MagicMock) -> None: mock_find.return_value = None @@ -160,6 +410,10 @@ def test_harvest_fetch_failure( with pytest.raises(click.exceptions.Exit): harvest_session("test", "my-branch") + mock_fetch.assert_called_once_with( + "paude-test", cwd=tmp_path, source_ref="refs/heads/main" + ) + @patch("paude.workflow.subprocess.run") @patch("paude.git_remote.git_diff_stat") @patch("paude.git_remote.git_fetch_from_remote") @@ -212,6 +466,8 @@ def test_harvest_with_pr_uses_force_with_lease( mock_diff.return_value = " 2 files changed\n" # checkout -B, fetch origin, push, pr view mock_run.side_effect = [ + # Current-repository remote inference. + CompletedProcess(args=[], returncode=0, stdout="", stderr=""), CompletedProcess(args=[], returncode=0, stdout="", stderr=""), CompletedProcess(args=[], returncode=0, stdout="", stderr=""), CompletedProcess(args=[], returncode=0, stdout="", stderr=""), @@ -222,10 +478,10 @@ def test_harvest_with_pr_uses_force_with_lease( harvest_session("test", "my-branch", create_pr=True) - # fetch origin is call [1], push is call [2] - fetch_call = mock_run.call_args_list[1] + # fetch origin is call [2], push is call [3] + fetch_call = mock_run.call_args_list[2] assert fetch_call[0][0] == ["git", "fetch", "origin"] - push_call = mock_run.call_args_list[2] + push_call = mock_run.call_args_list[3] push_args = push_call[0][0] assert "--force-with-lease" in push_args @@ -253,6 +509,8 @@ def test_harvest_creates_pr_when_previous_merged( mock_diff.return_value = " 2 files changed\n" # checkout -B, fetch origin, push, pr list (no open PRs), pr create mock_run.side_effect = [ + # Current-repository remote inference. + CompletedProcess(args=[], returncode=0, stdout="", stderr=""), CompletedProcess(args=[], returncode=0, stdout="", stderr=""), CompletedProcess(args=[], returncode=0, stdout="", stderr=""), CompletedProcess(args=[], returncode=0, stdout="", stderr=""), @@ -263,7 +521,7 @@ def test_harvest_creates_pr_when_previous_merged( harvest_session("test", "my-branch", create_pr=True) # pr list returns empty → should call gh pr create - pr_create_call = mock_run.call_args_list[4] + pr_create_call = mock_run.call_args_list[5] pr_create_args = pr_create_call[0][0] assert pr_create_args[:3] == ["gh", "pr", "create"] @@ -414,14 +672,16 @@ def test_harvest_rig_path_remote_and_repo( # remote added under the custom name, in the --repo host checkout mock_remote_add.assert_called_once_with("rig-vllm", rig_url, cwd=host_repo) # fetch + checkout run in the host checkout, using the custom remote - mock_fetch.assert_called_once_with("rig-vllm", cwd=host_repo) + mock_fetch.assert_called_once_with( + "rig-vllm", cwd=host_repo, source_ref="refs/heads/main" + ) checkout_args = mock_run.call_args[0][0] assert checkout_args == [ "git", "checkout", "-B", "fix/foo", - "rig-vllm/main", + "FETCH_HEAD", ] assert mock_run.call_args.kwargs["cwd"] == host_repo @@ -586,8 +846,9 @@ def test_harvest_reuses_remote_matching_path( harvest_session("test", "my-branch", container_path="/pvc/ws/rig") - mock_fetch.assert_called_once_with("paude-test", cwd=tmp_path) - + mock_fetch.assert_called_once_with( + "paude-test", cwd=tmp_path, source_ref="refs/heads/main" + ) class TestVerifyContainerRepo: """Tests for _verify_container_repo.""" @@ -636,6 +897,16 @@ def test_non_paude_url_treated_as_match(self) -> None: "git@github.com:user/repo.git", "/pvc/workspace" ) + @pytest.mark.parametrize( + "remote_url", + [ + "ext::podman run -i paude-test %S /pvc/other", + "ext::podman exec -i 'paude-test %S /pvc/other", + ], + ) + def test_rejects_unparsable_url_with_path_marker(self, remote_url: str) -> None: + assert not _remote_targets_container_path(remote_url, "/pvc/workspace") + class TestStatusSessions: """Tests for status_sessions."""