diff --git a/agent/git_operations.py b/agent/git_operations.py index cffa18d..f321bba 100644 --- a/agent/git_operations.py +++ b/agent/git_operations.py @@ -89,6 +89,7 @@ class GitOperations: logger.info(f"Successfully cloned repository to {self.workspace_dir}") await self._configure_git_user() + await self._ensure_baseline_commit() return True except Exception as e: @@ -135,6 +136,48 @@ class GitOperations: logger.error(f"Git clean failed: {clean.stderr}") return False + await self._ensure_baseline_commit() + return True + + async def _has_commits(self, cwd: str) -> bool: + """True if the checkout has at least one commit (a valid, born HEAD).""" + result = await self._run_git_command( + ["git", "rev-parse", "--verify", "--quiet", "HEAD"], cwd=cwd + ) + return result.returncode == 0 + + async def _ensure_baseline_commit(self) -> bool: + """Seed an initial commit when the clone is an empty repo (unborn HEAD, no commits). + + An empty remote has no default branch and no commits, so the clone's HEAD is an + unborn ref. Downstream `git worktree add ... HEAD` / `checkout -B ... HEAD` then fail + with "invalid reference: HEAD", and the agent silently degrades to "without git push", + so results never reach the remote (agent_swarm#73). Planting one empty baseline commit + on the configured base branch gives those operations a valid base and lets the result + branch push back normally. No-op when the repo already has commits. Returns True if the + repo ends up with a born HEAD. + """ + cwd = await self.repo_root() or self.workspace_dir + if await self._has_commits(cwd): + return True + + logger.warning( + "Cloned repository is empty (no commits); seeding baseline commit on '%s' " + "so task worktrees can be created and pushed (agent_swarm#73)", + self.base_branch, + ) + # Land the first commit on the configured base branch even though HEAD is still unborn. + await self._run_git_command(["git", "checkout", "-B", self.base_branch], cwd=cwd) + commit = await self._run_git_command( + ["git", "commit", "--allow-empty", "-m", + "chore: initialize empty repository baseline"], + cwd=cwd, + ) + if commit.returncode != 0: + logger.error(f"Failed to seed baseline commit for empty repository: {commit.stderr}") + return False + + logger.info(f"Seeded baseline commit on branch '{self.base_branch}' for empty repository") return True async def create_result_branch(self, task_id: Optional[str] = None) -> bool: diff --git a/scripts/test-git-empty-repo.py b/scripts/test-git-empty-repo.py new file mode 100644 index 0000000..ee1587c --- /dev/null +++ b/scripts/test-git-empty-repo.py @@ -0,0 +1,91 @@ +#!/usr/bin/env python3 +"""Regression for agent_swarm#73: binding an EMPTY repo (no commits) must still +create a task worktree and push results back, instead of silently degrading to +"without git push". See agent/git_operations.py:_ensure_baseline_commit. +""" +import asyncio +import importlib.util +import os +import subprocess +import sys +import tempfile +from pathlib import Path +from typing import Optional + + +ROOT = Path(__file__).resolve().parents[1] +GIT_OPS_PATH = ROOT / "agent" / "git_operations.py" + +spec = importlib.util.spec_from_file_location("git_operations", GIT_OPS_PATH) +git_operations = importlib.util.module_from_spec(spec) +assert spec and spec.loader +spec.loader.exec_module(git_operations) +GitOperations = git_operations.GitOperations + + +def run(command: list[str], cwd: Optional[Path] = None): + result = subprocess.run( + command, + cwd=cwd, + text=True, + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + check=False, + ) + if result.returncode != 0: + raise RuntimeError( + f"Command failed: {' '.join(command)}\nSTDOUT:\n{result.stdout}\nSTDERR:\n{result.stderr}" + ) + return result + + +async def main(): + with tempfile.TemporaryDirectory(prefix="swarm-git-empty-") as tmp: + tmpdir = Path(tmp) + remote = tmpdir / "remote.git" + workspace = tmpdir / "workspace" + worktree = tmpdir / "task-worktree" + + # An EMPTY bare remote: no commits, no default branch — exactly the #73 scenario. + run(["git", "init", "--bare", "-b", "main", str(remote)]) + + os.environ["GIT_BASE_BRANCH"] = "main" + git_ops = GitOperations(str(workspace), "test-agent") + + assert await git_ops.clone_workspace(str(remote)), "clone of empty repo failed" + assert await git_ops.is_git_workspace() + + # Baseline must have been seeded so HEAD is now valid. + head = subprocess.run( + ["git", "rev-parse", "--verify", "--quiet", "HEAD"], + cwd=workspace, text=True, stdout=subprocess.PIPE, stderr=subprocess.PIPE, + ) + assert head.returncode == 0 and head.stdout.strip(), "empty repo still has unborn HEAD" + + # The previously-failing path: worktree add on an empty repo. + branch = await git_ops.add_task_worktree(str(worktree), "task-empty") + assert branch, "add_task_worktree returned None on empty repo (regression #73)" + + # Do real work in the worktree and push it back. + task_git = GitOperations(str(worktree), "test-agent") + task_git.result_branch = branch + (worktree / "hello.py").write_text("print('hello from agent')\n", encoding="utf-8") + + commit_sha = await task_git.commit_changes("Task task-empty: add hello") + assert commit_sha, "commit failed in empty-repo worktree" + + pushed = await task_git.push_results() + assert pushed == branch, "push_results failed for empty-repo worktree" + + refs = run(["git", "for-each-ref", "--format=%(refname:short)", "refs/heads"], cwd=remote) + assert branch in refs.stdout.splitlines(), "result branch missing on remote" + + await git_ops.remove_task_worktree(str(worktree)) + + print("Empty-repo git workflow test passed") + print(f"branch={branch}") + print(f"commit={commit_sha}") + + +if __name__ == "__main__": + asyncio.run(main())