feat(python-robomp): added DirtyState and inspect_dirty_state in git_ops

- Added `DirtyState` and `inspect_dirty_state` in `git_ops` to report uncommitted, unpushed, and summary state.
- Updated `_drive_turn` to recheck completion each cycle and emit dirty-state reminders or exit when clean.
- Wired `dirty_state_reminder` into persona rendering with `uncommitted`, `unpushed`, and `summary` context.
- Added `dirty_state_reminder.md` guidance for fixing/restoring changes and pushing only after `bun run fix` completes.
- Added worker tests for dirty, clean, and persistent-dirty flows and prompt-count assertions.
This commit is contained in:
can1357
2026-05-19 18:39:09 +09:00
parent a17f74bcd6
commit 784ac0b6cf
5 changed files with 298 additions and 13 deletions
+87
View File
@@ -445,6 +445,26 @@ class PushResult:
branch: str
@dataclass(slots=True, frozen=True)
class DirtyState:
"""Summary of the workspace's uncommitted + unpushed state.
`uncommitted` counts entries from `git status --porcelain`. `unpushed`
counts commits in `HEAD` not reachable from any `origin/*` ref — i.e.
commits that would be lost if the workspace were thrown away. `summary`
is a human-friendly multi-line description for embedding in a reminder
prompt; empty when both counts are zero.
"""
uncommitted: int
unpushed: int
summary: str
@property
def is_dirty(self) -> bool:
return self.uncommitted > 0 or self.unpushed > 0
class HeadDriftError(GitCommandError):
"""Raised when `expected_head` no longer matches the current HEAD.
@@ -479,6 +499,71 @@ def rev_parse_head(
return proc.stdout.strip()
def inspect_dirty_state(
repo_dir: Path,
*,
slot_uid: int | None = None,
safe_directory: Path | None = None,
) -> DirtyState:
"""Probe the worktree at `repo_dir` for uncommitted/unpushed work.
Returns a {@link DirtyState}. Errors from the underlying git invocations
are swallowed — the caller treats "we couldn't tell" as clean so a broken
git binary can't pin the agent in a reminder loop forever.
"""
slot_kwargs = _slot_subprocess_kwargs(slot_uid)
uncommitted = 0
uncommitted_sample: list[str] = []
status = _run_git(
["status", "--porcelain=v1", "--untracked-files=normal"],
cwd=repo_dir,
token=None,
safe_directory=safe_directory,
**slot_kwargs,
)
if status.returncode == 0 and status.stdout.strip():
lines = status.stdout.splitlines()
uncommitted = len(lines)
uncommitted_sample = lines[:10]
unpushed = 0
unpushed_sample: list[str] = []
count = _run_git(
["rev-list", "--count", "HEAD", "--not", "--remotes=origin"],
cwd=repo_dir,
token=None,
safe_directory=safe_directory,
**slot_kwargs,
)
if count.returncode == 0:
try:
unpushed = int(count.stdout.strip() or "0")
except ValueError:
unpushed = 0
if unpushed > 0:
log_proc = _run_git(
["log", f"--max-count={min(unpushed, 5)}", "--oneline", "HEAD", "--not", "--remotes=origin"],
cwd=repo_dir,
token=None,
safe_directory=safe_directory,
**slot_kwargs,
)
if log_proc.returncode == 0:
unpushed_sample = [line for line in log_proc.stdout.splitlines() if line.strip()]
if uncommitted == 0 and unpushed == 0:
return DirtyState(uncommitted=0, unpushed=0, summary="")
parts: list[str] = []
if uncommitted:
more = f"\n… and {uncommitted - len(uncommitted_sample)} more" if uncommitted > len(uncommitted_sample) else ""
parts.append(f"Uncommitted changes ({uncommitted}):\n" + "\n".join(uncommitted_sample) + more)
if unpushed:
log_text = "\n".join(unpushed_sample) if unpushed_sample else "(no log available)"
parts.append(f"Unpushed commits ({unpushed}):\n{log_text}")
return DirtyState(uncommitted=uncommitted, unpushed=unpushed, summary="\n\n".join(parts))
def push(
repo_dir: Path,
*,
@@ -556,12 +641,14 @@ def push(
__all__ = [
"AUTH_ENV_VAR",
"DirtyState",
"GitCommandError",
"HeadDriftError",
"PushResult",
"clone",
"fetch_prune",
"fetch_ref",
"inspect_dirty_state",
"push",
"redact_credentials",
"rev_parse_head",