diff --git a/src/auto_commit_service/config.py b/src/auto_commit_service/config.py index e101489..23befd8 100644 --- a/src/auto_commit_service/config.py +++ b/src/auto_commit_service/config.py @@ -107,6 +107,10 @@ class AutoCommitSettings(BaseServiceSettings): default="master", description="Git branch to push to", ) + pre_cycle_pull: bool = Field( + default=True, + description="Pull from remote before each commit cycle to sync multi-host changes", + ) # Claude fallback settings claude_fallback_enabled: bool = Field( diff --git a/src/auto_commit_service/git/__init__.py b/src/auto_commit_service/git/__init__.py index 15b7bb3..5bfbab8 100644 --- a/src/auto_commit_service/git/__init__.py +++ b/src/auto_commit_service/git/__init__.py @@ -8,6 +8,10 @@ from .operations import ( git_commit, git_push, git_pull_rebase, + git_fetch, + git_stash, + git_stash_pop, + pre_cycle_sync, ) from .diff_parser import summarize_diff, DiffSummary from .discovery import discover_git_repos @@ -20,6 +24,10 @@ __all__ = [ "git_commit", "git_push", "git_pull_rebase", + "git_fetch", + "git_stash", + "git_stash_pop", + "pre_cycle_sync", "summarize_diff", "DiffSummary", "discover_git_repos", diff --git a/src/auto_commit_service/git/operations.py b/src/auto_commit_service/git/operations.py index bf00024..76ca06d 100644 --- a/src/auto_commit_service/git/operations.py +++ b/src/auto_commit_service/git/operations.py @@ -391,6 +391,115 @@ async def git_pull_rebase( raise GitError(f"Unexpected error during pull rebase: {e}") +async def git_fetch( + repo_path: Path, + remote: str = "origin", +) -> bool: + """Fetch from remote to update refs without touching the working tree. + + Returns True on success, False on failure (non-fatal). + """ + try: + _, stderr, returncode = await _run_git_command( + "fetch", remote, cwd=repo_path, check=False + ) + if returncode != 0: + logger.warning(f"git fetch failed in {repo_path}: {stderr}") + return False + return True + except Exception as e: + logger.warning(f"git fetch error in {repo_path}: {e}") + return False + + +async def git_stash(repo_path: Path) -> bool: + """Stash uncommitted changes. Returns True if something was stashed.""" + stdout, _, returncode = await _run_git_command( + "stash", cwd=repo_path, check=False + ) + return returncode == 0 and "No local changes" not in stdout + + +async def git_stash_pop(repo_path: Path) -> bool: + """Pop stashed changes. Returns True on success.""" + _, stderr, returncode = await _run_git_command( + "stash", "pop", cwd=repo_path, check=False + ) + if returncode != 0: + logger.warning(f"git stash pop failed in {repo_path}: {stderr}") + return False + return True + + +async def pre_cycle_sync( + repo_path: Path, + remote: str = "origin", + branch: str = "main", +) -> dict: + """Sync repo with remote before a commit cycle. + + Sequence: fetch -> check if behind -> stash -> pull --rebase -> stash pop. + All failures are non-fatal (logged and returned in result dict). + + Returns dict with keys: fetched, pulled, behind_count, stashed, error + """ + result = { + "fetched": False, + "pulled": False, + "behind_count": 0, + "stashed": False, + "error": None, + } + + # Step 1: Fetch + if not await git_fetch(repo_path, remote): + result["error"] = "fetch_failed" + return result + result["fetched"] = True + + # Step 2: Check if behind (re-read status after fetch updates refs) + try: + status = await git_status(repo_path) + except Exception as e: + result["error"] = f"status_failed: {e}" + return result + + result["behind_count"] = status.behind + if status.behind == 0: + return result # Nothing to pull + + # Step 3: Stash if dirty + has_changes = status.has_changes + stashed = False + if has_changes: + stashed = await git_stash(repo_path) + result["stashed"] = stashed + + # Step 4: Pull --rebase + try: + await git_pull_rebase(repo_path, remote, branch) + result["pulled"] = True + except MergeConflictError as e: + logger.warning(f"Conflict during pre-cycle pull in {repo_path}: {e}") + result["error"] = "merge_conflict" + except GitError as e: + logger.warning(f"Pull failed in {repo_path}: {e}") + result["error"] = f"pull_failed: {e}" + + # Step 5: Unstash if we stashed + if stashed: + pop_ok = await git_stash_pop(repo_path) + if not pop_ok: + logger.warning( + f"Stash pop failed in {repo_path}. " + f"Changes are in git stash; will be picked up next cycle." + ) + error_prefix = result.get("error") or "" + result["error"] = f"{error_prefix}; stash_pop_failed".lstrip("; ") + + return result + + async def git_log_recent( repo_path: Path, count: int = 5, diff --git a/src/auto_commit_service/scheduler/daemon.py b/src/auto_commit_service/scheduler/daemon.py index 0ceed55..93e952f 100644 --- a/src/auto_commit_service/scheduler/daemon.py +++ b/src/auto_commit_service/scheduler/daemon.py @@ -10,7 +10,7 @@ from typing import Any from ..commit_history import CommitHistory from ..config import AutoCommitSettings -from ..git import Repository, discover_git_repos, git_status +from ..git import Repository, discover_git_repos, git_status, pre_cycle_sync from ..llm import LlamaCommitClient from ..models import CycleResult, ProcessStatus, RepoProcessResult from ..recovery import ErrorHandler @@ -663,6 +663,30 @@ class CommitDaemon: # Yield to event loop periodically to keep HTTP API responsive await asyncio.sleep(0) + # Pre-cycle pull: sync with remote before processing + if self.settings.pre_cycle_pull: + try: + sync_result = await pre_cycle_sync( + repo.path, + remote=self.settings.git_remote, + branch=self.settings.git_branch, + ) + if sync_result["pulled"]: + logger.info( + f"[{cycle_id}] {repo.name}: Pulled {sync_result['behind_count']} " + f"commit(s) from remote" + ) + elif sync_result["error"]: + logger.warning( + f"[{cycle_id}] {repo.name}: Pre-cycle sync issue: " + f"{sync_result['error']} (continuing anyway)" + ) + except Exception as e: + logger.warning( + f"[{cycle_id}] {repo.name}: Pre-cycle sync failed: {e} " + f"(continuing without sync)" + ) + # Get status first to know the branch and if we need to push try: status = await git_status(repo.path) diff --git a/tests/test_git_operations.py b/tests/test_git_operations.py index 51caaea..19d6f22 100644 --- a/tests/test_git_operations.py +++ b/tests/test_git_operations.py @@ -1,5 +1,7 @@ """Tests for git operations module.""" +import subprocess + import pytest from pathlib import Path @@ -8,6 +10,10 @@ from auto_commit_service.git import ( git_diff, git_add_all, git_commit, + git_fetch, + git_stash, + git_stash_pop, + pre_cycle_sync, ) from auto_commit_service.git.repository import Repository @@ -104,3 +110,168 @@ class TestRepository: non_git.mkdir() repo = Repository(name="test", path=non_git) assert not repo.exists + + +def _make_bare_remote(tmp_path: Path, repo_path: Path) -> Path: + """Create a bare remote and configure repo to push/fetch from it.""" + bare = tmp_path / "remote.git" + subprocess.run(["git", "init", "--bare", str(bare)], check=True, capture_output=True) + subprocess.run( + ["git", "remote", "add", "origin", str(bare)], + cwd=repo_path, check=True, capture_output=True, + ) + subprocess.run( + ["git", "push", "-u", "origin", "master"], + cwd=repo_path, check=True, capture_output=True, + ) + return bare + + +def _clone_from_bare(tmp_path: Path, bare: Path, name: str) -> Path: + """Clone from bare remote into a new working copy.""" + clone = tmp_path / name + subprocess.run( + ["git", "clone", str(bare), str(clone)], + check=True, capture_output=True, + ) + subprocess.run( + ["git", "config", "user.email", "test@test.com"], + cwd=clone, check=True, capture_output=True, + ) + subprocess.run( + ["git", "config", "user.name", "Test User"], + cwd=clone, check=True, capture_output=True, + ) + return clone + + +class TestGitFetch: + """Tests for git_fetch function.""" + + async def test_fetch_success(self, temp_git_repo: Path, tmp_path: Path) -> None: + """Test fetching from a valid remote.""" + bare = _make_bare_remote(tmp_path, temp_git_repo) + result = await git_fetch(temp_git_repo, "origin") + assert result is True + + async def test_fetch_no_remote(self, temp_git_repo: Path) -> None: + """Test fetch with no remote configured (graceful failure).""" + result = await git_fetch(temp_git_repo, "origin") + assert result is False + + +class TestGitStash: + """Tests for git_stash and git_stash_pop.""" + + async def test_stash_dirty_tree(self, temp_git_repo: Path) -> None: + """Test stashing with uncommitted changes.""" + (temp_git_repo / "README.md").write_text("# Modified\n") + stashed = await git_stash(temp_git_repo) + assert stashed is True + + # Working tree should be clean now + status = await git_status(temp_git_repo) + assert not status.has_changes + + async def test_stash_clean_tree(self, temp_git_repo: Path) -> None: + """Test stashing with no changes returns False.""" + stashed = await git_stash(temp_git_repo) + assert stashed is False + + async def test_stash_pop_restores(self, temp_git_repo: Path) -> None: + """Test stash pop restores changes.""" + (temp_git_repo / "README.md").write_text("# Modified\n") + await git_stash(temp_git_repo) + + popped = await git_stash_pop(temp_git_repo) + assert popped is True + + status = await git_status(temp_git_repo) + assert status.has_changes + assert "README.md" in status.modified + + async def test_stash_pop_empty(self, temp_git_repo: Path) -> None: + """Test stash pop with nothing stashed returns False.""" + popped = await git_stash_pop(temp_git_repo) + assert popped is False + + +class TestPreCycleSync: + """Tests for pre_cycle_sync function.""" + + async def test_not_behind(self, temp_git_repo: Path, tmp_path: Path) -> None: + """Test sync when already up-to-date.""" + _make_bare_remote(tmp_path, temp_git_repo) + + result = await pre_cycle_sync(temp_git_repo, "origin", "master") + + assert result["fetched"] is True + assert result["pulled"] is False + assert result["behind_count"] == 0 + assert result["error"] is None + + async def test_behind_clean_tree(self, temp_git_repo: Path, tmp_path: Path) -> None: + """Test sync when behind with clean working tree — pulls without stash.""" + bare = _make_bare_remote(tmp_path, temp_git_repo) + + # Create a second clone, commit, push — making temp_git_repo behind + other = _clone_from_bare(tmp_path, bare, "other-clone") + (other / "other.txt").write_text("from other host") + subprocess.run(["git", "add", "."], cwd=other, check=True, capture_output=True) + subprocess.run( + ["git", "commit", "-m", "commit from other host"], + cwd=other, check=True, capture_output=True, + ) + subprocess.run( + ["git", "push", "origin", "master"], + cwd=other, check=True, capture_output=True, + ) + + result = await pre_cycle_sync(temp_git_repo, "origin", "master") + + assert result["fetched"] is True + assert result["pulled"] is True + assert result["behind_count"] == 1 + assert result["stashed"] is False + assert result["error"] is None + # Verify the file arrived + assert (temp_git_repo / "other.txt").exists() + + async def test_behind_dirty_tree(self, temp_git_repo: Path, tmp_path: Path) -> None: + """Test sync when behind with dirty working tree — stash, pull, pop.""" + bare = _make_bare_remote(tmp_path, temp_git_repo) + + # Push a commit from another clone + other = _clone_from_bare(tmp_path, bare, "other-clone") + (other / "other.txt").write_text("from other host") + subprocess.run(["git", "add", "."], cwd=other, check=True, capture_output=True) + subprocess.run( + ["git", "commit", "-m", "commit from other host"], + cwd=other, check=True, capture_output=True, + ) + subprocess.run( + ["git", "push", "origin", "master"], + cwd=other, check=True, capture_output=True, + ) + + # Dirty the local working tree (different file to avoid conflicts) + (temp_git_repo / "local_wip.txt").write_text("work in progress") + + result = await pre_cycle_sync(temp_git_repo, "origin", "master") + + assert result["fetched"] is True + assert result["pulled"] is True + assert result["stashed"] is True + assert result["error"] is None + # Remote file pulled + assert (temp_git_repo / "other.txt").exists() + # Local WIP restored from stash + assert (temp_git_repo / "local_wip.txt").exists() + + async def test_fetch_failure(self, temp_git_repo: Path) -> None: + """Test sync with no remote — fetch fails gracefully.""" + result = await pre_cycle_sync(temp_git_repo, "origin", "master") + + assert result["fetched"] is False + assert result["pulled"] is False + assert result["error"] == "fetch_failed"