diff --git a/CLAUDE.md b/CLAUDE.md index 6f59910be..cea617f9f 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -172,6 +172,7 @@ Publishing: `npm publish` (triggers `prepublishOnly` which builds UI, then publi - `auth.py` - Authentication error detection for Claude CLI - `env_constants.py` - Shared environment variable constants (API_ENV_VARS) used by client.py and chat sessions - `rate_limit_utils.py` - Rate limit detection, retry parsing, exponential backoff with jitter +- `git_sync.py` - Opt-in auto-push of the project's current branch to its Git remote after each agent session (`git_auto_push` setting) - `api/database.py` - SQLAlchemy models (Feature, Schedule, ScheduleOverride) - `api/dependency_resolver.py` - Cycle detection (Kahn's algorithm + DFS) and dependency validation - `api/migration.py` - JSON-to-SQLite migration utility @@ -201,7 +202,7 @@ The FastAPI server provides REST and WebSocket endpoints for the UI: - `terminal.py` - Interactive terminal I/O with PTY support (WebSocket bidirectional) - `devserver.py` - Dev server control (start/stop) and config - `schedules.py` - CRUD for time-based agent scheduling -- `settings.py` - Global settings management (model selection, YOLO, batch size, headless browser) +- `settings.py` - Global settings management (model selection, YOLO, batch size, headless browser, Git auto-push) **Services** (`server/services/`): - `process_manager.py` - Agent process lifecycle management diff --git a/README.md b/README.md index 0ad71c941..de01fc67c 100644 --- a/README.md +++ b/README.md @@ -332,6 +332,33 @@ When test progress increases, the agent sends: } ``` +### GitHub / Git Remote Integration + +The agent already commits its work to a local Git repository in the project folder. To keep a remote (e.g. GitHub) in sync, connect the repo once and turn on **Auto-push to Git Remote** in the Settings panel (gear icon). After every agent session, AutoForge pushes the project's *current branch* to its remote. + +Recommended workflow: + +```bash +cd /path/to/my-app + +# 1. One-time: create the GitHub repo from the local one and push it +gh repo create my-app --private --source . --push + +# 2. Before asking for new features: work on a branch instead of main +git checkout -b feature/new-dashboard + +# 3. Add features in the UI (Expand Project / Add Feature) and start the agent. +# With auto-push on, commits land on origin/feature/new-dashboard as they are made. + +# 4. Review and merge +gh pr create --fill +``` + +Notes: +- Auto-push is off by default and never creates repositories, remotes or branches on the host, and never force-pushes. +- If the branch has no upstream yet, it is pushed to `origin` with `--set-upstream`. +- Pushes are non-interactive: configure credentials beforehand (`gh auth login` sets up a Git credential helper, or use SSH keys). Failures such as missing remotes or rejected pushes are logged as `[git auto-push] skipped: ...` and never stop the agent. + ### Alternative API Providers (GLM, Ollama, Kimi, Custom) Alternative providers are configured via the **Settings UI** (gear icon > API Provider). Select your provider, set the base URL, auth token, and model directly in the UI — no `.env` changes needed. diff --git a/agent.py b/agent.py index 668391f7b..55775374f 100644 --- a/agent.py +++ b/agent.py @@ -23,6 +23,7 @@ sys.stderr = io.TextIOWrapper(sys.stderr.buffer, encoding="utf-8", errors="replace", line_buffering=True) from client import create_client +from git_sync import maybe_push_to_remote from progress import ( count_passing_tests, has_features, @@ -299,6 +300,10 @@ async def run_autonomous_agent( # Don't crash - return error status so the loop can retry status, response = "error", str(e) + # Optionally push the session's commits to the Git remote (opt-in setting) + if status == "continue": + await asyncio.to_thread(maybe_push_to_remote, project_dir) + # Check for project completion - EXIT when all features pass if "all features are passing" in response.lower() or "no more work to do" in response.lower(): print("\n" + "=" * 70) diff --git a/git_sync.py b/git_sync.py new file mode 100644 index 000000000..a79bb1ed0 --- /dev/null +++ b/git_sync.py @@ -0,0 +1,113 @@ +""" +Git Remote Sync +=============== + +Optional "auto-push" support: after an agent session finishes, push the +project's current branch to its Git remote so work shows up on GitHub (or any +other Git host) without manual intervention. + +The feature is opt-in via the global ``git_auto_push`` setting and is +deliberately conservative: + +- It never creates repositories, remotes or branches on the host. The user + sets up the remote once (e.g. ``gh repo create --source . --push``) and picks + the branch to work on (e.g. ``git checkout -b feature/x``). +- It never force-pushes. +- Failures (no remote, auth problems, network errors, rejected pushes) are + reported and swallowed so they can never break the agent loop. +""" + +import os +import subprocess +from pathlib import Path + +GIT_AUTO_PUSH_SETTING = "git_auto_push" +PUSH_TIMEOUT_SECONDS = 120 + + +def is_auto_push_enabled() -> bool: + """Return True when the global ``git_auto_push`` setting is on.""" + try: + from registry import get_setting + except Exception: + return False + return (get_setting(GIT_AUTO_PUSH_SETTING, "false") or "false").lower() == "true" + + +def _git(project_dir: Path, *args: str, timeout: int = 30) -> subprocess.CompletedProcess[str]: + env = os.environ.copy() + # Never block on an interactive credential prompt - fail fast instead. + env["GIT_TERMINAL_PROMPT"] = "0" + env.setdefault("GCM_INTERACTIVE", "never") + return subprocess.run( + ["git", *args], + cwd=project_dir, + capture_output=True, + text=True, + timeout=timeout, + env=env, + stdin=subprocess.DEVNULL, + ) + + +def push_to_remote(project_dir: Path) -> tuple[bool, str]: + """ + Push the project's current branch to its remote. + + If the branch already tracks an upstream, a plain ``git push`` is used. + Otherwise the branch is pushed to ``origin`` (or the only configured + remote) and set as upstream. + + Returns: + (success, message) - message is a human-readable summary for logs. + """ + try: + inside = _git(project_dir, "rev-parse", "--is-inside-work-tree") + if inside.returncode != 0 or inside.stdout.strip() != "true": + return False, "not a git repository" + + branch = _git(project_dir, "symbolic-ref", "--quiet", "--short", "HEAD") + if branch.returncode != 0 or not branch.stdout.strip(): + return False, "HEAD is detached; check out a branch to enable auto-push" + branch_name = branch.stdout.strip() + + has_commit = _git(project_dir, "rev-parse", "--verify", "--quiet", "HEAD") + if has_commit.returncode != 0: + return False, "no commits to push yet" + + upstream = _git(project_dir, "rev-parse", "--abbrev-ref", "--symbolic-full-name", "@{u}") + if upstream.returncode == 0 and upstream.stdout.strip(): + push_args = ["push"] + target = upstream.stdout.strip() + else: + remotes = [r for r in _git(project_dir, "remote").stdout.split() if r] + if not remotes: + return False, "no git remote configured (e.g. run: gh repo create --source . --push)" + remote = "origin" if "origin" in remotes else remotes[0] + if remote != "origin" and len(remotes) > 1: + return False, "multiple remotes and no 'origin'; set an upstream with git push -u" + push_args = ["push", "--set-upstream", remote, branch_name] + target = f"{remote}/{branch_name}" + + result = _git(project_dir, *push_args, timeout=PUSH_TIMEOUT_SECONDS) + if result.returncode != 0: + detail = (result.stderr or result.stdout).strip().splitlines() + return False, f"push to {target} failed: {detail[-1] if detail else 'unknown error'}" + return True, f"pushed {branch_name} to {target}" + except FileNotFoundError: + return False, "git executable not found" + except subprocess.TimeoutExpired: + return False, "git push timed out" + except Exception as e: # pragma: no cover - defensive, must never break the agent loop + return False, f"unexpected error: {e}" + + +def maybe_push_to_remote(project_dir: Path) -> None: + """Push to the remote if auto-push is enabled, printing the outcome.""" + if not is_auto_push_enabled(): + return + ok, message = push_to_remote(project_dir) + if ok: + print(f"[git auto-push] {message}", flush=True) + else: + print(f"[git auto-push] skipped: {message}", flush=True) diff --git a/package.json b/package.json index 9c547ddfc..c2c177555 100644 --- a/package.json +++ b/package.json @@ -29,6 +29,7 @@ "autonomous_agent_demo.py", "client.py", "env_constants.py", + "git_sync.py", "parallel_orchestrator.py", "progress.py", "prompts.py", diff --git a/server/routers/settings.py b/server/routers/settings.py index 9ff04acde..dcb9a557a 100644 --- a/server/routers/settings.py +++ b/server/routers/settings.py @@ -118,6 +118,7 @@ async def get_settings(): batch_size=_parse_int(all_settings.get("batch_size"), 3), testing_batch_size=_parse_int(all_settings.get("testing_batch_size"), 3), effort=get_effort_setting(), + git_auto_push=_parse_bool(all_settings.get("git_auto_push")), api_provider=api_provider, api_base_url=all_settings.get("api_base_url"), api_has_auth_token=bool(all_settings.get("api_auth_token")), @@ -149,6 +150,9 @@ async def update_settings(update: SettingsUpdate): if update.effort is not None: set_setting("effort", update.effort) + if update.git_auto_push is not None: + set_setting("git_auto_push", "true" if update.git_auto_push else "false") + # API provider settings if update.api_provider is not None: old_provider = get_setting("api_provider", "claude") @@ -190,6 +194,7 @@ async def update_settings(update: SettingsUpdate): batch_size=_parse_int(all_settings.get("batch_size"), 3), testing_batch_size=_parse_int(all_settings.get("testing_batch_size"), 3), effort=get_effort_setting(), + git_auto_push=_parse_bool(all_settings.get("git_auto_push")), api_provider=api_provider, api_base_url=all_settings.get("api_base_url"), api_has_auth_token=bool(all_settings.get("api_auth_token")), diff --git a/server/schemas.py b/server/schemas.py index d54f00aa9..aae863776 100644 --- a/server/schemas.py +++ b/server/schemas.py @@ -485,6 +485,7 @@ class SettingsResponse(BaseModel): batch_size: int = 3 # Features per coding agent batch (1-15) testing_batch_size: int = 3 # Features per testing agent batch (1-15) effort: Literal["low", "medium", "high", "xhigh", "max"] = "xhigh" + git_auto_push: bool = False # Push current branch to its Git remote after each agent session api_provider: str = "claude" api_base_url: str | None = None api_has_auth_token: bool = False # Never expose actual token @@ -506,6 +507,7 @@ class SettingsUpdate(BaseModel): batch_size: int | None = None # Features per agent batch (1-15) testing_batch_size: int | None = None # Features per testing agent batch (1-15) effort: Literal["low", "medium", "high", "xhigh", "max"] | None = None + git_auto_push: bool | None = None api_provider: str | None = None api_base_url: str | None = Field(None, max_length=500) api_auth_token: str | None = Field(None, max_length=500) # Write-only, never returned diff --git a/test_git_sync.py b/test_git_sync.py new file mode 100644 index 000000000..761e8c45a --- /dev/null +++ b/test_git_sync.py @@ -0,0 +1,126 @@ +""" +Tests for git_sync.py (opt-in auto-push to a Git remote). + +Uses real git repositories in temporary directories with a local bare repo +acting as the remote, so no network access is required. +""" + +import subprocess +from pathlib import Path +from unittest.mock import patch + +import pytest + +import git_sync +from git_sync import maybe_push_to_remote, push_to_remote + + +def _run(cwd: Path, *args: str) -> str: + return subprocess.run( + ["git", *args], cwd=cwd, check=True, capture_output=True, text=True + ).stdout.strip() + + +@pytest.fixture +def repo(tmp_path: Path) -> Path: + project = tmp_path / "project" + project.mkdir() + _run(project, "init", "-b", "main") + _run(project, "config", "user.email", "test@example.com") + _run(project, "config", "user.name", "Test") + _run(project, "config", "commit.gpgsign", "false") + return project + + +@pytest.fixture +def remote(tmp_path: Path) -> Path: + bare = tmp_path / "remote.git" + subprocess.run(["git", "init", "--bare", "-b", "main", str(bare)], check=True, capture_output=True) + return bare + + +def _commit(repo: Path, name: str = "file.txt") -> None: + (repo / name).write_text(name) + _run(repo, "add", name) + _run(repo, "commit", "-m", f"add {name}") + + +def test_not_a_git_repo(tmp_path: Path): + ok, msg = push_to_remote(tmp_path) + assert not ok + assert "not a git repository" in msg + + +def test_no_commits(repo: Path): + ok, msg = push_to_remote(repo) + assert not ok + assert "no commits" in msg + + +def test_no_remote(repo: Path): + _commit(repo) + ok, msg = push_to_remote(repo) + assert not ok + assert "no git remote" in msg + + +def test_detached_head(repo: Path, remote: Path): + _commit(repo) + _run(repo, "remote", "add", "origin", str(remote)) + _run(repo, "checkout", "--detach") + ok, msg = push_to_remote(repo) + assert not ok + assert "detached" in msg + + +def test_pushes_and_sets_upstream(repo: Path, remote: Path): + _commit(repo) + _run(repo, "remote", "add", "origin", str(remote)) + _run(repo, "checkout", "-b", "feature/x") + + ok, msg = push_to_remote(repo) + assert ok, msg + assert "origin/feature/x" in msg + assert _run(repo, "rev-parse", "--abbrev-ref", "@{u}") == "origin/feature/x" + assert _run(remote, "rev-parse", "feature/x") == _run(repo, "rev-parse", "HEAD") + + # Subsequent commits use the existing upstream + _commit(repo, "second.txt") + ok, msg = push_to_remote(repo) + assert ok, msg + assert _run(remote, "rev-parse", "feature/x") == _run(repo, "rev-parse", "HEAD") + + +def test_rejected_push_is_reported_not_raised(repo: Path, remote: Path, tmp_path: Path): + _commit(repo) + _run(repo, "remote", "add", "origin", str(remote)) + assert push_to_remote(repo)[0] + + # Someone else pushes a diverging commit to the remote + other = tmp_path / "other" + subprocess.run(["git", "clone", str(remote), str(other)], check=True, capture_output=True) + _run(other, "config", "user.email", "o@example.com") + _run(other, "config", "user.name", "Other") + _run(other, "config", "commit.gpgsign", "false") + _commit(other, "other.txt") + _run(other, "push") + + _commit(repo, "local.txt") + ok, msg = push_to_remote(repo) + assert not ok + assert "failed" in msg + + +def test_maybe_push_respects_setting(repo: Path, remote: Path): + _commit(repo) + _run(repo, "remote", "add", "origin", str(remote)) + + with patch.object(git_sync, "is_auto_push_enabled", return_value=False): + maybe_push_to_remote(repo) + assert subprocess.run( + ["git", "rev-parse", "--verify", "main"], cwd=remote, capture_output=True + ).returncode != 0 + + with patch.object(git_sync, "is_auto_push_enabled", return_value=True): + maybe_push_to_remote(repo) + assert _run(remote, "rev-parse", "main") == _run(repo, "rev-parse", "HEAD") diff --git a/ui/src/components/SettingsModal.tsx b/ui/src/components/SettingsModal.tsx index 5605f4e63..765306516 100644 --- a/ui/src/components/SettingsModal.tsx +++ b/ui/src/components/SettingsModal.tsx @@ -46,6 +46,12 @@ export function SettingsModal({ isOpen, onClose }: SettingsModalProps) { } } + const handleGitAutoPushToggle = () => { + if (settings && !updateSettings.isPending) { + updateSettings.mutate({ git_auto_push: !settings.git_auto_push }) + } + } + const handleModelChange = (modelId: string) => { if (!updateSettings.isPending) { updateSettings.mutate({ api_model: modelId }) @@ -436,6 +442,24 @@ export function SettingsModal({ isOpen, onClose }: SettingsModalProps) { /> + {/* Git Auto-Push Toggle */} +
+
+ +

