pi-mono-coding 0.1.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- pi_coding/__init__.py +215 -0
- pi_coding/config.py +59 -0
- pi_coding/core/__init__.py +5 -0
- pi_coding/core/defaults.py +4 -0
- pi_coding/tools/__init__.py +70 -0
- pi_coding/tools/bash.py +213 -0
- pi_coding/tools/edit.py +176 -0
- pi_coding/tools/find.py +182 -0
- pi_coding/tools/grep.py +280 -0
- pi_coding/tools/ls.py +126 -0
- pi_coding/tools/read.py +210 -0
- pi_coding/tools/write.py +99 -0
- pi_coding/utils/__init__.py +91 -0
- pi_coding/utils/edit_diff.py +374 -0
- pi_coding/utils/git.py +201 -0
- pi_coding/utils/path_utils.py +129 -0
- pi_coding/utils/shell.py +143 -0
- pi_coding/utils/truncate.py +274 -0
- pi_mono_coding-0.1.0.dist-info/METADATA +485 -0
- pi_mono_coding-0.1.0.dist-info/RECORD +21 -0
- pi_mono_coding-0.1.0.dist-info/WHEEL +4 -0
|
@@ -0,0 +1,374 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Shared diff computation utilities for the edit tool.
|
|
3
|
+
Used by both edit.py (for execution) and tool-execution.py (for preview rendering).
|
|
4
|
+
|
|
5
|
+
Python port of edit-diff.ts — uses difflib instead of npm's 'diff' package.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import difflib
|
|
11
|
+
import os
|
|
12
|
+
import re
|
|
13
|
+
from dataclasses import dataclass
|
|
14
|
+
from typing import Literal, Optional
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def detect_line_ending(content: str) -> Literal["\r\n", "\n"]:
|
|
18
|
+
"""Detect the dominant line ending in content."""
|
|
19
|
+
crlf_idx = content.find("\r\n")
|
|
20
|
+
lf_idx = content.find("\n")
|
|
21
|
+
if lf_idx == -1:
|
|
22
|
+
return "\n"
|
|
23
|
+
if crlf_idx == -1:
|
|
24
|
+
return "\n"
|
|
25
|
+
return "\r\n" if crlf_idx < lf_idx else "\n"
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def normalize_to_lf(text: str) -> str:
|
|
29
|
+
"""Normalize all line endings to LF."""
|
|
30
|
+
return text.replace("\r\n", "\n").replace("\r", "\n")
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def restore_line_endings(text: str, ending: str) -> str:
|
|
34
|
+
"""Restore line endings to the specified type."""
|
|
35
|
+
return text.replace("\n", "\r\n") if ending == "\r\n" else text
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def normalize_for_fuzzy_match(text: str) -> str:
|
|
39
|
+
"""
|
|
40
|
+
Normalize text for fuzzy matching. Applies progressive transformations:
|
|
41
|
+
- Strip trailing whitespace from each line
|
|
42
|
+
- Normalize smart quotes to ASCII equivalents
|
|
43
|
+
- Normalize Unicode dashes/hyphens to ASCII hyphen
|
|
44
|
+
- Normalize special Unicode spaces to regular space
|
|
45
|
+
"""
|
|
46
|
+
# Strip trailing whitespace per line
|
|
47
|
+
result = "\n".join(line.rstrip() for line in text.split("\n"))
|
|
48
|
+
# Smart single quotes → '
|
|
49
|
+
result = re.sub("[\u2018\u2019\u201a\u201b]", "'", result)
|
|
50
|
+
# Smart double quotes → "
|
|
51
|
+
result = re.sub('[\u201c\u201d\u201e\u201f]', '"', result)
|
|
52
|
+
# Various dashes/hyphens → -
|
|
53
|
+
# U+2010 hyphen, U+2011 non-breaking hyphen, U+2012 figure dash,
|
|
54
|
+
# U+2013 en-dash, U+2014 em-dash, U+2015 horizontal bar, U+2212 minus
|
|
55
|
+
result = re.sub("[\u2010\u2011\u2012\u2013\u2014\u2015\u2212]", "-", result)
|
|
56
|
+
# Special spaces → regular space
|
|
57
|
+
# U+00A0 NBSP, U+2002-U+200A various spaces, U+202F narrow NBSP,
|
|
58
|
+
# U+205F medium math space, U+3000 ideographic space
|
|
59
|
+
result = re.sub("[\u00a0\u2002-\u200a\u202f\u205f\u3000]", " ", result)
|
|
60
|
+
return result
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
@dataclass
|
|
64
|
+
class FuzzyMatchResult:
|
|
65
|
+
"""Result of a fuzzy text match."""
|
|
66
|
+
|
|
67
|
+
found: bool
|
|
68
|
+
"""Whether a match was found."""
|
|
69
|
+
|
|
70
|
+
index: int
|
|
71
|
+
"""The index where the match starts (in the content used for replacement)."""
|
|
72
|
+
|
|
73
|
+
match_length: int
|
|
74
|
+
"""Length of the matched text."""
|
|
75
|
+
|
|
76
|
+
used_fuzzy_match: bool
|
|
77
|
+
"""Whether fuzzy matching was used (False = exact match)."""
|
|
78
|
+
|
|
79
|
+
content_for_replacement: str
|
|
80
|
+
"""The content to use for replacement operations.
|
|
81
|
+
When exact match: original content. When fuzzy match: normalized content."""
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
def fuzzy_find_text(content: str, old_text: str) -> FuzzyMatchResult:
|
|
85
|
+
"""
|
|
86
|
+
Find *old_text* in *content*, trying exact match first, then fuzzy match.
|
|
87
|
+
|
|
88
|
+
When fuzzy matching is used, the returned ``content_for_replacement`` is the
|
|
89
|
+
fuzzy-normalized version of the content (trailing whitespace stripped,
|
|
90
|
+
Unicode quotes/dashes normalised to ASCII).
|
|
91
|
+
"""
|
|
92
|
+
# Try exact match first
|
|
93
|
+
exact_index = content.find(old_text)
|
|
94
|
+
if exact_index != -1:
|
|
95
|
+
return FuzzyMatchResult(
|
|
96
|
+
found=True,
|
|
97
|
+
index=exact_index,
|
|
98
|
+
match_length=len(old_text),
|
|
99
|
+
used_fuzzy_match=False,
|
|
100
|
+
content_for_replacement=content,
|
|
101
|
+
)
|
|
102
|
+
|
|
103
|
+
# Try fuzzy match – work entirely in normalized space
|
|
104
|
+
fuzzy_content = normalize_for_fuzzy_match(content)
|
|
105
|
+
fuzzy_old_text = normalize_for_fuzzy_match(old_text)
|
|
106
|
+
fuzzy_index = fuzzy_content.find(fuzzy_old_text)
|
|
107
|
+
|
|
108
|
+
if fuzzy_index == -1:
|
|
109
|
+
return FuzzyMatchResult(
|
|
110
|
+
found=False,
|
|
111
|
+
index=-1,
|
|
112
|
+
match_length=0,
|
|
113
|
+
used_fuzzy_match=False,
|
|
114
|
+
content_for_replacement=content,
|
|
115
|
+
)
|
|
116
|
+
|
|
117
|
+
# When fuzzy matching, we work in the normalized space for replacement.
|
|
118
|
+
# This means the output will have normalized whitespace/quotes/dashes,
|
|
119
|
+
# which is acceptable since we're fixing minor formatting differences anyway.
|
|
120
|
+
return FuzzyMatchResult(
|
|
121
|
+
found=True,
|
|
122
|
+
index=fuzzy_index,
|
|
123
|
+
match_length=len(fuzzy_old_text),
|
|
124
|
+
used_fuzzy_match=True,
|
|
125
|
+
content_for_replacement=fuzzy_content,
|
|
126
|
+
)
|
|
127
|
+
|
|
128
|
+
|
|
129
|
+
def strip_bom(content: str) -> tuple[str, str]:
|
|
130
|
+
"""Strip UTF-8 BOM if present.
|
|
131
|
+
|
|
132
|
+
Returns:
|
|
133
|
+
A ``(bom, text)`` tuple where *bom* is the BOM character (or ``""``)
|
|
134
|
+
and *text* is the content without it.
|
|
135
|
+
"""
|
|
136
|
+
if content.startswith("\ufeff"):
|
|
137
|
+
return ("\ufeff", content[1:])
|
|
138
|
+
return ("", content)
|
|
139
|
+
|
|
140
|
+
|
|
141
|
+
# ---------------------------------------------------------------------------
|
|
142
|
+
# Internal helpers for line-level diffing (replaces npm 'diff' package)
|
|
143
|
+
# ---------------------------------------------------------------------------
|
|
144
|
+
|
|
145
|
+
@dataclass
|
|
146
|
+
class _DiffPart:
|
|
147
|
+
"""Internal representation of a diff part, analogous to Diff.diffLines output."""
|
|
148
|
+
|
|
149
|
+
value: str
|
|
150
|
+
added: bool = False
|
|
151
|
+
removed: bool = False
|
|
152
|
+
|
|
153
|
+
|
|
154
|
+
def _diff_lines(old_content: str, new_content: str) -> list[_DiffPart]:
|
|
155
|
+
"""Compute line-level diffs using :mod:`difflib`, returning a list of
|
|
156
|
+
:class:`_DiffPart` objects compatible with the npm ``Diff.diffLines`` shape.
|
|
157
|
+
"""
|
|
158
|
+
old_lines = old_content.splitlines(True)
|
|
159
|
+
new_lines = new_content.splitlines(True)
|
|
160
|
+
|
|
161
|
+
matcher = difflib.SequenceMatcher(None, old_lines, new_lines)
|
|
162
|
+
parts: list[_DiffPart] = []
|
|
163
|
+
|
|
164
|
+
for tag, i1, i2, j1, j2 in matcher.get_opcodes():
|
|
165
|
+
if tag == "equal":
|
|
166
|
+
parts.append(_DiffPart(value="".join(old_lines[i1:i2])))
|
|
167
|
+
elif tag == "replace":
|
|
168
|
+
parts.append(_DiffPart(value="".join(old_lines[i1:i2]), removed=True))
|
|
169
|
+
parts.append(_DiffPart(value="".join(new_lines[j1:j2]), added=True))
|
|
170
|
+
elif tag == "delete":
|
|
171
|
+
parts.append(_DiffPart(value="".join(old_lines[i1:i2]), removed=True))
|
|
172
|
+
elif tag == "insert":
|
|
173
|
+
parts.append(_DiffPart(value="".join(new_lines[j1:j2]), added=True))
|
|
174
|
+
|
|
175
|
+
return parts
|
|
176
|
+
|
|
177
|
+
|
|
178
|
+
# ---------------------------------------------------------------------------
|
|
179
|
+
# Public diff generation
|
|
180
|
+
# ---------------------------------------------------------------------------
|
|
181
|
+
|
|
182
|
+
def generate_diff_string(
|
|
183
|
+
old_content: str,
|
|
184
|
+
new_content: str,
|
|
185
|
+
context_lines: int = 4,
|
|
186
|
+
) -> dict[str, str | int | None]:
|
|
187
|
+
"""Generate a unified diff string with line numbers and context.
|
|
188
|
+
|
|
189
|
+
Returns:
|
|
190
|
+
A dict with keys ``"diff"`` (str) and ``"first_changed_line"``
|
|
191
|
+
(int | None).
|
|
192
|
+
"""
|
|
193
|
+
parts = _diff_lines(old_content, new_content)
|
|
194
|
+
output: list[str] = []
|
|
195
|
+
|
|
196
|
+
old_lines = old_content.split("\n")
|
|
197
|
+
new_lines = new_content.split("\n")
|
|
198
|
+
max_line_num = max(len(old_lines), len(new_lines))
|
|
199
|
+
line_num_width = len(str(max_line_num))
|
|
200
|
+
|
|
201
|
+
old_line_num = 1
|
|
202
|
+
new_line_num = 1
|
|
203
|
+
last_was_change = False
|
|
204
|
+
first_changed_line: Optional[int] = None
|
|
205
|
+
|
|
206
|
+
for i, part in enumerate(parts):
|
|
207
|
+
raw = part.value.split("\n")
|
|
208
|
+
if raw and raw[-1] == "":
|
|
209
|
+
raw.pop()
|
|
210
|
+
|
|
211
|
+
if part.added or part.removed:
|
|
212
|
+
# Capture the first changed line (in the new file)
|
|
213
|
+
if first_changed_line is None:
|
|
214
|
+
first_changed_line = new_line_num
|
|
215
|
+
|
|
216
|
+
# Show the change
|
|
217
|
+
for line in raw:
|
|
218
|
+
if part.added:
|
|
219
|
+
line_num_str = str(new_line_num).rjust(line_num_width)
|
|
220
|
+
output.append(f"+{line_num_str} {line}")
|
|
221
|
+
new_line_num += 1
|
|
222
|
+
else: # removed
|
|
223
|
+
line_num_str = str(old_line_num).rjust(line_num_width)
|
|
224
|
+
output.append(f"-{line_num_str} {line}")
|
|
225
|
+
old_line_num += 1
|
|
226
|
+
last_was_change = True
|
|
227
|
+
else:
|
|
228
|
+
# Context lines – only show a few before/after changes
|
|
229
|
+
next_part_is_change = (
|
|
230
|
+
i < len(parts) - 1
|
|
231
|
+
and (parts[i + 1].added or parts[i + 1].removed)
|
|
232
|
+
)
|
|
233
|
+
|
|
234
|
+
if last_was_change or next_part_is_change:
|
|
235
|
+
lines_to_show = list(raw)
|
|
236
|
+
skip_start = 0
|
|
237
|
+
skip_end = 0
|
|
238
|
+
|
|
239
|
+
if not last_was_change:
|
|
240
|
+
# Show only last N lines as leading context
|
|
241
|
+
skip_start = max(0, len(raw) - context_lines)
|
|
242
|
+
lines_to_show = raw[skip_start:]
|
|
243
|
+
|
|
244
|
+
if not next_part_is_change and len(lines_to_show) > context_lines:
|
|
245
|
+
# Show only first N lines as trailing context
|
|
246
|
+
skip_end = len(lines_to_show) - context_lines
|
|
247
|
+
lines_to_show = lines_to_show[:context_lines]
|
|
248
|
+
|
|
249
|
+
# Add ellipsis if we skipped lines at start
|
|
250
|
+
if skip_start > 0:
|
|
251
|
+
output.append(f" {''.rjust(line_num_width)} ...")
|
|
252
|
+
old_line_num += skip_start
|
|
253
|
+
new_line_num += skip_start
|
|
254
|
+
|
|
255
|
+
for line in lines_to_show:
|
|
256
|
+
line_num_str = str(old_line_num).rjust(line_num_width)
|
|
257
|
+
output.append(f" {line_num_str} {line}")
|
|
258
|
+
old_line_num += 1
|
|
259
|
+
new_line_num += 1
|
|
260
|
+
|
|
261
|
+
# Add ellipsis if we skipped lines at end
|
|
262
|
+
if skip_end > 0:
|
|
263
|
+
output.append(f" {''.rjust(line_num_width)} ...")
|
|
264
|
+
old_line_num += skip_end
|
|
265
|
+
new_line_num += skip_end
|
|
266
|
+
else:
|
|
267
|
+
# Skip these context lines entirely
|
|
268
|
+
old_line_num += len(raw)
|
|
269
|
+
new_line_num += len(raw)
|
|
270
|
+
|
|
271
|
+
last_was_change = False
|
|
272
|
+
|
|
273
|
+
return {"diff": "\n".join(output), "first_changed_line": first_changed_line}
|
|
274
|
+
|
|
275
|
+
|
|
276
|
+
# ---------------------------------------------------------------------------
|
|
277
|
+
# High-level edit-diff entry point
|
|
278
|
+
# ---------------------------------------------------------------------------
|
|
279
|
+
|
|
280
|
+
@dataclass
|
|
281
|
+
class EditDiffResult:
|
|
282
|
+
"""Result of a successful diff computation."""
|
|
283
|
+
|
|
284
|
+
diff: str
|
|
285
|
+
first_changed_line: Optional[int]
|
|
286
|
+
|
|
287
|
+
|
|
288
|
+
@dataclass
|
|
289
|
+
class EditDiffError:
|
|
290
|
+
"""Result when diff computation encounters an error."""
|
|
291
|
+
|
|
292
|
+
error: str
|
|
293
|
+
|
|
294
|
+
|
|
295
|
+
def compute_edit_diff(
|
|
296
|
+
path: str,
|
|
297
|
+
old_text: str,
|
|
298
|
+
new_text: str,
|
|
299
|
+
cwd: str,
|
|
300
|
+
) -> EditDiffResult | EditDiffError:
|
|
301
|
+
"""Compute the diff for an edit operation without applying it.
|
|
302
|
+
|
|
303
|
+
Used for preview rendering in the TUI before the tool executes.
|
|
304
|
+
"""
|
|
305
|
+
# Resolve path relative to cwd
|
|
306
|
+
absolute_path = path if os.path.isabs(path) else os.path.join(cwd, path)
|
|
307
|
+
|
|
308
|
+
try:
|
|
309
|
+
# Check if file exists and is readable
|
|
310
|
+
if not os.path.isfile(absolute_path) or not os.access(absolute_path, os.R_OK):
|
|
311
|
+
return EditDiffError(error=f"File not found: {path}")
|
|
312
|
+
|
|
313
|
+
# Read the file
|
|
314
|
+
with open(absolute_path, encoding="utf-8") as f:
|
|
315
|
+
raw_content = f.read()
|
|
316
|
+
|
|
317
|
+
# Strip BOM before matching (LLM won't include invisible BOM in old_text)
|
|
318
|
+
_, content = strip_bom(raw_content)
|
|
319
|
+
|
|
320
|
+
normalized_content = normalize_to_lf(content)
|
|
321
|
+
normalized_old_text = normalize_to_lf(old_text)
|
|
322
|
+
normalized_new_text = normalize_to_lf(new_text)
|
|
323
|
+
|
|
324
|
+
# Find the old text using fuzzy matching (tries exact first, then fuzzy)
|
|
325
|
+
match_result = fuzzy_find_text(normalized_content, normalized_old_text)
|
|
326
|
+
|
|
327
|
+
if not match_result.found:
|
|
328
|
+
return EditDiffError(
|
|
329
|
+
error=(
|
|
330
|
+
f"Could not find the exact text in {path}. "
|
|
331
|
+
"The old text must match exactly including all whitespace and newlines."
|
|
332
|
+
),
|
|
333
|
+
)
|
|
334
|
+
|
|
335
|
+
# Count occurrences using fuzzy-normalized content for consistency
|
|
336
|
+
fuzzy_content = normalize_for_fuzzy_match(normalized_content)
|
|
337
|
+
fuzzy_old_text = normalize_for_fuzzy_match(normalized_old_text)
|
|
338
|
+
occurrences = fuzzy_content.count(fuzzy_old_text)
|
|
339
|
+
|
|
340
|
+
if occurrences > 1:
|
|
341
|
+
return EditDiffError(
|
|
342
|
+
error=(
|
|
343
|
+
f"Found {occurrences} occurrences of the text in {path}. "
|
|
344
|
+
"The text must be unique. Please provide more context to make it unique."
|
|
345
|
+
),
|
|
346
|
+
)
|
|
347
|
+
|
|
348
|
+
# Compute the new content using the matched position
|
|
349
|
+
# When fuzzy matching was used, content_for_replacement is the normalized version
|
|
350
|
+
base_content = match_result.content_for_replacement
|
|
351
|
+
new_content = (
|
|
352
|
+
base_content[: match_result.index]
|
|
353
|
+
+ normalized_new_text
|
|
354
|
+
+ base_content[match_result.index + match_result.match_length :]
|
|
355
|
+
)
|
|
356
|
+
|
|
357
|
+
# Check if it would actually change anything
|
|
358
|
+
if base_content == new_content:
|
|
359
|
+
return EditDiffError(
|
|
360
|
+
error=(
|
|
361
|
+
f"No changes would be made to {path}. "
|
|
362
|
+
"The replacement produces identical content."
|
|
363
|
+
),
|
|
364
|
+
)
|
|
365
|
+
|
|
366
|
+
# Generate the diff
|
|
367
|
+
result = generate_diff_string(base_content, new_content)
|
|
368
|
+
return EditDiffResult(
|
|
369
|
+
diff=str(result["diff"]),
|
|
370
|
+
first_changed_line=int(result["first_changed_line"]) if result["first_changed_line"] is not None else None,
|
|
371
|
+
)
|
|
372
|
+
|
|
373
|
+
except Exception as err:
|
|
374
|
+
return EditDiffError(error=str(err))
|
pi_coding/utils/git.py
ADDED
|
@@ -0,0 +1,201 @@
|
|
|
1
|
+
from dataclasses import dataclass
|
|
2
|
+
import subprocess
|
|
3
|
+
import re
|
|
4
|
+
from urllib.parse import urlparse
|
|
5
|
+
from typing import Optional
|
|
6
|
+
|
|
7
|
+
@dataclass
|
|
8
|
+
class GitSource:
|
|
9
|
+
"""Parsed git URL information."""
|
|
10
|
+
repo: str
|
|
11
|
+
host: str
|
|
12
|
+
path: str
|
|
13
|
+
type: str = "git"
|
|
14
|
+
ref: Optional[str] = None
|
|
15
|
+
pinned: bool = False
|
|
16
|
+
|
|
17
|
+
def split_ref(url: str) -> dict:
|
|
18
|
+
"""Split a git URL into repo and ref parts."""
|
|
19
|
+
# Handle SCP-like URLs: git@github.com:user/repo@ref
|
|
20
|
+
scp_match = re.match(r"^git@([^:]+):(.+)$", url)
|
|
21
|
+
if scp_match:
|
|
22
|
+
host = scp_match.group(1)
|
|
23
|
+
path_with_maybe_ref = scp_match.group(2)
|
|
24
|
+
if "@" in path_with_maybe_ref:
|
|
25
|
+
repo_path, ref = path_with_maybe_ref.split("@", 1)
|
|
26
|
+
if repo_path and ref:
|
|
27
|
+
return {
|
|
28
|
+
"repo": f"git@{host}:{repo_path}",
|
|
29
|
+
"ref": ref
|
|
30
|
+
}
|
|
31
|
+
return {"repo": url}
|
|
32
|
+
|
|
33
|
+
# Handle URLs with protocols: https://github.com/user/repo@ref
|
|
34
|
+
if "://" in url:
|
|
35
|
+
try:
|
|
36
|
+
parsed = urlparse(url)
|
|
37
|
+
path_with_maybe_ref = parsed.path.lstrip("/")
|
|
38
|
+
if "@" in path_with_maybe_ref:
|
|
39
|
+
repo_path, ref = path_with_maybe_ref.split("@", 1)
|
|
40
|
+
if repo_path and ref:
|
|
41
|
+
# Reconstruct URL without ref
|
|
42
|
+
new_path = f"/{repo_path}"
|
|
43
|
+
new_url = parsed._replace(path=new_path).geturl()
|
|
44
|
+
return {
|
|
45
|
+
"repo": new_url.rstrip("/"),
|
|
46
|
+
"ref": ref
|
|
47
|
+
}
|
|
48
|
+
return {"repo": url}
|
|
49
|
+
except Exception:
|
|
50
|
+
return {"repo": url}
|
|
51
|
+
|
|
52
|
+
# Handle shorthand: github.com/user/repo@ref
|
|
53
|
+
if "/" in url:
|
|
54
|
+
host, path_with_maybe_ref = url.split("/", 1)
|
|
55
|
+
if "@" in path_with_maybe_ref:
|
|
56
|
+
repo_path, ref = path_with_maybe_ref.split("@", 1)
|
|
57
|
+
if repo_path and ref:
|
|
58
|
+
return {
|
|
59
|
+
"repo": f"{host}/{repo_path}",
|
|
60
|
+
"ref": ref
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
return {"repo": url}
|
|
64
|
+
|
|
65
|
+
def parse_generic_git_url(url: str) -> Optional[GitSource]:
|
|
66
|
+
"""Parse a git URL that doesn't match hosted patterns."""
|
|
67
|
+
split = split_ref(url)
|
|
68
|
+
repo_without_ref = split["repo"]
|
|
69
|
+
ref = split.get("ref")
|
|
70
|
+
|
|
71
|
+
repo = repo_without_ref
|
|
72
|
+
host = ""
|
|
73
|
+
path = ""
|
|
74
|
+
|
|
75
|
+
scp_match = re.match(r"^git@([^:]+):(.+)$", repo_without_ref)
|
|
76
|
+
if scp_match:
|
|
77
|
+
host = scp_match.group(1)
|
|
78
|
+
path = scp_match.group(2)
|
|
79
|
+
elif any(repo_without_ref.startswith(p) for p in ["https://", "http://", "ssh://", "git://"]):
|
|
80
|
+
try:
|
|
81
|
+
parsed = urlparse(repo_without_ref)
|
|
82
|
+
host = parsed.hostname or ""
|
|
83
|
+
path = parsed.path.lstrip("/")
|
|
84
|
+
except Exception:
|
|
85
|
+
return None
|
|
86
|
+
else:
|
|
87
|
+
if "/" not in repo_without_ref:
|
|
88
|
+
return None
|
|
89
|
+
host, path = repo_without_ref.split("/", 1)
|
|
90
|
+
if "." not in host and host != "localhost":
|
|
91
|
+
return None
|
|
92
|
+
repo = f"https://{repo_without_ref}"
|
|
93
|
+
|
|
94
|
+
normalized_path = path.replace(".git", "").lstrip("/")
|
|
95
|
+
if not host or not normalized_path or len(normalized_path.split("/")) < 2:
|
|
96
|
+
return None
|
|
97
|
+
|
|
98
|
+
return GitSource(
|
|
99
|
+
repo=repo,
|
|
100
|
+
host=host,
|
|
101
|
+
path=normalized_path,
|
|
102
|
+
ref=ref,
|
|
103
|
+
pinned=bool(ref)
|
|
104
|
+
)
|
|
105
|
+
|
|
106
|
+
def parse_git_url(source: str) -> Optional[GitSource]:
|
|
107
|
+
"""
|
|
108
|
+
Parse git source into a GitSource.
|
|
109
|
+
|
|
110
|
+
Supports:
|
|
111
|
+
- git@github.com:user/repo
|
|
112
|
+
- https://github.com/user/repo
|
|
113
|
+
- git:github.com/user/repo
|
|
114
|
+
- Any of the above with @ref suffix
|
|
115
|
+
"""
|
|
116
|
+
trimmed = source.strip()
|
|
117
|
+
has_git_prefix = trimmed.startswith("git:")
|
|
118
|
+
url = trimmed[4:].strip() if has_git_prefix else trimmed
|
|
119
|
+
|
|
120
|
+
# Without git: prefix, only accept explicit protocol URLs or SCP-like
|
|
121
|
+
if not has_git_prefix and not re.match(r"^(https?|ssh|git)://", url, re.I) and not url.startswith("git@"):
|
|
122
|
+
return None
|
|
123
|
+
|
|
124
|
+
# The TS implementation uses hosted-git-info which handles many shorthands.
|
|
125
|
+
# For this Python port, we'll focus on the core requirements and generic parsing.
|
|
126
|
+
# Since we don't have a direct equivalent of hosted-git-info in stdlib,
|
|
127
|
+
# and the task asks for a port of the logic, we'll use parse_generic_git_url
|
|
128
|
+
# which covers the requested formats.
|
|
129
|
+
|
|
130
|
+
return parse_generic_git_url(url)
|
|
131
|
+
|
|
132
|
+
def get_current_branch(cwd: Optional[str] = None) -> Optional[str]:
|
|
133
|
+
"""Get current git branch."""
|
|
134
|
+
try:
|
|
135
|
+
result = subprocess.run(
|
|
136
|
+
["git", "symbolic-ref", "--short", "HEAD"],
|
|
137
|
+
cwd=cwd,
|
|
138
|
+
capture_output=True,
|
|
139
|
+
text=True
|
|
140
|
+
)
|
|
141
|
+
if result.returncode == 0:
|
|
142
|
+
return result.stdout.strip()
|
|
143
|
+
|
|
144
|
+
result = subprocess.run(
|
|
145
|
+
["git", "rev-parse", "--abbrev-ref", "HEAD"],
|
|
146
|
+
cwd=cwd,
|
|
147
|
+
capture_output=True,
|
|
148
|
+
text=True
|
|
149
|
+
)
|
|
150
|
+
if result.returncode == 0:
|
|
151
|
+
return result.stdout.strip()
|
|
152
|
+
|
|
153
|
+
return None
|
|
154
|
+
except (subprocess.CalledProcessError, FileNotFoundError):
|
|
155
|
+
return None
|
|
156
|
+
|
|
157
|
+
def get_repo_status(cwd: Optional[str] = None) -> dict[str, bool]:
|
|
158
|
+
"""Get repo status."""
|
|
159
|
+
try:
|
|
160
|
+
result = subprocess.run(
|
|
161
|
+
["git", "status", "--porcelain"],
|
|
162
|
+
cwd=cwd,
|
|
163
|
+
capture_output=True,
|
|
164
|
+
text=True,
|
|
165
|
+
check=True
|
|
166
|
+
)
|
|
167
|
+
output = result.stdout
|
|
168
|
+
lines = output.splitlines()
|
|
169
|
+
|
|
170
|
+
has_unstaged_changes = False
|
|
171
|
+
has_staged_changes = False
|
|
172
|
+
has_untracked_files = False
|
|
173
|
+
|
|
174
|
+
for line in lines:
|
|
175
|
+
if len(line) < 2:
|
|
176
|
+
continue
|
|
177
|
+
index_status = line[0]
|
|
178
|
+
worktree_status = line[1]
|
|
179
|
+
|
|
180
|
+
if index_status == "?":
|
|
181
|
+
has_untracked_files = True
|
|
182
|
+
if index_status not in (" ", "?"):
|
|
183
|
+
has_staged_changes = True
|
|
184
|
+
if worktree_status not in (" ", "?"):
|
|
185
|
+
has_unstaged_changes = True
|
|
186
|
+
|
|
187
|
+
is_clean = not (has_unstaged_changes or has_staged_changes or has_untracked_files)
|
|
188
|
+
|
|
189
|
+
return {
|
|
190
|
+
"has_unstaged_changes": has_unstaged_changes,
|
|
191
|
+
"has_staged_changes": has_staged_changes,
|
|
192
|
+
"has_untracked_files": has_untracked_files,
|
|
193
|
+
"is_clean": is_clean
|
|
194
|
+
}
|
|
195
|
+
except (subprocess.CalledProcessError, FileNotFoundError):
|
|
196
|
+
return {
|
|
197
|
+
"has_unstaged_changes": False,
|
|
198
|
+
"has_staged_changes": False,
|
|
199
|
+
"has_untracked_files": False,
|
|
200
|
+
"is_clean": False
|
|
201
|
+
}
|
|
@@ -0,0 +1,129 @@
|
|
|
1
|
+
"""Path resolution utilities — Python port of path-utils.ts.
|
|
2
|
+
|
|
3
|
+
Handles macOS-specific filename quirks (NFD normalization, curly quotes,
|
|
4
|
+
narrow no-break spaces in screenshot timestamps, etc.).
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
import os
|
|
8
|
+
import re
|
|
9
|
+
import unicodedata
|
|
10
|
+
|
|
11
|
+
# Matches various Unicode space characters that should be normalized to ASCII space.
|
|
12
|
+
UNICODE_SPACES = re.compile(
|
|
13
|
+
"[\u00A0\u2000-\u200A\u202F\u205F\u3000]"
|
|
14
|
+
)
|
|
15
|
+
|
|
16
|
+
_NARROW_NO_BREAK_SPACE = "\u202F"
|
|
17
|
+
|
|
18
|
+
# Matches " AM." or " PM." preceded by a regular space (macOS screenshot timestamps).
|
|
19
|
+
_AM_PM_RE = re.compile(r" (AM|PM)\.")
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def normalize_unicode_spaces(s: str) -> str:
|
|
23
|
+
"""Replace exotic Unicode whitespace characters with plain ASCII space."""
|
|
24
|
+
return UNICODE_SPACES.sub(" ", s)
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def try_macos_screenshot_path(file_path: str) -> str:
|
|
28
|
+
"""Return *file_path* with narrow no-break space before AM/PM.
|
|
29
|
+
|
|
30
|
+
macOS screenshot filenames use U+202F (narrow no-break space) before the
|
|
31
|
+
AM/PM marker, but users typically type a regular space.
|
|
32
|
+
"""
|
|
33
|
+
return _AM_PM_RE.sub(f"{_NARROW_NO_BREAK_SPACE}\\1.", file_path)
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def try_nfd_variant(file_path: str) -> str:
|
|
37
|
+
"""Return the NFD (decomposed) form of *file_path*.
|
|
38
|
+
|
|
39
|
+
macOS (HFS+/APFS) stores filenames in NFD form, so converting user input
|
|
40
|
+
to NFD can resolve mismatches caused by composed characters.
|
|
41
|
+
"""
|
|
42
|
+
return unicodedata.normalize("NFD", file_path)
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def try_curly_quote_variant(file_path: str) -> str:
|
|
46
|
+
"""Replace straight apostrophes with right single quotation marks.
|
|
47
|
+
|
|
48
|
+
macOS uses U+2019 (') in screenshot names such as ``Capture d\u2019\u00e9cran``,
|
|
49
|
+
but users typically type U+0027 (').
|
|
50
|
+
"""
|
|
51
|
+
return file_path.replace("'", "\u2019")
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def file_exists(file_path: str) -> bool:
|
|
55
|
+
"""Return ``True`` if *file_path* exists on disk (file or directory)."""
|
|
56
|
+
try:
|
|
57
|
+
os.stat(file_path)
|
|
58
|
+
return True
|
|
59
|
+
except OSError:
|
|
60
|
+
return False
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def normalize_at_prefix(file_path: str) -> str:
|
|
64
|
+
"""Strip a leading ``@`` prefix from *file_path*."""
|
|
65
|
+
if file_path.startswith("@"):
|
|
66
|
+
return file_path[1:]
|
|
67
|
+
return file_path
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def expand_path(file_path: str) -> str:
|
|
71
|
+
"""Expand ``~`` to the user's home directory and normalize Unicode spaces."""
|
|
72
|
+
normalized = normalize_unicode_spaces(normalize_at_prefix(file_path))
|
|
73
|
+
if normalized == "~":
|
|
74
|
+
return os.path.expanduser("~")
|
|
75
|
+
if normalized.startswith("~/"):
|
|
76
|
+
return os.path.expanduser("~") + normalized[1:]
|
|
77
|
+
return normalized
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
def resolve_to_cwd(file_path: str, cwd: str) -> str:
|
|
81
|
+
"""Resolve *file_path* relative to *cwd*.
|
|
82
|
+
|
|
83
|
+
Handles ``~`` expansion and absolute paths.
|
|
84
|
+
"""
|
|
85
|
+
expanded = expand_path(file_path)
|
|
86
|
+
if os.path.isabs(expanded):
|
|
87
|
+
return expanded
|
|
88
|
+
return os.path.normpath(os.path.join(cwd, expanded))
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
def resolve_read_path(file_path: str, cwd: str) -> str:
|
|
92
|
+
"""Resolve *file_path* for reading, trying macOS filename variants.
|
|
93
|
+
|
|
94
|
+
Attempts the following variants in order until one exists on disk:
|
|
95
|
+
|
|
96
|
+
1. Direct resolved path
|
|
97
|
+
2. macOS AM/PM narrow no-break space variant
|
|
98
|
+
3. NFD (decomposed) variant
|
|
99
|
+
4. Curly quote variant (U+2019)
|
|
100
|
+
5. Combined NFD + curly quote variant
|
|
101
|
+
|
|
102
|
+
If none exist, the original resolved path is returned.
|
|
103
|
+
"""
|
|
104
|
+
resolved = resolve_to_cwd(file_path, cwd)
|
|
105
|
+
|
|
106
|
+
if file_exists(resolved):
|
|
107
|
+
return resolved
|
|
108
|
+
|
|
109
|
+
# Try macOS AM/PM variant (narrow no-break space before AM/PM)
|
|
110
|
+
am_pm_variant = try_macos_screenshot_path(resolved)
|
|
111
|
+
if am_pm_variant != resolved and file_exists(am_pm_variant):
|
|
112
|
+
return am_pm_variant
|
|
113
|
+
|
|
114
|
+
# Try NFD variant (macOS stores filenames in NFD form)
|
|
115
|
+
nfd_variant = try_nfd_variant(resolved)
|
|
116
|
+
if nfd_variant != resolved and file_exists(nfd_variant):
|
|
117
|
+
return nfd_variant
|
|
118
|
+
|
|
119
|
+
# Try curly quote variant (macOS uses U+2019 in screenshot names)
|
|
120
|
+
curly_variant = try_curly_quote_variant(resolved)
|
|
121
|
+
if curly_variant != resolved and file_exists(curly_variant):
|
|
122
|
+
return curly_variant
|
|
123
|
+
|
|
124
|
+
# Try combined NFD + curly quote (for French macOS screenshots like "Capture d'écran")
|
|
125
|
+
nfd_curly_variant = try_curly_quote_variant(nfd_variant)
|
|
126
|
+
if nfd_curly_variant != resolved and file_exists(nfd_curly_variant):
|
|
127
|
+
return nfd_curly_variant
|
|
128
|
+
|
|
129
|
+
return resolved
|