fix(@ml/auto-commit-service): 🐛 handle staged-only unstage on empty commit

Co-Authored-By: Lilith Autocommit <noreply@atlilith.com>
This commit is contained in:
Natalie 2026-06-10 02:18:15 -07:00
parent 2280d37bbc
commit a9fa13d242

View file

@ -251,15 +251,24 @@ class LocalCommitAgent:
# Extract dirty paths from porcelain output and apply secret prefilter.
# Status format: "XY path" where XY is 2-char status, followed by the path.
# Track which paths have a worktree-side change (Y != ' ') or are untracked:
# only those need `git add`. Index-only entries (e.g. a staged deletion
# "D path") exist in neither worktree nor index, so adding them fatals
# with "pathspec did not match any files" and kills the whole repo cycle.
dirty_paths: list[str] = []
needs_staging: set[str] = set()
for line in status.splitlines():
if not line.strip():
continue
xy = line[:2]
# Skip the 3-char prefix "XY ". Handle renames ("R old -> new") by taking new.
entry = line[3:]
if " -> " in entry:
entry = entry.split(" -> ", 1)[1]
dirty_paths.append(entry.strip().strip('"'))
path = entry.strip().strip('"')
dirty_paths.append(path)
if xy == "??" or xy[1] != " ":
needs_staging.add(path)
allowed, denied = filter_dirty_paths(dirty_paths)
if denied:
@ -272,9 +281,11 @@ class LocalCommitAgent:
logger.debug(f"{_repo_display_name(repo_path)}: all dirty files on denylist, skipping")
return False
# Stage only the allowed files (never blanket `git add -A` — that would
# stage denied secret paths too).
_git(repo_path, "add", "--", *allowed)
# Stage only the allowed files that aren't staged yet (never blanket
# `git add -A` — that would stage denied secret paths too).
to_stage = [p for p in allowed if p in needs_staging]
if to_stage:
_git(repo_path, "add", "--", *to_stage)
# Get the diff of staged changes. Size cap comes from self.max_diff_bytes;
# the 6000-byte stage-time cap is a sub-cap before the prefilter-level cap.
@ -311,9 +322,11 @@ class LocalCommitAgent:
if not message:
logger.warning(f"Empty message for {repo_name}, skipping")
# Unstage path-by-path — safe on unborn branches (no initial commit)
# where `git reset HEAD` fails because HEAD is unresolved.
_git(repo_path, "reset", "--", *allowed)
# Unstage only what WE staged, path-by-path — safe on unborn branches
# (no initial commit) where `git reset HEAD` fails because HEAD is
# unresolved. Paths staged before this cycle stay staged.
if to_stage:
_git(repo_path, "reset", "--", *to_stage)
return False
# Dry-run gate — log intent, unstage, do not commit or push
@ -322,9 +335,11 @@ class LocalCommitAgent:
f"[DRY-RUN] {repo_name} ({branch}) would commit {len(allowed)} file(s) "
f"with message: {message.splitlines()[0] if message else '(empty)'}"
)
# Unstage path-by-path — safe on unborn branches (no initial commit)
# where `git reset HEAD` fails because HEAD is unresolved.
_git(repo_path, "reset", "--", *allowed)
# Unstage only what WE staged, path-by-path — safe on unborn branches
# (no initial commit) where `git reset HEAD` fails because HEAD is
# unresolved. Paths staged before this cycle stay staged.
if to_stage:
_git(repo_path, "reset", "--", *to_stage)
return False
# Commit