From 851808910b2fcf22242edbddf1ecbdcd09b3b43a Mon Sep 17 00:00:00 2001 From: Harshit Sharma <66710144+harshitethic@users.noreply.github.com> Date: Wed, 23 Sep 2026 17:24:50 +0530 Subject: [PATCH 1/3] feat: add explicit workspace commit API --- backend/app/main.py | 92 +++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 92 insertions(+) diff --git a/backend/app/main.py b/backend/app/main.py index 359c165..7dfbf24 100644 --- a/backend/app/main.py +++ b/backend/app/main.py @@ -49,6 +49,18 @@ class CommandRequest(BaseModel): command: str = Field(..., min_length=1, max_length=1000) +class CommitRequest(BaseModel): + workspace_id: str + message: str = Field(..., min_length=1, max_length=200) + author_name: str = Field(default="PatchPilot", min_length=1, max_length=100) + author_email: str = Field( + default="patchpilot@localhost", + min_length=3, + max_length=254, + pattern=r"^[^@\s]+@[^@\s]+$", + ) + + class IssueRequest(BaseModel): repo: str = Field(..., pattern=r"^[A-Za-z0-9_.-]+/[A-Za-z0-9_.-]+$") issue_number: int = Field(..., ge=1) @@ -275,6 +287,74 @@ def workspace_repo(workspace_id: str) -> Path: return repo +def commit_workspace( + repo: Path, + message: str, + author_name: str, + author_email: str, +) -> dict[str, str]: + message = message.strip() + author_name = author_name.strip() + author_email = author_email.strip() + if not message: + raise HTTPException(400, "Commit message cannot be blank") + if not author_name: + raise HTTPException(400, "Commit author name cannot be blank") + + code, status = run(["git", "status", "--porcelain"], repo, 30) + if code != 0: + raise HTTPException(500, f"Could not inspect workspace status: {status[-1500:]}") + if not status.strip(): + raise HTTPException(400, "No workspace changes to commit") + + code, output = run(["git", "add", "--all"], repo, 60) + if code != 0: + raise HTTPException(500, f"Could not stage workspace changes: {output[-1500:]}") + + staged_code, staged_output = run( + ["git", "diff", "--cached", "--quiet"], + repo, + 30, + ) + if staged_code == 0: + raise HTTPException(400, "No workspace changes to commit") + if staged_code != 1: + raise HTTPException( + 500, + f"Could not inspect staged changes: {staged_output[-1500:]}", + ) + + commit_code, commit_output = run( + [ + "git", + "-c", + f"user.name={author_name}", + "-c", + f"user.email={author_email}", + "commit", + "-m", + message, + ], + repo, + 120, + ) + if commit_code != 0: + raise HTTPException(500, f"Git commit failed: {commit_output[-2000:]}") + + sha_code, commit_sha = run(["git", "rev-parse", "HEAD"], repo, 30) + branch_code, branch = run(["git", "branch", "--show-current"], repo, 30) + if sha_code != 0 or branch_code != 0: + raise HTTPException(500, "Commit succeeded but Git metadata could not be read") + + return { + "commit_sha": commit_sha.strip(), + "branch": branch.strip(), + "message": message, + "author_name": author_name, + "author_email": author_email, + } + + def github_headers() -> dict[str, str]: token = os.getenv("GITHUB_TOKEN", "") if not token: @@ -413,6 +493,17 @@ async def workspace(req: WorkspaceRequest) -> dict[str, Any]: return {"workspace_id": req.workspace_id, "files": list_files(repo), "diff": build_patch(repo)} +@app.post("/api/commit") +async def commit(req: CommitRequest) -> dict[str, str]: + repo = workspace_repo(req.workspace_id) + return commit_workspace( + repo, + req.message, + req.author_name, + req.author_email, + ) + + @app.get("/api/openapi-summary") async def openapi_summary() -> dict[str, Any]: return { @@ -425,6 +516,7 @@ async def openapi_summary() -> dict[str, Any]: "test execution", "repair loop", "isolated branches", + "workspace commits", "GitHub issue import", ], } From 120da014f2e08fbfc6c401fbec9f4a65cd53c6f8 Mon Sep 17 00:00:00 2001 From: Harshit Sharma <66710144+harshitethic@users.noreply.github.com> Date: Wed, 23 Sep 2026 17:25:06 +0530 Subject: [PATCH 2/3] test: cover workspace commit workflow --- backend/tests/test_main.py | 80 ++++++++++++++++++++++++++++++++++++++ 1 file changed, 80 insertions(+) diff --git a/backend/tests/test_main.py b/backend/tests/test_main.py index c20cdb5..d3132bc 100644 --- a/backend/tests/test_main.py +++ b/backend/tests/test_main.py @@ -1,5 +1,6 @@ import asyncio import os +import subprocess import tempfile import unittest from pathlib import Path @@ -9,8 +10,10 @@ from pydantic import ValidationError from app.main import ( + CommitRequest, RunRequest, apply_edits, + commit_workspace, github_headers, health, parse_json_object, @@ -123,6 +126,83 @@ def test_workspace_repo_returns_existing_repo_inside_root(self) -> None: with patch("app.main.WORKSPACES", root): self.assertEqual(workspace_repo("safe-id"), repo.resolve()) + def test_commit_request_rejects_invalid_author_email(self) -> None: + with self.assertRaises(ValidationError): + CommitRequest( + workspace_id="safe-id", + message="feat: test", + author_email="not-an-email", + ) + + def test_commit_workspace_creates_real_commit_without_global_git_config(self) -> None: + with tempfile.TemporaryDirectory() as temp_dir: + repo = Path(temp_dir) + subprocess.run( + ["git", "init", "-b", "main"], + cwd=repo, + check=True, + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + text=True, + ) + (repo / "feature.txt").write_text("hello\n", encoding="utf-8") + + result = commit_workspace( + repo, + "feat: add feature file", + "PatchPilot Test", + "patchpilot-test@example.com", + ) + + self.assertEqual(result["branch"], "main") + self.assertEqual(result["message"], "feat: add feature file") + self.assertEqual(len(result["commit_sha"]), 40) + + log = subprocess.run( + ["git", "show", "-s", "--format=%an|%ae|%s", "HEAD"], + cwd=repo, + check=True, + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + text=True, + ).stdout.strip() + self.assertEqual( + log, + "PatchPilot Test|patchpilot-test@example.com|feat: add feature file", + ) + status = subprocess.run( + ["git", "status", "--porcelain"], + cwd=repo, + check=True, + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + text=True, + ).stdout + self.assertEqual(status, "") + + def test_commit_workspace_rejects_clean_repository(self) -> None: + with tempfile.TemporaryDirectory() as temp_dir: + repo = Path(temp_dir) + subprocess.run( + ["git", "init", "-b", "main"], + cwd=repo, + check=True, + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + text=True, + ) + + with self.assertRaises(HTTPException) as context: + commit_workspace( + repo, + "feat: empty", + "PatchPilot", + "patchpilot@localhost", + ) + + self.assertEqual(context.exception.status_code, 400) + self.assertIn("No workspace changes", context.exception.detail) + def test_github_headers_requires_token(self) -> None: with patch.dict(os.environ, {}, clear=True): with self.assertRaises(HTTPException) as context: From dcfe2e60a6ef8e1abbd405111460845482bacd16 Mon Sep 17 00:00:00 2001 From: Harshit Sharma <66710144+harshitethic@users.noreply.github.com> Date: Wed, 23 Sep 2026 17:25:23 +0530 Subject: [PATCH 3/3] docs: document workspace commit workflow --- README.md | 22 ++++++++++++++++++++-- 1 file changed, 20 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index 5fcd3a5..ce45c4c 100644 --- a/README.md +++ b/README.md @@ -62,6 +62,7 @@ It is designed to sit **between an issue and a pull request** โ€” with the devel - ๐Ÿงช Automatic test-command detection and execution - ๐Ÿ” Limited repair loop when tests fail - ๐ŸŒฟ **Automatic isolated Git branch per run** +- โœ… **Explicit workspace commit API with per-commit author identity** - ๐Ÿ™ **GitHub issue import via API** - ๐Ÿ“ฆ Isolated per-run workspaces - ๐Ÿ” Reviewable Git diffs @@ -122,7 +123,23 @@ You can provide a branch name in the run request: If omitted, PatchPilot generates a unique `patchpilot/...` branch name automatically. -**Important:** the current version creates the branch only inside PatchPilot's cloned workspace. It does **not** push the branch to GitHub yet. Remote commits and pull requests remain future roadmap items. +PatchPilot can also turn the reviewed workspace diff into a local Git commit: + +```http +POST /api/commit +Content-Type: application/json + +{ + "workspace_id": "", + "message": "fix: handle expired sessions", + "author_name": "PatchPilot", + "author_email": "patchpilot@localhost" +} +``` + +The endpoint stages the workspace with `git add --all`, refuses clean workspaces, creates the commit without modifying global Git identity, and returns the commit SHA and branch. + +**Important:** branches and commits still exist only inside PatchPilot's cloned workspace. PatchPilot does **not** push to GitHub yet, so remote pull requests remain future roadmap work. --- @@ -184,6 +201,7 @@ Use the planning stage as a fast first pass over an unfamiliar codebase before m | `/api/run` | POST | Clone, plan, edit, test, repair, and return a diff | | `/api/execute` | POST | Execute a command inside an existing workspace | | `/api/workspace` | POST | Inspect workspace files and current diff | +| `/api/commit` | POST | Stage and commit the current workspace changes | | `/api/github/issue` | GET | Fetch a GitHub issue | | `/api/import-issue` | POST | Convert a GitHub issue into an agent task | | `/api/openapi-summary` | GET | Return PatchPilot feature metadata | @@ -412,7 +430,7 @@ The project roadmap intentionally includes a stronger sandbox for this reason. - [x] GitHub issue import - [x] Automatic isolated branches -- [ ] Commit changes from the agent +- [x] Commit changes from the agent - [ ] Open pull requests automatically ### Next