From a9fa13d24208c599bab933ebf46f3e70a3b66059 Mon Sep 17 00:00:00 2001 From: Natalie Date: Wed, 10 Jun 2026 02:18:15 -0700 Subject: [PATCH] =?UTF-8?q?fix(@ml/auto-commit-service):=20=F0=9F=90=9B=20?= =?UTF-8?q?handle=20staged-only=20unstage=20on=20empty=20commit?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Lilith Autocommit --- src/auto_commit_service/tray/local_agent.py | 35 +++++++++++++++------ 1 file changed, 25 insertions(+), 10 deletions(-) diff --git a/src/auto_commit_service/tray/local_agent.py b/src/auto_commit_service/tray/local_agent.py index 1144671..d37c258 100644 --- a/src/auto_commit_service/tray/local_agent.py +++ b/src/auto_commit_service/tray/local_agent.py @@ -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