Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
23 changes: 22 additions & 1 deletion tests/test_live_core.py
Original file line number Diff line number Diff line change
Expand Up @@ -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", [
Expand Down Expand Up @@ -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 <dir>`, `-c <name>=<value>`, `--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", []),
Expand All @@ -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"]),
Expand All @@ -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 <dir> apply` and `git -C <dir> checkout -- <path>` 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."""
Expand Down
26 changes: 21 additions & 5 deletions tokeneconomizer/loopguard/shell_edits.py
Original file line number Diff line number Diff line change
Expand Up @@ -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 <dir>`, `-c <name>=<value>` 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 -- <path>` or `git restore <path>` 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("-")]


Expand Down Expand Up @@ -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)
Expand Down