+ Push the project's current branch to its remote after each agent session +

+
+ +
+ {/* Regression Agents */}
diff --git a/ui/src/components/views/SettingsView.tsx b/ui/src/components/views/SettingsView.tsx index f22ec0344..b5aa3eb5f 100644 --- a/ui/src/components/views/SettingsView.tsx +++ b/ui/src/components/views/SettingsView.tsx @@ -44,6 +44,12 @@ export function SettingsView() { } } + const handleGitAutoPushToggle = () => { + if (settings && !updateSettings.isPending) { + updateSettings.mutate({ git_auto_push: !settings.git_auto_push }) + } + } + const handleModelChange = (modelId: string) => { if (!updateSettings.isPending) { updateSettings.mutate({ api_model: modelId }) @@ -427,6 +433,24 @@ export function SettingsView() { />
+ {/* Git Auto-Push Toggle */} +
+
+ +

+ Push the project's current branch to its remote after each agent session +

+
+ +
+ {/* Reasoning Effort */}
diff --git a/ui/src/hooks/useProjects.ts b/ui/src/hooks/useProjects.ts index 88da9c0c4..f7898cfab 100644 --- a/ui/src/hooks/useProjects.ts +++ b/ui/src/hooks/useProjects.ts @@ -304,6 +304,7 @@ const DEFAULT_SETTINGS: Settings = { batch_size: 3, testing_batch_size: 3, effort: 'xhigh', + git_auto_push: false, api_provider: 'claude', api_base_url: null, api_has_auth_token: false, diff --git a/ui/src/lib/types.ts b/ui/src/lib/types.ts index b2ff61aa2..17706fbf5 100644 --- a/ui/src/lib/types.ts +++ b/ui/src/lib/types.ts @@ -654,6 +654,7 @@ export interface Settings { batch_size: number // Features per coding agent batch (1-15) testing_batch_size: number // Features per testing agent batch (1-15) effort: EffortLevel // Reasoning effort passed to Claude Code (--effort) + git_auto_push: boolean // Push current branch to its Git remote after each agent session api_provider: string api_base_url: string | null api_has_auth_token: boolean @@ -667,6 +668,7 @@ export interface SettingsUpdate { batch_size?: number testing_batch_size?: number effort?: EffortLevel + git_auto_push?: boolean api_provider?: string api_base_url?: string api_auth_token?: string