feat(@ml/auto-commit-service): add pre-cycle sync and stash operations

Co-Authored-By: Lilith Autocommit <noreply@atlilith.com>
This commit is contained in:
Natalie 2026-04-12 23:01:51 -07:00
parent 783c0117e4
commit fd3511ed67
5 changed files with 317 additions and 1 deletions

View file

@ -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(

View file

@ -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",

View file

@ -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,

View file

@ -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)

View file

@ -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"