From 0827548fd57269d3283ecd40fe02890a5ad7282b Mon Sep 17 00:00:00 2001 From: kina0630 Date: Wed, 13 May 2026 21:23:28 +0800 Subject: [PATCH 1/2] feat: use git source for remote run --- .../docs/remote-sandbox-production.md | 18 +- coding-agent/src/coding_agent/__main__.py | 143 ++++++- .../src/coding_agent/remote/client.py | 14 +- coding-agent/tests/cli/test_remote_client.py | 367 +++++++++++++++++- 4 files changed, 522 insertions(+), 20 deletions(-) diff --git a/coding-agent/docs/remote-sandbox-production.md b/coding-agent/docs/remote-sandbox-production.md index b1379edd4..c7c37eaf0 100644 --- a/coding-agent/docs/remote-sandbox-production.md +++ b/coding-agent/docs/remote-sandbox-production.md @@ -205,9 +205,11 @@ Use `remote run` for the recommended one-shot remote workflow: coding-agent remote run team --repo . --goal "fix the failing test" ``` -`remote run --repo` uploads a local snapshot, streams one prompt, leaves the -remote session open, and prints follow-up commands for inspecting or exporting -the result. It does not overwrite the local checkout by default. +`remote run --repo` uses a Git-backed workspace when the local checkout is +clean, has `remote.origin.url`, is on a named branch, and `HEAD` is available +from `origin`. It streams one prompt, leaves the remote session open, and prints +follow-up commands for inspecting, exporting, or publishing the result. It does +not overwrite the local checkout by default. Set the remote tool approval policy explicitly when automation cannot answer approval prompts: @@ -248,12 +250,18 @@ on existing Git credential helper state. GitHub PR creation is not part of this slice. Snapshot sessions can still be inspected and exported, but they are not branch -publication inputs because snapshots intentionally exclude `.git`. +publication inputs because snapshots intentionally exclude `.git`. Use +`--snapshot-fallback` for local-only repos, dirty worktrees, detached HEADs, or +unpublished local commits: + +```bash +coding-agent remote run team --repo . --snapshot-fallback --goal "try this local tree" +``` Use `--download` only when you want the old archive-overwrite fallback: ```bash -coding-agent remote run team --repo . --goal "fix the failing test" --download +coding-agent remote run team --repo . --snapshot-fallback --goal "fix the failing test" --download ``` `--download` fetches the final workspace through the archive manifest/archive diff --git a/coding-agent/src/coding_agent/__main__.py b/coding-agent/src/coding_agent/__main__.py index 7f6812764..713717889 100644 --- a/coding-agent/src/coding_agent/__main__.py +++ b/coding-agent/src/coding_agent/__main__.py @@ -4,6 +4,7 @@ import os import shlex +import subprocess import sys from pathlib import Path from collections.abc import Mapping @@ -583,7 +584,7 @@ def remote_repl( @click.option( "--repo", default=None, - help="Upload a local workspace snapshot. Results stay remote unless --download is passed.", + help="Use a clean Git repo as the remote workspace source. Results stay remote unless --download is passed.", ) @click.option( "--empty-workspace", @@ -618,6 +619,11 @@ def remote_repl( is_flag=True, help="Download and overwrite --repo after the remote run completes.", ) +@click.option( + "--snapshot-fallback", + is_flag=True, + help="Use archive upload for local-only or dirty repos instead of Git-backed publication input.", +) def remote_run( name: str, repo: str | None, @@ -627,6 +633,7 @@ def remote_run( approval_policy: str, yes: bool, download_results: bool, + snapshot_fallback: bool, ) -> None: """Create a one-shot remote run and stream one prompt.""" _remote_run_once( @@ -639,6 +646,7 @@ def remote_run( yes=yes, download_results=download_results, cleanup_on_success=download_results, + snapshot_fallback=snapshot_fallback, ) @@ -653,6 +661,7 @@ def _remote_run_once( yes: bool, download_results: bool, cleanup_on_success: bool, + snapshot_fallback: bool = True, ) -> None: from coding_agent.remote.client import ( auth_headers, @@ -667,11 +676,11 @@ def _remote_run_once( if repo is not None and empty_workspace: raise click.ClickException( - "Pass either --repo to upload a workspace snapshot or --empty-workspace, not both." + "Pass either --repo to use a local repo or --empty-workspace, not both." ) if repo is None and not empty_workspace: raise click.ClickException( - "Pass --repo to upload a workspace snapshot or --empty-workspace to create a blank remote workspace." + "Pass --repo to use a local repo or --empty-workspace to create a blank remote workspace." ) if download_results and repo is None: raise click.ClickException("Pass --repo with --download.") @@ -680,20 +689,28 @@ def _remote_run_once( headers = auth_headers(endpoint) repo_path: Path | None = None snapshot_archive_base64: str | None = None + workspace_source: dict[str, object] | None = None if repo is not None: repo_path = Path(repo).expanduser().resolve() if not repo_path.is_dir(): raise click.ClickException(f"--repo must be an existing directory: {repo}") - try: - snapshot_archive_base64 = create_workspace_archive_base64(repo_path) - except ValueError as exc: - raise click.ClickException(str(exc)) from exc + if snapshot_fallback: + try: + snapshot_archive_base64 = create_workspace_archive_base64(repo_path) + except ValueError as exc: + raise click.ClickException(str(exc)) from exc + else: + workspace_source = _git_workspace_source_for_remote_run( + repo_path, + runtime_profile=runtime_profile, + ) session_id = create_remote_session( endpoint, snapshot_archive_base64=snapshot_archive_base64, + workspace_source=workspace_source, approval_policy=approval_policy, - runtime_profile=runtime_profile, + runtime_profile=None if workspace_source is not None else runtime_profile, ) click.echo(f"Created one-shot remote session {session_id} on remote {name}") status: int | None = None @@ -785,6 +802,10 @@ def _remote_run_once( remote_name=name, session_id=session_id, repo_path=repo_path, + can_publish_branch=( + workspace_source is not None + and workspace_source.get("kind") == "git" + ), ) if stream_error is not None: raise stream_error @@ -798,11 +819,99 @@ def _format_cli_command(args: list[str]) -> str: return " ".join(shlex.quote(arg) for arg in args) +def _git_workspace_source_for_remote_run( + repo_path: Path, + *, + runtime_profile: str | None, +) -> dict[str, object]: + try: + top_level = _run_git_for_remote_source( + repo_path, ["rev-parse", "--show-toplevel"] + ) + if Path(top_level).resolve() != repo_path: + raise click.ClickException( + "--repo must point at the Git worktree root for Git-backed remote runs." + ) + status = _run_git_for_remote_source(repo_path, ["status", "--porcelain=v1"]) + if status: + raise click.ClickException( + "remote run --repo requires a clean Git working tree. " + "Commit, stash, or pass --snapshot-fallback to upload an archive instead." + ) + remote_url = _run_git_for_remote_source( + repo_path, ["config", "--get", "remote.origin.url"] + ) + if not remote_url: + raise click.ClickException( + "remote run --repo requires remote.origin.url. " + "Pass --snapshot-fallback for local-only repositories." + ) + base_ref = _run_git_for_remote_source( + repo_path, ["rev-parse", "--abbrev-ref", "HEAD"] + ) + if base_ref == "HEAD": + raise click.ClickException( + "remote run --repo requires a named Git branch. " + "Pass --snapshot-fallback for detached HEAD worktrees." + ) + base_sha = _run_git_for_remote_source(repo_path, ["rev-parse", "HEAD"]) + _run_git_for_remote_source( + repo_path, + ["merge-base", "--is-ancestor", "HEAD", f"refs/remotes/origin/{base_ref}"], + ) + except click.ClickException: + raise + except subprocess.CalledProcessError as exc: + message = _git_remote_source_error_message(exc) + raise click.ClickException(message) from exc + workspace_source: dict[str, object] = { + "kind": "git", + "remote_url": remote_url, + "base_ref": base_ref, + "base_sha": base_sha, + } + if runtime_profile is not None: + workspace_source["runtime_profile"] = runtime_profile + return workspace_source + + +def _run_git_for_remote_source(repo_path: Path, args: list[str]) -> str: + result = subprocess.run( + ["git", "-C", str(repo_path), *args], + shell=False, + capture_output=True, + text=True, + timeout=30, + check=True, + stdin=subprocess.DEVNULL, + ) + return result.stdout.strip() + + +def _git_remote_source_error_message(exc: subprocess.CalledProcessError) -> str: + stderr = exc.stderr.strip() if isinstance(exc.stderr, str) else "" + command = " ".join(str(part) for part in exc.cmd) + if "config --get remote.origin.url" in command: + return ( + "remote run --repo requires remote.origin.url. " + "Pass --snapshot-fallback for local-only repositories." + ) + if "merge-base" in command: + return ( + "remote run --repo requires HEAD to be available from origin. " + "Push the branch or pass --snapshot-fallback to upload an archive instead." + ) + if stderr: + return f"Git-backed remote run setup failed: {stderr}" + return "Git-backed remote run setup failed. Pass --snapshot-fallback to upload an archive instead." + + def _print_remote_result_next_steps( *, remote_name: str, session_id: str, repo_path: Path | None, + can_publish_branch: bool, ) -> None: click.echo(f"Remote session {session_id} left open for result inspection.") click.echo( @@ -835,6 +944,24 @@ def _print_remote_result_next_steps( ] ) ) + if can_publish_branch: + click.echo( + "Publish review branch: " + + _format_cli_command( + [ + "python", + "-m", + "coding_agent", + "remote", + "publish", + remote_name, + "--session", + session_id, + "--branch", + f"coding-agent/session-{session_id}", + ] + ) + ) if repo_path is not None: click.echo( "Fallback archive download: " diff --git a/coding-agent/src/coding_agent/remote/client.py b/coding-agent/src/coding_agent/remote/client.py index 1fcf74403..780c4acd0 100644 --- a/coding-agent/src/coding_agent/remote/client.py +++ b/coding-agent/src/coding_agent/remote/client.py @@ -126,16 +126,24 @@ def create_remote_session( endpoint: RemoteEndpoint, *, snapshot_archive_base64: str | None = None, + workspace_source: dict[str, object] | None = None, approval_policy: str = "auto", runtime_profile: str | None = None, ) -> str: if approval_policy not in APPROVAL_POLICIES: raise click.ClickException(f"Unsupported approval policy: {approval_policy}") - workspace_source: dict[str, object] = {"kind": "docker"} + if workspace_source is not None and snapshot_archive_base64 is not None: + raise click.ClickException( + "Pass either workspace_source or snapshot_archive_base64, not both." + ) + if workspace_source is None: + workspace_source = {"kind": "docker"} + if snapshot_archive_base64 is not None: + workspace_source["snapshot_archive_base64"] = snapshot_archive_base64 + else: + workspace_source = dict(workspace_source) if runtime_profile is not None: workspace_source["runtime_profile"] = runtime_profile - if snapshot_archive_base64 is not None: - workspace_source["snapshot_archive_base64"] = snapshot_archive_base64 payload: dict[str, object] = { "workspace_source": workspace_source, "approval_policy": approval_policy, diff --git a/coding-agent/tests/cli/test_remote_client.py b/coding-agent/tests/cli/test_remote_client.py index d2f4a7f62..01d27e814 100644 --- a/coding-agent/tests/cli/test_remote_client.py +++ b/coding-agent/tests/cli/test_remote_client.py @@ -4,6 +4,7 @@ import os import shlex import stat +import subprocess from pathlib import Path import httpx @@ -14,6 +15,117 @@ from coding_agent.__main__ import main +def test_create_remote_session_sends_git_workspace_source( + monkeypatch: pytest.MonkeyPatch, +) -> None: + from coding_agent.remote.client import RemoteEndpoint, create_remote_session + + calls: list[tuple[str, dict[str, object] | None, dict[str, str]]] = [] + + class FakeResponse: + def raise_for_status(self) -> None: + return None + + def json(self) -> dict[str, object]: + return {"session_id": "sess-git"} + + class FakeClient: + def __init__( + self, + *, + base_url: str, + headers: dict[str, str] | None = None, + timeout: float, + ) -> None: + assert base_url == "http://agent.example" + assert timeout == 60.0 + self.headers = headers or {} + + def __enter__(self) -> FakeClient: + return self + + def __exit__(self, exc_type: object, exc: object, tb: object) -> None: + return None + + def post( + self, path: str, json: dict[str, object] | None = None + ) -> FakeResponse: + calls.append((path, json, self.headers)) + return FakeResponse() + + monkeypatch.setattr("coding_agent.remote.client.httpx.Client", FakeClient) + + session_id = create_remote_session( + RemoteEndpoint("dev", "http://agent.example", "secret-token"), + workspace_source={ + "kind": "git", + "remote_url": "https://github.com/org/repo.git", + "base_ref": "main", + "base_sha": "abc123", + }, + approval_policy="auto", + runtime_profile="universal", + ) + + assert session_id == "sess-git" + assert calls == [ + ( + "/sessions", + { + "workspace_source": { + "kind": "git", + "remote_url": "https://github.com/org/repo.git", + "base_ref": "main", + "base_sha": "abc123", + "runtime_profile": "universal", + }, + "approval_policy": "auto", + }, + {"Authorization": "Bearer secret-token"}, + ) + ] + + +def _run_git(repo_path: Path, args: list[str]) -> str: + result = subprocess.run( + ["git", "-C", str(repo_path), *args], + shell=False, + capture_output=True, + text=True, + check=True, + stdin=subprocess.DEVNULL, + ) + return result.stdout.strip() + + +def _init_clean_git_repo_with_origin(tmp_path: Path) -> tuple[Path, str]: + repo_path = tmp_path / "repo" + remote_path = tmp_path / "remote.git" + remote_path.mkdir() + _ = subprocess.run( + ["git", "init", "--bare", str(remote_path)], + shell=False, + capture_output=True, + text=True, + check=True, + stdin=subprocess.DEVNULL, + ) + repo_path.mkdir() + _run_git(repo_path, ["init", "-b", "main"]) + _run_git(repo_path, ["config", "user.name", "Test User"]) + _run_git(repo_path, ["config", "user.email", "test@example.com"]) + (repo_path / "README.md").write_text("# repo\n", encoding="utf-8") + _run_git(repo_path, ["add", "README.md"]) + _run_git(repo_path, ["commit", "-m", "initial"]) + _run_git(repo_path, ["remote", "add", "origin", remote_path.as_uri()]) + _run_git(repo_path, ["push", "-u", "origin", "main"]) + _run_git( + repo_path, ["remote", "set-url", "origin", "https://github.com/org/repo.git"] + ) + head_sha = _run_git(repo_path, ["rev-parse", "HEAD"]) + return repo_path, head_sha + + class _RemoteFakeResponse: def __init__(self, payload: dict[str, object]) -> None: self._payload = payload @@ -380,6 +492,7 @@ def fake_stream_prompt( assert "Remote session sess-run left open for result inspection." in result.output assert "coding_agent remote diff dev --session sess-run" in result.output assert "coding_agent remote patch dev --session sess-run" in result.output + assert "coding_agent remote publish dev" not in result.output assert "coding_agent remote sessions close dev sess-run" in result.output assert calls == [ ( @@ -934,10 +1047,7 @@ def test_remote_repl_requires_repo_or_empty_workspace( ) assert result.exit_code != 0 - assert ( - "Pass --repo to upload a workspace snapshot or --empty-workspace" - in result.output - ) + assert "Pass --repo to use a local repo or --empty-workspace" in result.output def test_remote_diff_prints_changed_files(tmp_path: Path, monkeypatch) -> None: @@ -1388,6 +1498,7 @@ def fake_stream_prompt( "dev", "--repo", str(repo_path), + "--snapshot-fallback", "--goal", "hello", ], @@ -1398,6 +1509,7 @@ def fake_stream_prompt( assert "Remote session sess-run left open for result inspection." in result.output assert "coding_agent remote diff dev --session sess-run" in result.output assert "coding_agent remote patch dev --session sess-run" in result.output + assert "coding_agent remote publish dev" not in result.output assert "coding_agent remote download dev --session sess-run --repo" in result.output assert "Downloaded remote workspace snapshot" not in result.output assert calls == [ @@ -1423,6 +1535,252 @@ def fake_stream_prompt( assert applied == [] +def test_remote_run_clean_git_repo_uses_git_workspace_source( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + config_path = tmp_path / "remotes.json" + repo_path, head_sha = _init_clean_git_repo_with_origin(tmp_path) + remote_url = _run_git(repo_path, ["config", "--get", "remote.origin.url"]) + monkeypatch.setenv("CODING_AGENT_REMOTES_FILE", str(config_path)) + runner = CliRunner() + runner.invoke( + main, + ["remote", "add", "dev", "http://agent.example", "--token", "secret-token"], + catch_exceptions=False, + ) + calls: list[tuple[str, str, dict[str, object] | None, dict[str, str]]] = [] + + class FakeResponse: + def __init__(self, payload: dict[str, object]) -> None: + self._payload = payload + + def raise_for_status(self) -> None: + return None + + def json(self) -> dict[str, object]: + return self._payload + + class FakeClient: + def __init__(self, **kwargs: object) -> None: + headers = kwargs.get("headers") + self.headers = headers if isinstance(headers, dict) else {} + + def __enter__(self) -> FakeClient: + return self + + def __exit__(self, exc_type: object, exc: object, tb: object) -> None: + return None + + def post( + self, path: str, json: dict[str, object] | None = None + ) -> FakeResponse: + calls.append(("post", path, json, self.headers)) + if path == "/sessions": + return FakeResponse({"session_id": "sess-git"}) + raise AssertionError(f"unexpected post {path}") + + def fake_stream_prompt( + *, base_url: str, session_id: str, prompt: str, headers: dict[str, str] + ) -> int: + calls.append( + ( + "stream", + f"/sessions/{session_id}/prompt", + {"prompt": prompt, "base_url": base_url}, + headers, + ) + ) + return 0 + + def fail_archive(path: Path) -> str: + raise AssertionError(f"unexpected snapshot archive for {path}") + + monkeypatch.setattr("coding_agent.remote.client.httpx.Client", FakeClient) + monkeypatch.setattr("coding_agent.remote.client.stream_prompt", fake_stream_prompt) + monkeypatch.setattr( + "coding_agent.workspace_archive.create_workspace_archive_base64", + fail_archive, + ) + + result = runner.invoke( + main, + [ + "remote", + "run", + "dev", + "--repo", + str(repo_path), + "--runtime", + "universal", + "--goal", + "hello", + ], + catch_exceptions=False, + ) + + assert result.exit_code == 0 + assert ( + "coding_agent remote publish dev --session sess-git --branch coding-agent/session-sess-git" + in result.output + ) + assert calls == [ + ( + "post", + "/sessions", + { + "workspace_source": { + "kind": "git", + "remote_url": remote_url, + "base_ref": "main", + "base_sha": head_sha, + "runtime_profile": "universal", + }, + "approval_policy": "auto", + }, + {"Authorization": "Bearer secret-token"}, + ), + ( + "stream", + "/sessions/sess-git/prompt", + {"prompt": "hello", "base_url": "http://agent.example"}, + {"Authorization": "Bearer secret-token"}, + ), + ] + + +def test_remote_run_dirty_git_repo_requires_explicit_snapshot_fallback( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + config_path = tmp_path / "remotes.json" + repo_path, _ = _init_clean_git_repo_with_origin(tmp_path) + (repo_path / "README.md").write_text("# changed\n", encoding="utf-8") + monkeypatch.setenv("CODING_AGENT_REMOTES_FILE", str(config_path)) + runner = CliRunner() + runner.invoke( + main, + ["remote", "add", "dev", "http://agent.example", "--token", "secret-token"], + catch_exceptions=False, + ) + + class FailingClient: + def __init__(self, **kwargs: object) -> None: + del kwargs + raise AssertionError("remote run should fail before creating a session") + + monkeypatch.setattr("coding_agent.remote.client.httpx.Client", FailingClient) + + result = runner.invoke( + main, + [ + "remote", + "run", + "dev", + "--repo", + str(repo_path), + "--goal", + "hello", + ], + ) + + assert result.exit_code != 0 + assert "remote run --repo requires a clean Git working tree" in result.output + assert "--snapshot-fallback" in result.output + + +def test_remote_run_snapshot_fallback_uploads_archive_for_local_only_repo( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + config_path = tmp_path / "remotes.json" + repo_path = tmp_path / "repo" + repo_path.mkdir() + monkeypatch.setenv("CODING_AGENT_REMOTES_FILE", str(config_path)) + runner = CliRunner() + runner.invoke( + main, + ["remote", "add", "dev", "http://agent.example", "--token", "secret-token"], + catch_exceptions=False, + ) + calls: list[tuple[str, str, dict[str, object] | None, dict[str, str]]] = [] + + class FakeResponse: + def __init__(self, payload: dict[str, object]) -> None: + self._payload = payload + + def raise_for_status(self) -> None: + return None + + def json(self) -> dict[str, object]: + return self._payload + + class FakeClient: + def __init__(self, **kwargs: object) -> None: + headers = kwargs.get("headers") + self.headers = headers if isinstance(headers, dict) else {} + + def __enter__(self) -> FakeClient: + return self + + def __exit__(self, exc_type: object, exc: object, tb: object) -> None: + return None + + def post( + self, path: str, json: dict[str, object] | None = None + ) -> FakeResponse: + calls.append(("post", path, json, self.headers)) + if path == "/sessions": + return FakeResponse({"session_id": "sess-snapshot"}) + raise AssertionError(f"unexpected post {path}") + + def fake_stream_prompt( + *, base_url: str, session_id: str, prompt: str, headers: dict[str, str] + ) -> int: + calls.append( + ( + "stream", + f"/sessions/{session_id}/prompt", + {"prompt": prompt, "base_url": base_url}, + headers, + ) + ) + return 0 + + monkeypatch.setattr("coding_agent.remote.client.httpx.Client", FakeClient) + monkeypatch.setattr("coding_agent.remote.client.stream_prompt", fake_stream_prompt) + monkeypatch.setattr( + "coding_agent.workspace_archive.create_workspace_archive_base64", + lambda path: "archive-encoded", + ) + + result = runner.invoke( + main, + [ + "remote", + "run", + "dev", + "--repo", + str(repo_path), + "--snapshot-fallback", + "--goal", + "hello", + ], + catch_exceptions=False, + ) + + assert result.exit_code == 0 + assert calls[0] == ( + "post", + "/sessions", + { + "workspace_source": { + "kind": "docker", + "snapshot_archive_base64": "archive-encoded", + }, + "approval_policy": "auto", + }, + {"Authorization": "Bearer secret-token"}, + ) + + def test_remote_run_with_download_keeps_explicit_archive_overwrite( tmp_path: Path, monkeypatch ) -> None: @@ -1526,6 +1884,7 @@ def fake_download_workspace_manifest( "dev", "--repo", str(repo_path), + "--snapshot-fallback", "--goal", "hello", "--download", From eb5bc34da0b12279a57536a9a8d9f948da7fdaa9 Mon Sep 17 00:00:00 2001 From: kina0630 Date: Wed, 13 May 2026 21:45:03 +0800 Subject: [PATCH 2/2] fix: report missing git for remote run --- coding-agent/src/coding_agent/__main__.py | 23 ++++++---- coding-agent/tests/cli/test_remote_client.py | 45 ++++++++++++++++++++ 2 files changed, 59 insertions(+), 9 deletions(-) diff --git a/coding-agent/src/coding_agent/__main__.py b/coding-agent/src/coding_agent/__main__.py index 713717889..eb6e19b9d 100644 --- a/coding-agent/src/coding_agent/__main__.py +++ b/coding-agent/src/coding_agent/__main__.py @@ -876,15 +876,20 @@ def _git_workspace_source_for_remote_run( def _run_git_for_remote_source(repo_path: Path, args: list[str]) -> str: - result = subprocess.run( - ["git", "-C", str(repo_path), *args], - shell=False, - capture_output=True, - text=True, - timeout=30, - check=True, - stdin=subprocess.DEVNULL, - ) + try: + result = subprocess.run( + ["git", "-C", str(repo_path), *args], + shell=False, + capture_output=True, + text=True, + timeout=30, + check=True, + stdin=subprocess.DEVNULL, + ) + except FileNotFoundError as exc: + raise click.ClickException( + "git not found; please install git or adjust PATH." + ) from exc return result.stdout.strip() diff --git a/coding-agent/tests/cli/test_remote_client.py b/coding-agent/tests/cli/test_remote_client.py index 01d27e814..915c71b94 100644 --- a/coding-agent/tests/cli/test_remote_client.py +++ b/coding-agent/tests/cli/test_remote_client.py @@ -1687,6 +1687,51 @@ def __init__(self, **kwargs: object) -> None: assert "--snapshot-fallback" in result.output +def test_remote_run_reports_missing_git_for_git_backed_repo( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + config_path = tmp_path / "remotes.json" + repo_path = tmp_path / "repo" + repo_path.mkdir() + monkeypatch.setenv("CODING_AGENT_REMOTES_FILE", str(config_path)) + runner = CliRunner() + runner.invoke( + main, + ["remote", "add", "dev", "http://agent.example", "--token", "secret-token"], + catch_exceptions=False, + ) + + class FailingClient: + def __init__(self, **kwargs: object) -> None: + del kwargs + raise AssertionError("remote run should fail before creating a session") + + def raise_missing_git( + *args: object, **kwargs: object + ) -> subprocess.CompletedProcess[str]: + del args, kwargs + raise FileNotFoundError("git") + + monkeypatch.setattr("coding_agent.remote.client.httpx.Client", FailingClient) + monkeypatch.setattr(subprocess, "run", raise_missing_git) + + result = runner.invoke( + main, + [ + "remote", + "run", + "dev", + "--repo", + str(repo_path), + "--goal", + "hello", + ], + ) + + assert result.exit_code != 0 + assert "git not found" in result.output + + def test_remote_run_snapshot_fallback_uploads_archive_for_local_only_repo( tmp_path: Path, monkeypatch: pytest.MonkeyPatch ) -> None: