Skip to content

Commit 7edc36d

Browse files
fix: filename quoting, nested globs, and small display bugs
- Decode git C-style quoting (backslash, quotes, octal) so quoted patch and status keys align with raw -z numstat keys - Parse quoted diff --git headers by splitting between quoted strings - Match dir-prefix ignore globs at any depth for monorepos - LC_ALL=C for deterministic English git errors; clean GitCommandError on non-UTF-8 output - config show prints 0.0 budget instead of none; ignore tool-dropped .agents/ dir
1 parent a79161e commit 7edc36d

5 files changed

Lines changed: 165 additions & 16 deletions

File tree

‎.gitignore‎

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -66,6 +66,9 @@ Thumbs.db
6666
*.log
6767
*.tmp
6868

69+
# Agent tooling droppings (not part of this project)
70+
.agents/
71+
6972
#md files
7073
plan.md
7174

‎src/gitmate/cli.py‎

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -125,7 +125,9 @@ def config_show() -> None:
125125
table.add_row("config file", str(config_mod.config_path()))
126126
table.add_row("model", cfg.model)
127127
table.add_row("commit_style", cfg.commit_style)
128-
table.add_row("budget_cap_usd", str(cfg.budget_cap_usd) if cfg.budget_cap_usd else "none")
128+
table.add_row(
129+
"budget_cap_usd", str(cfg.budget_cap_usd) if cfg.budget_cap_usd is not None else "none"
130+
)
129131
table.add_row("ignore_globs", ", ".join(cfg.ignore_globs) if cfg.ignore_globs else "none")
130132
table.add_row("api_key", "set" if key_set else "not set")
131133
console.print(table)

‎src/gitmate/diff_extractor.py‎

Lines changed: 99 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22

33
from __future__ import annotations
44

5+
import os
56
import subprocess
67
from collections.abc import Iterable
78
from dataclasses import dataclass
@@ -72,11 +73,15 @@ def run(self, args: list[str], cwd: Path) -> str:
7273
text=True,
7374
timeout=self._timeout,
7475
check=False,
76+
# Deterministic English errors regardless of host locale.
77+
env={**os.environ, "LC_ALL": "C"},
7578
)
7679
except FileNotFoundError as exc:
7780
raise GitCommandError("git binary not found on PATH.") from exc
7881
except subprocess.TimeoutExpired as exc:
7982
raise GitCommandError(f"git {' '.join(args)} timed out.") from exc
83+
except UnicodeDecodeError as exc:
84+
raise GitCommandError(f"git {' '.join(args)} output was not valid UTF-8.") from exc
8085
if proc.returncode != 0:
8186
err = proc.stderr.strip()
8287
if "not a git repository" in err:
@@ -85,17 +90,25 @@ def run(self, args: list[str], cwd: Path) -> str:
8590
return proc.stdout
8691

8792

93+
def _dir_match(posix: str, dirname: str) -> bool:
94+
"""True when dirname is the path itself or any parent directory in it."""
95+
return posix == dirname or posix.startswith(dirname + "/") or f"/{dirname}/" in f"/{posix}"
96+
97+
8898
def matches_any(path: str, patterns: Iterable[str]) -> bool:
89-
"""Gitignore-flavored match: dir prefixes, bare basenames, else fnmatch."""
99+
"""Gitignore-flavored match: dir prefixes, bare basenames, else fnmatch.
100+
101+
Directory patterns (`dist/`, `dist/*`) match at any depth, so monorepo
102+
`packages/ui/dist/` output is filtered just like root-level `dist/`.
103+
"""
90104
posix = PurePosixPath(path).as_posix()
91105
name = PurePosixPath(path).name
92106
for pat in patterns:
93107
if pat.endswith("/*"):
94-
prefix = pat[:-1]
95-
if posix == prefix[:-1] or posix.startswith(prefix):
108+
if _dir_match(posix, pat[:-2]):
96109
return True
97110
elif pat.endswith("/"):
98-
if posix == pat[:-1] or posix.startswith(pat):
111+
if _dir_match(posix, pat[:-1]):
99112
return True
100113
elif "/" not in pat:
101114
if fnmatchcase(name, pat):
@@ -174,18 +187,92 @@ def _parse_name_status(output: str) -> dict[str, tuple[FileStatus, str | None]]:
174187
if len(parts) != 3:
175188
raise GitCommandError(f"cannot parse rename/copy row: {line!r}.")
176189
_, old, new = parts
177-
statuses[new] = (status, old)
190+
statuses[_decode_quoted(new)] = (status, _decode_quoted(old))
178191
else:
179192
if len(parts) != 2:
180193
raise GitCommandError(f"cannot parse status row: {line!r}.")
181-
statuses[parts[1]] = (status, None)
194+
statuses[_decode_quoted(parts[1])] = (status, None)
182195
return statuses
183196

