Skip to content

Commit b68cc85

Browse files
Merge pull request #3 from getdownandcode/feat
fix: filename quoting, nested globs, and small display bugs
2 parents a79161e + bf649ff commit b68cc85

5 files changed

Lines changed: 198 additions & 18 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: 102 additions & 17 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,91 @@ 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` across quoted/unquoted combinations on either side.
200+
201+
Our git flags pin the prefixes, so Y always begins `b/` or `"b/`, and
202+
rfind lets the true boundary win over interior lookalikes. Rename/copy
203+
metadata and ---/+++ re-keying backstop anything stranger still.
204+
"""
205+
if rest.endswith('"'):
206+
idx = rest.rfind(' "b/')
207+
if idx == -1:
208+
return None, None
209+
return _strip_git_prefix(rest[:idx]), _strip_git_prefix(rest[idx + 1 :])
210+
idx = rest.rfind(" b/")
211+
if idx < 2:
212+
return None, None
213+
return _strip_git_prefix(rest[:idx]), _strip_git_prefix(rest[idx + 1 :])
214+
215+
216+
def _unescape_c_quoting(text: str) -> str:
217+
"""Decode git's C-style path escapes (`\\\\`, `\\"`, `\\n`, `\\t`, octal).
218+
219+
Only meaningful on paths git quoted; unquoted output (e.g. `-z --numstat`)
220+
is never munged and must pass through untouched. Octal runs decode as
221+
UTF-8 bytes because git escapes non-ASCII per byte, not per character.
222+
"""
223+
out: list[str] = []
224+
raw = bytearray()
225+
226+
def flush_octal() -> None:
227+
if raw:
228+
out.append(bytes(raw).decode("utf-8", errors="surrogateescape"))
229+
raw.clear()
230+
231+
i, n = 0, len(text)
232+
while i < n:
233+
ch = text[i]
234+
if ch != "\\" or i + 1 >= n:
235+
flush_octal()
236+
out.append(ch)
237+
i += 1
238+
continue
239+
nxt = text[i + 1]
240+
if nxt in "01234567":
241+
digits = text[i + 1 : i + 4]
242+
length = 0
243+
while length < 3 and length < len(digits) and digits[length] in "01234567":
244+
length += 1
245+
raw.extend(int(digits[:length], 8).to_bytes(1, "big"))
246+
i += 1 + length
247+
continue
248+
flush_octal()
249+
if nxt == "n":
250+
out.append("\n")
251+
elif nxt == "t":
252+
out.append("\t")
253+
elif nxt == "\\":
254+
out.append("\\")
255+
elif nxt == '"':
256+
out.append('"')
257+
else:
258+
out.append(ch)
259+
out.append(nxt)
260+
i += 2
261+
flush_octal()
262+
return "".join(out)
263+
264+
265+
def _decode_quoted(path: str) -> str:
266+
"""Remove git's C-style quoting when present; bare paths pass through."""
187267
if len(path) >= 2 and path.startswith('"') and path.endswith('"'):
188-
path = path[1:-1]
268+
return _unescape_c_quoting(path[1:-1])
269+
return path
270+
271+
272+
def _strip_git_prefix(path: str) -> str:
273+
"""Strip git's a//b/ prefixes, unescaping C-style quoting when present."""
274+
path = _decode_quoted(path)
189275
if path.startswith(("a/", "b/")):
190276
return path[2:]
191277
return path
@@ -202,11 +288,13 @@ def _parse_patch(output: str) -> dict[str, str]:
202288
def flush() -> None:
203289
nonlocal current, minus
204290
if current is not None or minus is not None:
291+
# Rename/copy lines carry bare paths (never a//b/ prefixed), so
292+
# decode quoting only; stripping would eat real a//b/ directories.
205293
for cl in chunk:
206294
if cl.startswith(("rename to ", "copy to ")):
207-
current = _strip_git_prefix(cl.split(" ", 2)[2].rstrip("\n"))
295+
current = _decode_quoted(cl.split(" ", 2)[2].rstrip("\n"))
208296
elif cl.startswith(("rename from ", "copy from ")):
209-
minus = _strip_git_prefix(cl.split(" ", 2)[2].rstrip("\n"))
297+
minus = _decode_quoted(cl.split(" ", 2)[2].rstrip("\n"))
210298
key = current if current is not None and current != "/dev/null" else minus
211299
if key is not None:
212300
if key not in chunks:
@@ -224,13 +312,9 @@ def flush() -> None:
224312
minus = _strip_git_prefix(rest[2:mid])
225313
current = _strip_git_prefix(rest[mid + 3 :])
226314
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
315+
minus, current = _split_header_fallback(rest)
230316
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
317+
minus, current = _split_header_fallback(rest)
234318
chunk = [line]
235319
elif current is None and minus is None:
236320
continue
@@ -248,6 +332,7 @@ def flush() -> None:
248332
for prev in text.splitlines():
249333
if prev.startswith("--- "):
250334
key = _strip_git_prefix(prev[4:].strip())
335+
break
251336
else:
252337
key = plus
253338
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: 80 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,79 @@ 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()
231+
232+
233+
def test_rename_plain_to_quoted_keeps_patch(git_repo: Path) -> None:
234+
commit_file(git_repo, "plain.txt", "line 1\n", "add plain")
235+
subprocess.run(["git", "mv", "plain.txt", 'weird"name.txt'], cwd=git_repo, check=True)
236+
(git_repo / 'weird"name.txt').write_text("line 1\nline 2\n", encoding="utf-8")
237+
subprocess.run(["git", "add", "-A"], cwd=git_repo, check=True)
238+
(diff,) = DiffExtractor(git_repo).staged()
239+
assert (diff.path, diff.old_path, diff.status) == ('weird"name.txt', "plain.txt", "renamed")
240+
assert "+line 2" in diff.patch_text
241+
242+
243+
def test_rename_quoted_to_plain_keeps_patch(git_repo: Path) -> None:
244+
commit_file(git_repo, 'weird"name.txt', "line 1\n", "add weird")
245+
subprocess.run(["git", "mv", 'weird"name.txt', "plain.txt"], cwd=git_repo, check=True)
246+
(git_repo / "plain.txt").write_text("line 1\nline 2\n", encoding="utf-8")
247+
subprocess.run(["git", "add", "-A"], cwd=git_repo, check=True)
248+
(diff,) = DiffExtractor(git_repo).staged()
249+
assert (diff.path, diff.old_path, diff.status) == ("plain.txt", 'weird"name.txt', "renamed")
250+
assert "+line 2" in diff.patch_text
251+
252+
253+
def test_pure_rename_into_a_dir_keeps_patch(git_repo: Path) -> None:
254+
commit_file(git_repo, "old.py", "line 1\n", "add old")
255+
(git_repo / "a").mkdir()
256+
subprocess.run(["git", "mv", "old.py", "a/foo.py"], cwd=git_repo, check=True)
257+
subprocess.run(["git", "add", "-A"], cwd=git_repo, check=True)
258+
(diff,) = DiffExtractor(git_repo).staged()
259+
assert (diff.path, diff.old_path, diff.status) == ("a/foo.py", "old.py", "renamed")
260+
assert "rename to a/foo.py" in diff.patch_text

0 commit comments

Comments
 (0)