Skip to content
Merged
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
284 changes: 117 additions & 167 deletions src/autocoder/core/gatekeeper.py

Large diffs are not rendered by default.

17 changes: 13 additions & 4 deletions src/autocoder/core/git_bootstrap.py
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,6 @@
import subprocess
from pathlib import Path


_DEFAULT_GITIGNORE_LINES = [
"",
"# Common local / build artifacts",
Expand Down Expand Up @@ -49,6 +48,8 @@
"# AutoCoder runtime artifacts",
".autocoder/",
"worktrees/",
".playwright-mcp/",
"*.pid",
".agent.lock",
".progress_cache",
"agent_system.db",
Expand Down Expand Up @@ -140,7 +141,10 @@ def ensure_git_repo_for_parallel(project_dir: Path) -> tuple[bool, str]:
if proc.returncode != 0:
proc = _run_git(["git", "init"], cwd=project_dir)
if proc.returncode != 0:
return False, f"git init failed: {(proc.stderr or proc.stdout).strip() or 'unknown error'}"
return (
False,
f"git init failed: {(proc.stderr or proc.stdout).strip() or 'unknown error'}",
)

# If HEAD exists already, we are good (don't touch user repo further).
if _git_has_head(project_dir):
Expand All @@ -162,9 +166,14 @@ def ensure_git_repo_for_parallel(project_dir: Path) -> tuple[bool, str]:
joined = (commit.stderr or commit.stdout or "").strip()
# If there is nothing to commit, allow an empty initial commit so HEAD exists.
if "nothing to commit" in joined.lower():
commit2 = _run_git(["git", "commit", "--no-gpg-sign", "--allow-empty", "-m", "init"], cwd=project_dir)
commit2 = _run_git(
["git", "commit", "--no-gpg-sign", "--allow-empty", "-m", "init"], cwd=project_dir
)
if commit2.returncode != 0:
return False, f"git commit failed: {(commit2.stderr or commit2.stdout).strip() or 'unknown error'}"
return (
False,
f"git commit failed: {(commit2.stderr or commit2.stdout).strip() or 'unknown error'}",
)
else:
return False, f"git commit failed: {joined or 'unknown error'}"

Expand Down
126 changes: 126 additions & 0 deletions src/autocoder/core/git_dirty.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,126 @@
"""
Git Dirty Detection
===================

Shared helper for determining whether a project git working tree is "dirty" in a way that should
block deterministic merges (Gatekeeper) or parallel-mode worktree orchestration.

We intentionally ignore known runtime artifacts that AutoCoder / Playwright can create, to avoid
blocking merges on harmless files.
"""

from __future__ import annotations

import fnmatch
import subprocess
from dataclasses import dataclass
from pathlib import Path


def _run_git(argv: list[str], *, cwd: Path) -> subprocess.CompletedProcess[str]:
return subprocess.run(
argv,
cwd=str(cwd),
capture_output=True,
text=True,
encoding="utf-8",
errors="replace",
check=False,
)


def git_status_porcelain(project_dir: Path) -> list[str]:
raw = _run_git(["git", "status", "--porcelain"], cwd=Path(project_dir)).stdout
return [ln for ln in (raw or "").splitlines() if ln.strip()]


def split_dirty(lines: list[str], *, project_dir: Path) -> tuple[list[str], list[str]]:
"""
Split `git status --porcelain` lines into:
- ignored (runtime/artifacts we don't want to block on)
- remaining (real changes that should block deterministic merges)
"""
project_dir = Path(project_dir).resolve()

ignore_any_status_substrings = [
".autocoder/",
"worktrees/",
"agent_system.db",
"assistant.db",
".progress_cache",
".eslintrc.json",
]
ignore_untracked_substrings = [
# Playwright MCP verification artifacts / screenshots
".playwright-mcp/",
]
ignore_untracked_filenames = {
# Claude Code CLI can leave these behind in the target project root.
".claude_settings.json",
"claude-progress.txt",
}
ignore_untracked_globs = [
"*.pid",
]

ignored: list[str] = []
remaining: list[str] = []
for ln in lines:
target = ln.replace("\\", "/")
status = ln[:2]
path_part = ln[3:] if len(ln) > 3 else ""
# Handle renames like: "R old -> new"
if "->" in path_part:
path_part = path_part.split("->", 1)[-1].strip()
rel = path_part.replace("\\", "/")
filename = rel.split("/")[-1] if rel else ""

if any(s in target for s in ignore_any_status_substrings):
ignored.append(ln)
continue

if status == "??":
if any(s in rel for s in ignore_untracked_substrings):
ignored.append(ln)
continue
if filename in ignore_untracked_filenames:
ignored.append(ln)
continue
if any(fnmatch.fnmatch(filename, pat) for pat in ignore_untracked_globs):
ignored.append(ln)
continue

# Claude CLI sometimes drops a redundant root-level app_spec.txt even when prompts/app_spec.txt exists.
if filename == "app_spec.txt" and (project_dir / "prompts" / "app_spec.txt").exists():
ignored.append(ln)
continue

# AutoCoder prompt scaffolding files are often left untracked in the target project.
if rel == "prompts/" or rel == "prompts":
ignored.append(ln)
continue
if rel.startswith("prompts/"):
rel_name = rel.split("/")[-1] if rel else ""
if rel_name == "app_spec.txt" or rel_name.endswith("_prompt.txt"):
ignored.append(ln)
continue

remaining.append(ln)

return ignored, remaining


@dataclass(frozen=True)
class GitDirtyStatus:
ignored: list[str]
remaining: list[str]

@property
def is_clean(self) -> bool:
return not self.remaining


def get_git_dirty_status(project_dir: Path) -> GitDirtyStatus:
lines = git_status_porcelain(project_dir)
ignored, remaining = split_dirty(lines, project_dir=project_dir)
return GitDirtyStatus(ignored=ignored, remaining=remaining)
Loading