184197

185-
def _strip_git_prefix(path: str) -> str:
186-
"""Strip git's a//b/ prefixes and optional C-style quoting."""
198+
def _split_header_fallback(rest: str) -> tuple[str | None, str | None]:
199+
"""Split `a/X b/Y` on the last ` b/`; prefixes are pinned by our git flags.
200+
201+
Correct for any spaces inside X or Y. Only a filename literally containing
202+
` b/` defeats it; the rename/copy metadata and ---/+++ re-keying backstop
203+
even that pathological case.
204+
"""
205+
if rest.startswith('"'):
206+
# Both paths quoted: "a/X" "b/Y" — split between the quoted strings.
207+
idx = rest.find('" "')
208+
if idx == -1:
209+
return None, None
210+
return _strip_git_prefix(rest[: idx + 1]), _strip_git_prefix(rest[idx + 1 :].strip())
211+
idx = rest.rfind(" b/")
212+
if idx < 2:
213+
return None, None
214+
return _strip_git_prefix(rest[:idx]), _strip_git_prefix(rest[idx + 1 :])
215+
216+
217+
def _unescape_c_quoting(text: str) -> str:
218+
"""Decode git's C-style path escapes (`\\\\`, `\\"`, `\\n`, `\\t`, octal).
219+
220+
Only meaningful on paths git quoted; unquoted output (e.g. `-z --numstat`)
221+
is never munged and must pass through untouched. Octal runs decode as
222+
UTF-8 bytes because git escapes non-ASCII per byte, not per character.
223+
"""
224+
out: list[str] = []
225+
raw = bytearray()
226+
227+
def flush_octal() -> None:
228+
if raw:
229+
out.append(bytes(raw).decode("utf-8", errors="surrogateescape"))
230+
raw.clear()
231+
232+
i, n = 0, len(text)
233+
while i < n:
234+
ch = text[i]
235+
if ch != "\\" or i + 1 >= n:
236+
flush_octal()
237+
out.append(ch)
238+
i += 1
239+
continue
240+
nxt = text[i + 1]
241+
if nxt in "01234567":
242+
digits = text[i + 1 : i + 4]
243+
length = 0
244+
while length < 3 and length < len(digits) and digits[length] in "01234567":
245+
length += 1
246+
raw.extend(int(digits[:length], 8).to_bytes(1, "big"))
247+
i += 1 + length
248+
continue
249+
flush_octal()
250+
if nxt == "n":
251+
out.append("\n")
252+
elif nxt == "t":
253+
out.append("\t")
254+
elif nxt == "\\":
255+
out.append("\\")
256+
elif nxt == '"':
257+
out.append('"')
258+
else:
259+
out.append(ch)
260+
out.append(nxt)
261+
i += 2
262+
flush_octal()
263+
return "".join(out)
264+
265+
266+
def _decode_quoted(path: str) -> str:
267+
"""Remove git's C-style quoting when present; bare paths pass through."""
187268
if len(path) >= 2 and path.startswith('"') and path.endswith('"'):
188-
path = path[1:-1]
269+
return _unescape_c_quoting(path[1:-1])
270+
return path
271+
272+
273+
def _strip_git_prefix(path: str) -> str:
274+
"""Strip git's a//b/ prefixes, unescaping C-style quoting when present."""
275+
path = _decode_quoted(path)
189276
if path.startswith(("a/", "b/")):
190277
return path[2:]
191278
return path
@@ -224,13 +311,9 @@ def flush() -> None:
224311
minus = _strip_git_prefix(rest[2:mid])
225312
current = _strip_git_prefix(rest[mid + 3 :])
226313
else:
227-
halves = rest.rsplit(" ", 1)
228-
minus = _strip_git_prefix(halves[0].strip()) if len(halves) == 2 else None
229-
current = _strip_git_prefix(halves[1].strip()) if len(halves) == 2 else None
314+
minus, current = _split_header_fallback(rest)
230315
else:
231-
halves = rest.rsplit(" ", 1)
232-
minus = _strip_git_prefix(halves[0].strip()) if len(halves) == 2 else None
233-
current = _strip_git_prefix(halves[1].strip()) if len(halves) == 2 else None
316+
minus, current = _split_header_fallback(rest)
234317
chunk = [line]
235318
elif current is None and minus is None:
236319
continue
@@ -248,6 +331,7 @@ def flush() -> None:
248331
for prev in text.splitlines():
249332
if prev.startswith("--- "):
250333
key = _strip_git_prefix(prev[4:].strip())
334+
break
251335
else:
252336
key = plus
253337
break

