# -*- coding: utf-8 -*- """Integration tests for /api/workspace/git/* (Sprint 4.3). Target router: src/pineagents/app/routers/git.py (11 routes, 0 cov) Backing: real ``git`` binary via ``_git()`` shelling out; the endpoints operate on ``get_coding_dir(workspace)`` which, for the default agent, resolves to ``/workspaces/default``. Coverage strategy (happy path first): - GET /status auto-initialises a git repo (with an initial commit) on first call, so it doubles as our fixture: one call gives us a real repo + a HEAD commit to exercise log / commit-diff / revert / diff. - Seed files directly into the default workspace dir (the app subprocess re-reads the working tree on every request) and then drive the full stage -> commit -> log -> commit-diff happy path over HTTP. - branch listing + checkout -b create. - 400 error branches (empty commit message, nothing staged, bad branch, path traversal, bad commit hash). Isolation note: app_server is module-scoped and all git endpoints act on the SINGLE default-agent repo, so tests share one working tree. Each test seeds files with a UNIQUE name (``integ-git--*.txt``) and scopes its assertions to those files, so ordering / cross-test bleed does not matter. ``_ensure_repo`` makes the first /status call idempotent. No LLM / external network deps required (clone is intentionally not covered here — it needs a real remote). """ from __future__ import annotations from pathlib import Path import pytest from helpers import default_http_timeout _HTTP_TIMEOUT = default_http_timeout(15.0) _GIT = "/api/workspace/git" # ================================================================== # # helpers # ================================================================== # def _workspace_dir(app_server) -> Path: """Default agent's coding dir (== workspace_dir when unset).""" return app_server.working_dir / "workspaces" / "default" def _ensure_repo(app_server) -> None: """Trigger auto-init by hitting GET /status once; assert 200.""" resp = app_server.api_request( "GET", f"{_GIT}/status", timeout=_HTTP_TIMEOUT, ) assert resp.status_code == 200, app_server.logs_tail() def _seed_file(app_server, name: str, content: str = "hello\n") -> str: """Write a file into the default workspace; return its rel path.""" ws = _workspace_dir(app_server) ws.mkdir(parents=True, exist_ok=True) (ws / name).write_text(content, encoding="utf-8") return name def _status(app_server) -> dict: resp = app_server.api_request( "GET", f"{_GIT}/status", timeout=_HTTP_TIMEOUT, ) assert resp.status_code == 200, app_server.logs_tail() return resp.json() def _paths_in_status(status: dict) -> set[str]: return {c["path"] for c in status.get("changes", [])} # ================================================================== # # A — status / auto-init (happy path, P0) # ================================================================== # @pytest.mark.integration @pytest.mark.p0 def test_status_auto_inits_repo(app_server) -> None: """GET /status on a fresh workspace auto-inits a repo. Test flow: 1. GET /status (first call triggers auto-init + initial commit). 2. Assert 200 and a non-empty branch name. API endpoints: - GET /api/workspace/git/status """ status = _status(app_server) assert isinstance(status.get("branch"), str) assert status["branch"] != "", status assert isinstance(status.get("changes"), list) @pytest.mark.integration @pytest.mark.p1 def test_status_shows_untracked_file(app_server) -> None: """A newly seeded file shows up as an untracked change. Test flow: 1. Ensure repo, seed a unique untracked file. 2. GET /status; assert the file appears with status '?'. API endpoints: - GET /api/workspace/git/status """ _ensure_repo(app_server) name = _seed_file(app_server, "integ-git-untracked-A.txt") status = _status(app_server) assert name in _paths_in_status(status), status entry = next(c for c in status["changes"] if c["path"] == name) assert entry["status"] == "?", entry assert entry["staged"] is False, entry # ================================================================== # # B — stage / unstage / commit roundtrip (happy path, P0/P1) # ================================================================== # @pytest.mark.integration @pytest.mark.p1 def test_stage_marks_file_staged(app_server) -> None: """POST /stage moves a file into the staged set. Test flow: 1. Seed a unique file. 2. POST /stage {paths:[file]} -> 200 {staged:[file]}. 3. GET /status; the file now has staged=True. API endpoints: - POST /api/workspace/git/stage - GET /api/workspace/git/status """ _ensure_repo(app_server) name = _seed_file(app_server, "integ-git-stage-B.txt") resp = app_server.api_request( "POST", f"{_GIT}/stage", json={"paths": [name]}, timeout=_HTTP_TIMEOUT, ) assert resp.status_code == 200, app_server.logs_tail() assert resp.json()["staged"] == [name] status = _status(app_server) staged = {c["path"] for c in status["changes"] if c["staged"]} assert name in staged, status @pytest.mark.integration @pytest.mark.p1 def test_unstage_reverts_staging(app_server) -> None: """POST /unstage moves a staged file back to untracked. Test flow: 1. Seed + stage a unique file. 2. POST /unstage {paths:[file]} -> 200 {unstaged:[file]}. 3. GET /status; the file is no longer staged. API endpoints: - POST /api/workspace/git/unstage """ _ensure_repo(app_server) name = _seed_file(app_server, "integ-git-unstage-B.txt") app_server.api_request( "POST", f"{_GIT}/stage", json={"paths": [name]}, timeout=_HTTP_TIMEOUT, ) resp = app_server.api_request( "POST", f"{_GIT}/unstage", json={"paths": [name]}, timeout=_HTTP_TIMEOUT, ) assert resp.status_code == 200, app_server.logs_tail() assert resp.json()["unstaged"] == [name] status = _status(app_server) staged = {c["path"] for c in status["changes"] if c["staged"]} assert name not in staged, status @pytest.mark.integration @pytest.mark.p0 def test_stage_commit_roundtrip(app_server) -> None: """Seed -> stage -> commit produces a commit visible in the log. Test flow: 1. Seed a unique file, stage it. 2. POST /commit {message} -> 200 {committed:True}. 3. GET /log; the commit message is present. 4. GET /status; the file is no longer listed as a change. API endpoints: - POST /api/workspace/git/stage - POST /api/workspace/git/commit - GET /api/workspace/git/log """ _ensure_repo(app_server) name = _seed_file(app_server, "integ-git-commit-B.txt", "commit me\n") app_server.api_request( "POST", f"{_GIT}/stage", json={"paths": [name]}, timeout=_HTTP_TIMEOUT, ) msg = "integ-git: commit B roundtrip" resp = app_server.api_request( "POST", f"{_GIT}/commit", json={"message": msg}, timeout=_HTTP_TIMEOUT, ) assert resp.status_code == 200, app_server.logs_tail() assert resp.json()["committed"] is True log_resp = app_server.api_request( "GET", f"{_GIT}/log", timeout=_HTTP_TIMEOUT, ) assert log_resp.status_code == 200, app_server.logs_tail() messages = [c["message"] for c in log_resp.json()] assert msg in messages, messages # committed file no longer a pending change assert name not in _paths_in_status(_status(app_server)) # ================================================================== # # C — diff (happy path, P1) # ================================================================== # @pytest.mark.integration @pytest.mark.p1 def test_diff_untracked_file_shows_content(app_server) -> None: """GET /diff?untracked=true returns the new file's content. Test flow: 1. Seed a unique untracked file with known content. 2. GET /diff?path=&untracked=true -> 200; diff contains the seeded content. API endpoints: - GET /api/workspace/git/diff """ _ensure_repo(app_server) marker = "integ-diff-marker-untracked\n" name = _seed_file(app_server, "integ-git-diff-C.txt", marker) resp = app_server.api_request( "GET", f"{_GIT}/diff", params={"path": name, "untracked": "true"}, timeout=_HTTP_TIMEOUT, ) assert resp.status_code == 200, app_server.logs_tail() assert marker.strip() in resp.json()["diff"], resp.json() @pytest.mark.integration @pytest.mark.p1 def test_diff_staged_shows_content(app_server) -> None: """GET /diff?staged=true returns staged content. Test flow: 1. Seed + stage a unique file with known content. 2. GET /diff?staged=true -> 200; diff contains the content. API endpoints: - GET /api/workspace/git/diff """ _ensure_repo(app_server) marker = "integ-diff-marker-staged\n" name = _seed_file(app_server, "integ-git-diffstaged-C.txt", marker) app_server.api_request( "POST", f"{_GIT}/stage", json={"paths": [name]}, timeout=_HTTP_TIMEOUT, ) resp = app_server.api_request( "GET", f"{_GIT}/diff", params={"staged": "true"}, timeout=_HTTP_TIMEOUT, ) assert resp.status_code == 200, app_server.logs_tail() assert marker.strip() in resp.json()["diff"], resp.json() # ================================================================== # # D — log / commit-diff (happy path, P1) # ================================================================== # @pytest.mark.integration @pytest.mark.p1 def test_commit_diff_by_hash(app_server) -> None: """GET /commit-diff returns the patch for a known commit. Test flow: 1. Seed + stage + commit a unique file. 2. GET /log; take the newest commit hash. 3. GET /commit-diff?commit_hash= -> 200; diff mentions the file and the returned hash echoes the request. API endpoints: - GET /api/workspace/git/log - GET /api/workspace/git/commit-diff """ _ensure_repo(app_server) name = _seed_file(app_server, "integ-git-cdiff-D.txt", "patch body\n") app_server.api_request( "POST", f"{_GIT}/stage", json={"paths": [name]}, timeout=_HTTP_TIMEOUT, ) app_server.api_request( "POST", f"{_GIT}/commit", json={"message": "integ-git: cdiff D"}, timeout=_HTTP_TIMEOUT, ) log = app_server.api_request( "GET", f"{_GIT}/log", timeout=_HTTP_TIMEOUT, ).json() assert log, "log empty after commit" commit_hash = log[0]["hash"] resp = app_server.api_request( "GET", f"{_GIT}/commit-diff", params={"commit_hash": commit_hash}, timeout=_HTTP_TIMEOUT, ) assert resp.status_code == 200, app_server.logs_tail() body = resp.json() assert body["hash"] == commit_hash assert name in body["diff"], body # ================================================================== # # E — branches / checkout (happy path, P1) # ================================================================== # @pytest.mark.integration @pytest.mark.p1 def test_branches_lists_current(app_server) -> None: """GET /branches returns a list with exactly one current branch. Test flow: 1. Ensure repo. 2. GET /branches -> 200; a list where one entry has current=True. API endpoints: - GET /api/workspace/git/branches """ _ensure_repo(app_server) resp = app_server.api_request( "GET", f"{_GIT}/branches", timeout=_HTTP_TIMEOUT, ) assert resp.status_code == 200, app_server.logs_tail() branches = resp.json() assert isinstance(branches, list) and branches, branches current = [b for b in branches if b["current"]] assert len(current) == 1, branches @pytest.mark.integration @pytest.mark.p1 def test_checkout_creates_new_branch(app_server) -> None: """POST /checkout with create=True creates and switches branch. Test flow: 1. Ensure repo. 2. POST /checkout {branch, create:True} -> 200 {branch}. 3. GET /branches; the new branch exists and is current. API endpoints: - POST /api/workspace/git/checkout - GET /api/workspace/git/branches """ _ensure_repo(app_server) branch = "integ-git-feature-E" resp = app_server.api_request( "POST", f"{_GIT}/checkout", json={"branch": branch, "create": True}, timeout=_HTTP_TIMEOUT, ) assert resp.status_code == 200, app_server.logs_tail() assert resp.json()["branch"] == branch branches = app_server.api_request( "GET", f"{_GIT}/branches", timeout=_HTTP_TIMEOUT, ).json() current = [b["name"] for b in branches if b["current"]] assert current == [branch], branches # ================================================================== # # F — error branches (400) — contract supplements # ================================================================== # @pytest.mark.integration @pytest.mark.p2 def test_commit_empty_message_returns_400(app_server) -> None: """POST /commit with a blank message -> 400. API endpoints: - POST /api/workspace/git/commit """ _ensure_repo(app_server) resp = app_server.api_request( "POST", f"{_GIT}/commit", json={"message": " "}, timeout=_HTTP_TIMEOUT, ) assert resp.status_code == 400, app_server.logs_tail() assert "empty" in resp.json()["detail"].lower() @pytest.mark.integration @pytest.mark.p2 def test_checkout_nonexistent_branch_returns_400(app_server) -> None: """POST /checkout to a missing branch (create=False) -> 400. API endpoints: - POST /api/workspace/git/checkout """ _ensure_repo(app_server) resp = app_server.api_request( "POST", f"{_GIT}/checkout", json={"branch": "integ-git-does-not-exist-F", "create": False}, timeout=_HTTP_TIMEOUT, ) assert resp.status_code == 400, app_server.logs_tail() @pytest.mark.integration @pytest.mark.p2 def test_diff_path_traversal_returns_400(app_server) -> None: """GET /diff with a traversal path -> 400 path traversal. API endpoints: - GET /api/workspace/git/diff """ _ensure_repo(app_server) resp = app_server.api_request( "GET", f"{_GIT}/diff", params={"path": "../../etc/passwd"}, timeout=_HTTP_TIMEOUT, ) assert resp.status_code == 400, app_server.logs_tail() assert "traversal" in resp.json()["detail"].lower() @pytest.mark.integration @pytest.mark.p2 def test_commit_diff_bad_hash_returns_400(app_server) -> None: """GET /commit-diff with an unknown hash -> 400. API endpoints: - GET /api/workspace/git/commit-diff """ _ensure_repo(app_server) resp = app_server.api_request( "GET", f"{_GIT}/commit-diff", params={"commit_hash": "deadbeefdeadbeef"}, timeout=_HTTP_TIMEOUT, ) assert resp.status_code == 400, app_server.logs_tail()