From 50e486e1edfadac5e6616667c669781a3a6df414 Mon Sep 17 00:00:00 2001
From: KVR Vishnu <46535652+VISHNU0906@users.noreply.github.com>
Date: Thu, 12 Nov 2026 04:52:24 +0530
Subject: [PATCH] loopguard: read the git subcommand after git's own options
git -C
apply x.diff and git -C checkout -- f.py put the
subcommand after git's own options (-C, -c, --no-pager, --git-dir ...).
The shell-edit parser took the word right after git for the
subcommand, so the patch was not counted as an edit and putting back a
file the turn had edited was not counted either.
Signed-off-by: KVR Vishnu <46535652+VISHNU0906@users.noreply.github.com>
---
tests/test_live_core.py | 23 ++++++++++++++++++++-
tokeneconomizer/loopguard/shell_edits.py | 26 +++++++++++++++++++-----
2 files changed, 43 insertions(+), 6 deletions(-)
diff --git a/tests/test_live_core.py b/tests/test_live_core.py
index 24c9648..e0436a9 100644
--- a/tests/test_live_core.py
+++ b/tests/test_live_core.py
@@ -661,7 +661,7 @@ def test_the_opencode_side_call_adds_only_the_flags_its_cli_lists(monkeypatch):
# ---- the guard sees edits made through the shell ---------------------------------------------
from tokeneconomizer import loopguard as guard # noqa: E402
-from tokeneconomizer.loopguard import turn # noqa: E402
+from tokeneconomizer.loopguard import shell_edits, turn # noqa: E402
@pytest.mark.parametrize("cmd,want", [
@@ -693,6 +693,11 @@ def test_the_opencode_side_call_adds_only_the_flags_its_cli_lists(monkeypatch):
("apply_patch <<'P'\n*** Begin Patch\n*** Add File: a/new.py\n+x\n*** Update File: b/old.py\n"
"*** End Patch\nP", ["new.py", "old.py"]),
("git checkout -- a.py b/c.py", ["a.py", "c.py"]),
+ # git's own options before the subcommand (`-C `, `-c =`, `--no-pager`)
+ ("git -C /w/repo apply /tmp/fix.diff", ["(patch)"]),
+ ("git -c core.autocrlf=false --no-pager apply fix.diff", ["(patch)"]),
+ ("git -C repo checkout HEAD -- pkg/mod.py", ["mod.py"]),
+ ("git --git-dir=.git --work-tree . restore pkg/mod.py", ["mod.py"]),
# not edits: output captures, scratch, reads, and operators inside quotes
("python -m pytest -q > /tmp/out.txt 2>&1; tail -5 /tmp/out.txt", []),
("grep -n 'x' a.py > hits.txt", []),
@@ -701,6 +706,8 @@ def test_the_opencode_side_call_adds_only_the_flags_its_cli_lists(monkeypatch):
("python -c \"print(1>2)\"", []),
("git stash -q; python bin/test x; git stash pop -q", []),
("sed -n '1,20p' pkg/mod.py", []),
+ ("git -C repo diff -- pkg/mod.py", []),
+ ("git -C repo log -- pkg/mod.py", []),
# a quoted `|` in a sed script must not split the command (seen live: sympy-13091)
("sed -i -E 's/x (<|<=|>|>=) y/z/' numbers.py expr.py", ["numbers.py", "expr.py"]),
("sed -i 's/a/b/' x.py 2>&1 | tail -2", ["x.py"]),
@@ -712,6 +719,20 @@ def test_edits_made_through_the_shell_are_recognised(cmd, want):
assert guard.shell_edit_targets(cmd) == want
+def test_a_git_command_run_with_dash_c_still_counts_its_edit_and_its_revert():
+ """`git -C apply` and `git -C checkout -- ` name the subcommand after
+ git's own options. Reading the word after `git` as the subcommand missed both, so the
+ patch was no edit and putting back a file the turn edited was not counted either."""
+ revert = "git -C /w/repo checkout -- pkg/mod.py"
+ assert shell_edits.shell_revert_targets(revert) == ["mod.py"]
+ g = guard.TurnGuard(turn_key="t")
+ g.fold("Bash", {"command": "git -C /w/repo apply /tmp/fix.diff"}, "", 0)
+ g.fold("Bash", {"command": "sed -i 's/a/b/' pkg/mod.py"}, "", 0)
+ g.fold("Bash", {"command": revert}, "", 0)
+ assert g.edits == 3
+ assert g.whole.edit_counts == {"(patch)": 1, "mod.py": 2}
+
+
def test_an_edit_after_a_here_string_is_counted():
"""`<<<` feeds one word to a command; reading it as a heredoc tag made every later line of
the command a heredoc body, so a `sed -i` after it was not counted as an edit."""
diff --git a/tokeneconomizer/loopguard/shell_edits.py b/tokeneconomizer/loopguard/shell_edits.py
index 8c800ab..23a4746 100644
--- a/tokeneconomizer/loopguard/shell_edits.py
+++ b/tokeneconomizer/loopguard/shell_edits.py
@@ -177,14 +177,30 @@ def _py_writes(text):
return out
+# git's own options that take the next word as their value (`git -C repo apply x.diff`).
+_GIT_VALUE_OPTS = {"-C", "-c", "--git-dir", "--work-tree", "--namespace", "--config-env",
+ "--attr-source"}
+
+
+def _git_args(tokens):
+ """The words of a git command from its subcommand on, past git's own options such as
+ `-C `, `-c =` or `--no-pager`; [] for any other command."""
+ if tokens[0].rsplit("/", 1)[-1] != "git":
+ return []
+ i = 1
+ while i < len(tokens) and tokens[i].startswith("-"):
+ i += 2 if tokens[i] in _GIT_VALUE_OPTS else 1
+ return tokens[i:]
+
+
def _restored(tokens):
"""The paths a `git checkout -- ` or `git restore ` puts back; [] for any other
command."""
- if tokens[0].rsplit("/", 1)[-1] != "git" or len(tokens) < 3 or \
- tokens[1] not in ("checkout", "restore"):
+ args = _git_args(tokens)
+ if len(args) < 2 or args[0] not in ("checkout", "restore"):
return []
- rest = (tokens[tokens.index("--") + 1:] if "--" in tokens
- else tokens[2:] if tokens[1] == "restore" else [])
+ rest = (args[args.index("--") + 1:] if "--" in args
+ else args[1:] if args[0] == "restore" else [])
return [t for t in rest if not t.startswith("-")]
@@ -232,7 +248,7 @@ def shell_edit_targets(cmd):
found += [t for t in tokens[1:] if not t.startswith("-") and _SOURCE_FILE.search(t)]
elif head == "apply_patch":
found += _PATCH_FILE.findall("\n".join(bodies + [" ".join(tokens[1:])])) or ["(patch)"]
- elif head == "patch" or (head == "git" and len(tokens) > 1 and tokens[1] == "apply"):
+ elif head == "patch" or _git_args(tokens)[:1] == ["apply"]:
found.append("(patch)")
else:
found += _restored(tokens)