Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down
27 changes: 27 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
5 changes: 5 additions & 0 deletions agent.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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)
Expand Down
113 changes: 113 additions & 0 deletions git_sync.py
Original file line number Diff line number Diff line change
@@ -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)
1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,7 @@
"autonomous_agent_demo.py",
"client.py",
"env_constants.py",
"git_sync.py",
"parallel_orchestrator.py",
"progress.py",
"prompts.py",
Expand Down
5 changes: 5 additions & 0 deletions server/routers/settings.py
Original file line number Diff line number Diff line change
Expand Up @@ -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")),
Expand Down Expand Up @@ -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")
Expand Down Expand Up @@ -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")),
Expand Down
2 changes: 2 additions & 0 deletions server/schemas.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand Down
126 changes: 126 additions & 0 deletions test_git_sync.py
Original file line number Diff line number Diff line change
@@ -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")
24 changes: 24 additions & 0 deletions ui/src/components/SettingsModal.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 })
Expand Down Expand Up @@ -436,6 +442,24 @@ export function SettingsModal({ isOpen, onClose }: SettingsModalProps) {
/>
</div>

{/* Git Auto-Push Toggle */}
<div className="flex items-center justify-between">
<div className="space-y-0.5">
<Label htmlFor="git-auto-push" className="font-medium">
Auto-push to Git Remote
</Label>
<p className="text-sm text-muted-foreground">
Push the project's current branch to its remote after each agent session
</p>
</div>
<Switch
id="git-auto-push"
checked={settings.git_auto_push ?? false}
onCheckedChange={handleGitAutoPushToggle}
disabled={isSaving}
/>
</div>

{/* Regression Agents */}
<div className="space-y-2">
<Label className="font-medium">Regression Agents</Label>
Expand Down
Loading