‎tests/test_cli.py‎

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -58,6 +58,16 @@ def test_config_set_field_persists(tmp_config_dir: Path) -> None:
5858
assert load_config().model == "my-model"
5959

6060

61+
def test_config_show_displays_zero_budget(
62+
tmp_config_dir: Path, mem_store: InMemorySecretStore
63+
) -> None:
64+
result = runner.invoke(cli.app, ["config", "set", "budget_cap_usd", "0"])
65+
assert result.exit_code == 0
66+
result = runner.invoke(cli.app, ["config", "show"])
67+
assert result.exit_code == 0
68+
assert "0.0" in result.output
69+
70+
6171
def test_config_set_unknown_field_fails(tmp_config_dir: Path) -> None:
6272
result = runner.invoke(cli.app, ["config", "set", "nope", "x"])
6373
assert result.exit_code != 0

‎tests/test_diff_extractor.py‎

Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22

33
from __future__ import annotations
44

5+
import os
56
import subprocess
67
from pathlib import Path
78

@@ -88,6 +89,9 @@ def test_matcher_rules() -> None:
8889
assert matches_any("a/b/c.min.js", ["*.min.js"])
8990
assert not matches_any("src/app.py", ["dist/*", "*.min.js"])
9091
assert matches_any("out/log.txt", ["out/"])
92+
assert matches_any("packages/ui/dist/bundle.js", ["dist/*"])
93+
assert matches_any("packages/ui/dist/bundle.js", ["dist/"])
94+
assert not matches_any("src/distraction.py", ["dist/*"])
9195

9296

9397
def test_branch_comparison(git_repo: Path) -> None:
@@ -178,3 +182,49 @@ def test_pure_rename_with_spaces(git_repo: Path) -> None:
178182
def test_copy_status_row() -> None:
179183
statuses = _parse_name_status("C100\told.py\tnew.py\n")
180184
assert statuses["new.py"] == ("added", "old.py")
185+
186+
187+
def test_nested_monorepo_dist_filtered(git_repo: Path) -> None:
188+
stage_file(git_repo, "packages/ui/dist/bundle.js", b"var x;\n")
189+
stage_file(git_repo, "src/ok.py", b"ok\n")
190+
(diff,) = DiffExtractor(git_repo).staged()
191+
assert diff.path == "src/ok.py"
192+
193+
194+
def test_deleted_file_with_dash_comments_keeps_patch(git_repo: Path) -> None:
195+
commit_file(git_repo, "query.sql", "SELECT 1;\n-- a comment\n", "add sql")
196+
subprocess.run(["git", "rm", "query.sql"], cwd=git_repo, check=True)
197+
(diff,) = DiffExtractor(git_repo).staged()
198+
assert (diff.path, diff.status) == ("query.sql", "deleted")
199+
assert "diff --git a/query.sql b/query.sql" in diff.patch_text
200+
assert "-- a comment" in diff.patch_text
201+
202+
203+
def test_backslash_filename_single_entry(git_repo: Path) -> None:
204+
# A literal backslash is always C-quoted by git, even with quotepath=false;
205+
# the entry must align with the raw -z numstat key, not duplicate.
206+
stage_file(git_repo, "back\\slash.py", b"x = 1\n")
207+
(diff,) = DiffExtractor(git_repo).staged()
208+
assert diff.path == "back\\slash.py"
209+
assert (diff.status, diff.additions) == ("added", 1)
210+
assert "+x = 1" in diff.patch_text
211+
212+
213+
def test_double_quote_filename_single_entry(git_repo: Path) -> None:
214+
stage_file(git_repo, 'we"ird.py', b"x = 1\n")
215+
(diff,) = DiffExtractor(git_repo).staged()
216+
assert diff.path == 'we"ird.py'
217+
assert diff.additions == 1
218+
219+
220+
def test_non_utf8_git_output_clean_error(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
221+
# macOS refuses to create non-UTF-8 filenames, so fake the git binary
222+
# itself emitting invalid bytes instead.
223+
bindir = tmp_path / "bin"
224+
bindir.mkdir()
225+
fake = bindir / "git"
226+
fake.write_bytes(b"#!/bin/sh\nprintf 'x\\377y'\n")
227+
fake.chmod(0o755)
228+
monkeypatch.setenv("PATH", f"{bindir}{os.pathsep}{os.environ['PATH']}")
229+
with pytest.raises(GitCommandError):
230+
DiffExtractor(tmp_path).staged()

0 commit comments

Comments
 (0)