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)