gitwise-cli 0.24.2__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.
- gitwise/__init__.py +11 -0
- gitwise/__main__.py +113 -0
- gitwise/_cli_completions.py +88 -0
- gitwise/_cli_dispatch.py +469 -0
- gitwise/_cli_introspection.py +275 -0
- gitwise/_cli_parser.py +345 -0
- gitwise/_cli_setup_agents.py +439 -0
- gitwise/_i18n_data.json +1934 -0
- gitwise/_paths.py +22 -0
- gitwise/_runtime_config.py +246 -0
- gitwise/audit.py +338 -0
- gitwise/branches.py +183 -0
- gitwise/clean.py +197 -0
- gitwise/commit.py +142 -0
- gitwise/conflicts.py +112 -0
- gitwise/context.py +163 -0
- gitwise/design.py +383 -0
- gitwise/diff.py +309 -0
- gitwise/doctor.py +116 -0
- gitwise/git.py +254 -0
- gitwise/health.py +345 -0
- gitwise/i18n.py +99 -0
- gitwise/log.py +329 -0
- gitwise/merge.py +193 -0
- gitwise/optimize.py +212 -0
- gitwise/output.py +652 -0
- gitwise/pick.py +102 -0
- gitwise/pr.py +543 -0
- gitwise/py.typed +0 -0
- gitwise/schema.py +49 -0
- gitwise/setup.py +551 -0
- gitwise/setup_agents/__init__.py +36 -0
- gitwise/setup_agents/adapters/__init__.py +17 -0
- gitwise/setup_agents/adapters/aider.py +5 -0
- gitwise/setup_agents/adapters/base.py +5 -0
- gitwise/setup_agents/adapters/codex.py +5 -0
- gitwise/setup_agents/adapters/continue_adapter.py +5 -0
- gitwise/setup_agents/adapters/cursor.py +5 -0
- gitwise/setup_agents/adapters/opencode.py +5 -0
- gitwise/setup_agents/adapters/pi.py +5 -0
- gitwise/setup_agents/exec.py +449 -0
- gitwise/setup_agents/format.py +164 -0
- gitwise/setup_agents/plan.py +254 -0
- gitwise/setup_agents/plan_gitfiles.py +167 -0
- gitwise/setup_agents/plan_skills.py +256 -0
- gitwise/setup_agents/providers/__init__.py +96 -0
- gitwise/setup_agents/providers/aider.py +11 -0
- gitwise/setup_agents/providers/base.py +79 -0
- gitwise/setup_agents/providers/claude.py +408 -0
- gitwise/setup_agents/providers/codex.py +11 -0
- gitwise/setup_agents/providers/continue_adapter.py +11 -0
- gitwise/setup_agents/providers/cursor.py +11 -0
- gitwise/setup_agents/providers/opencode.py +11 -0
- gitwise/setup_agents/providers/pi.py +11 -0
- gitwise/setup_agents/state.py +141 -0
- gitwise/setup_agents/types.py +48 -0
- gitwise/share/agents/skills/git-audit/SKILL.md +25 -0
- gitwise/share/agents/skills/git-clean/SKILL.md +22 -0
- gitwise/share/agents/skills/git-optimize/SKILL.md +21 -0
- gitwise/share/aider/CONVENTIONS.md.template +8 -0
- gitwise/share/aider/aider.conf.yml.template +4 -0
- gitwise/share/claude/CLAUDE.md.template +9 -0
- gitwise/share/claude/rules/gitwise.md +16 -0
- gitwise/share/claude/settings.json.template +47 -0
- gitwise/share/claude/skills/git-audit/SKILL.md +25 -0
- gitwise/share/claude/skills/git-clean/SKILL.md +22 -0
- gitwise/share/claude/skills/git-optimize/SKILL.md +21 -0
- gitwise/share/codex/agents/gitwise.toml.template +18 -0
- gitwise/share/continue/rules/gitwise.md.template +14 -0
- gitwise/share/cursor/rules/gitwise.mdc.template +16 -0
- gitwise/share/git-config-modern.txt +48 -0
- gitwise/share/hooks/commit-msg +22 -0
- gitwise/share/hooks/pre-commit +19 -0
- gitwise/share/opencode/agents/gitwise.md.template +14 -0
- gitwise/share/pi/skills/gitwise.md.template +14 -0
- gitwise/share/schemas/v1/input/audit.json +40 -0
- gitwise/share/schemas/v1/input/branches.json +51 -0
- gitwise/share/schemas/v1/input/clean.json +52 -0
- gitwise/share/schemas/v1/input/commands.json +36 -0
- gitwise/share/schemas/v1/input/commit.json +63 -0
- gitwise/share/schemas/v1/input/completions.json +51 -0
- gitwise/share/schemas/v1/input/conflicts.json +46 -0
- gitwise/share/schemas/v1/input/context.json +36 -0
- gitwise/share/schemas/v1/input/diff.json +56 -0
- gitwise/share/schemas/v1/input/doctor.json +36 -0
- gitwise/share/schemas/v1/input/health.json +36 -0
- gitwise/share/schemas/v1/input/log.json +71 -0
- gitwise/share/schemas/v1/input/merge.json +63 -0
- gitwise/share/schemas/v1/input/optimize.json +44 -0
- gitwise/share/schemas/v1/input/pick.json +63 -0
- gitwise/share/schemas/v1/input/pr.json +51 -0
- gitwise/share/schemas/v1/input/schema.json +48 -0
- gitwise/share/schemas/v1/input/setup-agents.json +108 -0
- gitwise/share/schemas/v1/input/setup.json +55 -0
- gitwise/share/schemas/v1/input/show.json +46 -0
- gitwise/share/schemas/v1/input/snapshot.json +36 -0
- gitwise/share/schemas/v1/input/stash.json +68 -0
- gitwise/share/schemas/v1/input/status.json +36 -0
- gitwise/share/schemas/v1/input/suggest.json +36 -0
- gitwise/share/schemas/v1/input/summarize.json +44 -0
- gitwise/share/schemas/v1/input/sync.json +55 -0
- gitwise/share/schemas/v1/input/tag.json +73 -0
- gitwise/share/schemas/v1/input/undo.json +60 -0
- gitwise/share/schemas/v1/input/update.json +40 -0
- gitwise/share/schemas/v1/input/worktree.json +50 -0
- gitwise/show.py +118 -0
- gitwise/snapshot.py +110 -0
- gitwise/stash.py +188 -0
- gitwise/status.py +93 -0
- gitwise/suggest.py +148 -0
- gitwise/summarize.py +202 -0
- gitwise/sync.py +257 -0
- gitwise/tag.py +252 -0
- gitwise/undo.py +145 -0
- gitwise/update.py +42 -0
- gitwise/utils/__init__.py +1 -0
- gitwise/utils/git_output.py +51 -0
- gitwise/utils/json_envelope.py +58 -0
- gitwise/utils/parsing.py +34 -0
- gitwise/worktree.py +182 -0
- gitwise_cli-0.24.2.dist-info/METADATA +151 -0
- gitwise_cli-0.24.2.dist-info/RECORD +125 -0
- gitwise_cli-0.24.2.dist-info/WHEEL +4 -0
- gitwise_cli-0.24.2.dist-info/entry_points.txt +2 -0
- gitwise_cli-0.24.2.dist-info/licenses/LICENSE +21 -0
gitwise/diff.py
ADDED
|
@@ -0,0 +1,309 @@
|
|
|
1
|
+
"""Focused changed-file list for AI agents and humans."""
|
|
2
|
+
|
|
3
|
+
from pathlib import Path
|
|
4
|
+
|
|
5
|
+
from .git import require_root
|
|
6
|
+
from .git import run as git_run
|
|
7
|
+
from .i18n import t
|
|
8
|
+
from .output import (
|
|
9
|
+
HAS_DELTA,
|
|
10
|
+
bat_pipe,
|
|
11
|
+
error,
|
|
12
|
+
info,
|
|
13
|
+
print_diffstat,
|
|
14
|
+
print_dim,
|
|
15
|
+
print_file_status,
|
|
16
|
+
print_header,
|
|
17
|
+
print_json,
|
|
18
|
+
)
|
|
19
|
+
from .utils.git_output import parse_name_status_entries
|
|
20
|
+
from .utils.json_envelope import ok_envelope
|
|
21
|
+
|
|
22
|
+
DiffValue = str | int | bool
|
|
23
|
+
DiffFileEntry = dict[str, DiffValue]
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def _parse_diffstat_entries(lines: list[str]) -> list[DiffFileEntry]:
|
|
27
|
+
entries: list[DiffFileEntry] = []
|
|
28
|
+
for line in lines:
|
|
29
|
+
if "|" not in line:
|
|
30
|
+
continue
|
|
31
|
+
parts = line.split("|", 1)
|
|
32
|
+
if len(parts) != 2:
|
|
33
|
+
continue
|
|
34
|
+
path = parts[0].strip()
|
|
35
|
+
changes = parts[1].strip()
|
|
36
|
+
if not path:
|
|
37
|
+
continue
|
|
38
|
+
changed_count = 0
|
|
39
|
+
graph = ""
|
|
40
|
+
change_parts = changes.split(" ", 1)
|
|
41
|
+
if change_parts and change_parts[0].isdigit():
|
|
42
|
+
changed_count = int(change_parts[0])
|
|
43
|
+
if len(change_parts) == 2:
|
|
44
|
+
graph = change_parts[1].strip()
|
|
45
|
+
entry: DiffFileEntry = {
|
|
46
|
+
"path": path,
|
|
47
|
+
"changes": changes,
|
|
48
|
+
"lines_changed": changed_count,
|
|
49
|
+
"graph": graph,
|
|
50
|
+
}
|
|
51
|
+
entries.append(entry)
|
|
52
|
+
return entries
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def _parse_name_status_lines(lines: list[str]) -> dict[str, DiffFileEntry]:
|
|
56
|
+
entries: dict[str, DiffFileEntry] = {}
|
|
57
|
+
parsed = parse_name_status_entries("\n".join(lines))
|
|
58
|
+
for entry in parsed:
|
|
59
|
+
path = entry.get("path")
|
|
60
|
+
if not path:
|
|
61
|
+
continue
|
|
62
|
+
typed_entry: DiffFileEntry = dict(entry)
|
|
63
|
+
entries[path] = typed_entry
|
|
64
|
+
return entries
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def _diff_totals(files: list[DiffFileEntry]) -> DiffFileEntry:
|
|
68
|
+
insertions = 0
|
|
69
|
+
deletions = 0
|
|
70
|
+
lines_changed = 0
|
|
71
|
+
binary_files = 0
|
|
72
|
+
for item in files:
|
|
73
|
+
ins = item.get("insertions")
|
|
74
|
+
dels = item.get("deletions")
|
|
75
|
+
changed = item.get("lines_changed")
|
|
76
|
+
is_binary = item.get("is_binary")
|
|
77
|
+
if isinstance(ins, int):
|
|
78
|
+
insertions += ins
|
|
79
|
+
if isinstance(dels, int):
|
|
80
|
+
deletions += dels
|
|
81
|
+
if isinstance(changed, int):
|
|
82
|
+
lines_changed += changed
|
|
83
|
+
if is_binary is True:
|
|
84
|
+
binary_files += 1
|
|
85
|
+
return {
|
|
86
|
+
"insertions": insertions,
|
|
87
|
+
"deletions": deletions,
|
|
88
|
+
"lines_changed": lines_changed,
|
|
89
|
+
"binary_files": binary_files,
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
def _name_status_details(cwd: Path, *, staged: bool) -> dict[str, DiffFileEntry]:
|
|
94
|
+
args = ["--no-pager", "diff", "--name-status"]
|
|
95
|
+
if staged:
|
|
96
|
+
args.append("--staged")
|
|
97
|
+
else:
|
|
98
|
+
args.append("HEAD")
|
|
99
|
+
r = git_run(args, cwd=cwd, check=False)
|
|
100
|
+
if r.returncode != 0:
|
|
101
|
+
return {}
|
|
102
|
+
return _parse_name_status_lines(r.stdout.splitlines())
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
def _numstat_details(cwd: Path, *, staged: bool) -> dict[str, DiffFileEntry]:
|
|
106
|
+
args = ["--no-pager", "diff", "--numstat"]
|
|
107
|
+
if staged:
|
|
108
|
+
args.append("--staged")
|
|
109
|
+
else:
|
|
110
|
+
args.append("HEAD")
|
|
111
|
+
r = git_run(args, cwd=cwd, check=False)
|
|
112
|
+
if r.returncode != 0:
|
|
113
|
+
return {}
|
|
114
|
+
|
|
115
|
+
details: dict[str, DiffFileEntry] = {}
|
|
116
|
+
for line in r.stdout.splitlines():
|
|
117
|
+
parts = line.split("\t")
|
|
118
|
+
if len(parts) < 3:
|
|
119
|
+
continue
|
|
120
|
+
add_raw = parts[0].strip()
|
|
121
|
+
del_raw = parts[1].strip()
|
|
122
|
+
path = parts[2].strip()
|
|
123
|
+
if not path:
|
|
124
|
+
continue
|
|
125
|
+
|
|
126
|
+
if add_raw == "-" or del_raw == "-":
|
|
127
|
+
details[path] = {"path": path, "is_binary": True}
|
|
128
|
+
continue
|
|
129
|
+
|
|
130
|
+
if not (add_raw.isdigit() and del_raw.isdigit()):
|
|
131
|
+
continue
|
|
132
|
+
|
|
133
|
+
details[path] = {
|
|
134
|
+
"path": path,
|
|
135
|
+
"insertions": int(add_raw),
|
|
136
|
+
"deletions": int(del_raw),
|
|
137
|
+
"is_binary": False,
|
|
138
|
+
}
|
|
139
|
+
return details
|
|
140
|
+
|
|
141
|
+
|
|
142
|
+
def _has_commits(cwd: Path) -> bool:
|
|
143
|
+
return git_run(["rev-parse", "HEAD"], cwd=cwd, check=False).returncode == 0
|
|
144
|
+
|
|
145
|
+
|
|
146
|
+
def _diff_cmd(*, use_stat: bool, staged: bool, name_only: bool, full: bool) -> list[str]:
|
|
147
|
+
if full:
|
|
148
|
+
return ["--no-pager", "diff", "HEAD"]
|
|
149
|
+
if use_stat:
|
|
150
|
+
return ["--no-pager", "diff", "--stat", "HEAD"]
|
|
151
|
+
if staged:
|
|
152
|
+
return ["--no-pager", "diff", "--name-status", "--staged"]
|
|
153
|
+
if name_only:
|
|
154
|
+
return ["--no-pager", "diff", "--name-only", "HEAD"]
|
|
155
|
+
return ["--no-pager", "diff", "--name-status", "HEAD"]
|
|
156
|
+
|
|
157
|
+
|
|
158
|
+
def _print_diff_human_full(*, diff_text: str) -> None:
|
|
159
|
+
if HAS_DELTA:
|
|
160
|
+
print_dim(t("using_delta"))
|
|
161
|
+
bat_pipe(diff_text, language="diff")
|
|
162
|
+
|
|
163
|
+
|
|
164
|
+
def _merge_stat_files(
|
|
165
|
+
*,
|
|
166
|
+
files: list[DiffFileEntry],
|
|
167
|
+
status_details: dict[str, DiffFileEntry],
|
|
168
|
+
numstat_details: dict[str, DiffFileEntry],
|
|
169
|
+
) -> list[DiffFileEntry]:
|
|
170
|
+
merged_files: list[DiffFileEntry] = []
|
|
171
|
+
for item in files:
|
|
172
|
+
path_value = item.get("path")
|
|
173
|
+
if not isinstance(path_value, str) or not path_value:
|
|
174
|
+
continue
|
|
175
|
+
merged: DiffFileEntry = dict(item)
|
|
176
|
+
if path_value in status_details:
|
|
177
|
+
for key, value in status_details[path_value].items():
|
|
178
|
+
merged[key] = value
|
|
179
|
+
if path_value in numstat_details:
|
|
180
|
+
for key, value in numstat_details[path_value].items():
|
|
181
|
+
merged[key] = value
|
|
182
|
+
merged_files.append(merged)
|
|
183
|
+
return merged_files
|
|
184
|
+
|
|
185
|
+
|
|
186
|
+
def _render_stat_output(
|
|
187
|
+
*,
|
|
188
|
+
files: list[DiffFileEntry],
|
|
189
|
+
cwd: Path,
|
|
190
|
+
staged: bool,
|
|
191
|
+
as_json: bool,
|
|
192
|
+
) -> int:
|
|
193
|
+
if not files:
|
|
194
|
+
if as_json:
|
|
195
|
+
print_json(ok_envelope(files=[], count=0))
|
|
196
|
+
return 0
|
|
197
|
+
info(t("no_uncommitted_changes"))
|
|
198
|
+
return 0
|
|
199
|
+
|
|
200
|
+
status_details = _name_status_details(cwd, staged=staged)
|
|
201
|
+
numstat_details = _numstat_details(cwd, staged=staged)
|
|
202
|
+
merged_files = _merge_stat_files(
|
|
203
|
+
files=files,
|
|
204
|
+
status_details=status_details,
|
|
205
|
+
numstat_details=numstat_details,
|
|
206
|
+
)
|
|
207
|
+
if as_json:
|
|
208
|
+
print_json(
|
|
209
|
+
ok_envelope(
|
|
210
|
+
files=merged_files,
|
|
211
|
+
count=len(merged_files),
|
|
212
|
+
totals=_diff_totals(merged_files),
|
|
213
|
+
)
|
|
214
|
+
)
|
|
215
|
+
return 0
|
|
216
|
+
|
|
217
|
+
styled_files = [
|
|
218
|
+
{
|
|
219
|
+
"path": str(file_item.get("path", "")),
|
|
220
|
+
"changes": str(file_item.get("changes", "")),
|
|
221
|
+
"status": str(file_item.get("code", file_item.get("status", "M"))),
|
|
222
|
+
}
|
|
223
|
+
for file_item in merged_files
|
|
224
|
+
if str(file_item.get("path", ""))
|
|
225
|
+
]
|
|
226
|
+
print_diffstat(t("changed_files", count=str(len(styled_files))), styled_files)
|
|
227
|
+
return 0
|
|
228
|
+
|
|
229
|
+
|
|
230
|
+
def _render_non_stat_output(
|
|
231
|
+
*,
|
|
232
|
+
lines: list[str],
|
|
233
|
+
staged: bool,
|
|
234
|
+
name_only: bool,
|
|
235
|
+
as_json: bool,
|
|
236
|
+
) -> int:
|
|
237
|
+
if not lines:
|
|
238
|
+
if as_json:
|
|
239
|
+
print_json(ok_envelope(files=[], count=0))
|
|
240
|
+
return 0
|
|
241
|
+
if staged:
|
|
242
|
+
info(t("nothing_staged"))
|
|
243
|
+
else:
|
|
244
|
+
info(t("tip_staged"))
|
|
245
|
+
return 0
|
|
246
|
+
|
|
247
|
+
if name_only:
|
|
248
|
+
files = [{"path": line.strip()} for line in lines if line.strip()]
|
|
249
|
+
else:
|
|
250
|
+
files = []
|
|
251
|
+
for line in lines:
|
|
252
|
+
parts = line.split("\t", 1)
|
|
253
|
+
if len(parts) == 2:
|
|
254
|
+
files.append({"status": parts[0].strip(), "path": parts[1].strip()})
|
|
255
|
+
|
|
256
|
+
if as_json:
|
|
257
|
+
print_json(ok_envelope(files=files, count=len(files)))
|
|
258
|
+
return 0
|
|
259
|
+
|
|
260
|
+
print_header(t("changed_files", count=str(len(files))))
|
|
261
|
+
for file_item in files:
|
|
262
|
+
print_file_status(file_item["status"], file_item["path"])
|
|
263
|
+
return 0
|
|
264
|
+
|
|
265
|
+
|
|
266
|
+
def run_diff(
|
|
267
|
+
*,
|
|
268
|
+
staged: bool = False,
|
|
269
|
+
stat: bool = False,
|
|
270
|
+
name_only: bool = False,
|
|
271
|
+
full: bool = False,
|
|
272
|
+
as_json: bool = False,
|
|
273
|
+
) -> int:
|
|
274
|
+
root, err = require_root()
|
|
275
|
+
if err:
|
|
276
|
+
return err
|
|
277
|
+
if root is None:
|
|
278
|
+
return 1
|
|
279
|
+
cwd = root
|
|
280
|
+
|
|
281
|
+
if not staged and not _has_commits(cwd):
|
|
282
|
+
if as_json:
|
|
283
|
+
print_json(ok_envelope(files=[], count=0, note=t("no_commits_yet")))
|
|
284
|
+
return 0
|
|
285
|
+
info(t("no_commits_yet"))
|
|
286
|
+
return 0
|
|
287
|
+
|
|
288
|
+
use_stat = stat or (not staged and not name_only and not full)
|
|
289
|
+
cmd = _diff_cmd(use_stat=use_stat, staged=staged, name_only=name_only, full=full)
|
|
290
|
+
result = git_run(cmd, cwd=cwd, check=False)
|
|
291
|
+
if result.returncode != 0:
|
|
292
|
+
error(t("git_diff_failed", error=result.stderr.strip()))
|
|
293
|
+
return 1
|
|
294
|
+
|
|
295
|
+
if full:
|
|
296
|
+
if as_json:
|
|
297
|
+
print_json(ok_envelope(diff=result.stdout))
|
|
298
|
+
else:
|
|
299
|
+
_print_diff_human_full(diff_text=result.stdout)
|
|
300
|
+
return 0
|
|
301
|
+
|
|
302
|
+
lines = [line for line in result.stdout.splitlines() if line.strip()]
|
|
303
|
+
if use_stat:
|
|
304
|
+
files = _parse_diffstat_entries(lines)
|
|
305
|
+
return _render_stat_output(files=files, cwd=cwd, staged=staged, as_json=as_json)
|
|
306
|
+
|
|
307
|
+
return _render_non_stat_output(
|
|
308
|
+
lines=lines, staged=staged, name_only=name_only, as_json=as_json
|
|
309
|
+
)
|
gitwise/doctor.py
ADDED
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
"""Detects git version, Python version, platform, and optional deps."""
|
|
2
|
+
|
|
3
|
+
import platform
|
|
4
|
+
import shutil
|
|
5
|
+
import sys
|
|
6
|
+
|
|
7
|
+
from . import __version__
|
|
8
|
+
from .git import gpg_status
|
|
9
|
+
from .git import version as git_version
|
|
10
|
+
from .i18n import t
|
|
11
|
+
from .output import (
|
|
12
|
+
print_blank,
|
|
13
|
+
print_dim,
|
|
14
|
+
print_header,
|
|
15
|
+
print_json,
|
|
16
|
+
print_kv,
|
|
17
|
+
print_status_line,
|
|
18
|
+
warn,
|
|
19
|
+
)
|
|
20
|
+
|
|
21
|
+
MIN_GIT = (2, 29, 0)
|
|
22
|
+
|
|
23
|
+
_OPTIONAL_TOOLS = ["bat", "delta", "rg", "eza", "git-sizer", "watchman"]
|
|
24
|
+
|
|
25
|
+
_TOOL_INFO: dict[str, tuple[str, str]] = {
|
|
26
|
+
"bat": ("tool_bat_desc", "brew install bat"),
|
|
27
|
+
"delta": ("tool_delta_desc", "brew install git-delta"),
|
|
28
|
+
"rg": ("tool_rg_desc", "brew install ripgrep"),
|
|
29
|
+
"eza": ("tool_eza_desc", "brew install eza"),
|
|
30
|
+
"git-sizer": ("tool_git_sizer_desc", "brew install git-sizer"),
|
|
31
|
+
"watchman": ("tool_watchman_desc", "brew install watchman"),
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def run_doctor(*, as_json: bool = False) -> int:
|
|
36
|
+
git_ver = git_version()
|
|
37
|
+
git_ok = git_ver >= MIN_GIT
|
|
38
|
+
|
|
39
|
+
python_ver = sys.version_info[:3]
|
|
40
|
+
python_ok = python_ver >= (3, 9)
|
|
41
|
+
|
|
42
|
+
platform_name = platform.system()
|
|
43
|
+
fsmonitor_supported = platform_name in ("Darwin", "Windows")
|
|
44
|
+
|
|
45
|
+
optional = {tool: bool(shutil.which(tool)) for tool in _OPTIONAL_TOOLS}
|
|
46
|
+
gpg = gpg_status()
|
|
47
|
+
|
|
48
|
+
result = {
|
|
49
|
+
"v": 2,
|
|
50
|
+
"gitwise_version": __version__,
|
|
51
|
+
"git_version": ".".join(str(n) for n in git_ver),
|
|
52
|
+
"git_version_ok": git_ok,
|
|
53
|
+
"git_min_required": ".".join(str(n) for n in MIN_GIT),
|
|
54
|
+
"python_version": ".".join(str(n) for n in python_ver),
|
|
55
|
+
"python_version_ok": python_ok,
|
|
56
|
+
"platform": platform_name,
|
|
57
|
+
"fsmonitor_supported": fsmonitor_supported,
|
|
58
|
+
"optional_tools": optional,
|
|
59
|
+
"gpg": gpg,
|
|
60
|
+
"ok": git_ok and python_ok,
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
if as_json:
|
|
64
|
+
print_json(result)
|
|
65
|
+
return 0 if result["ok"] else 1
|
|
66
|
+
|
|
67
|
+
print_header(f"gitwise {__version__}")
|
|
68
|
+
print_blank()
|
|
69
|
+
|
|
70
|
+
git_str = ".".join(str(n) for n in git_ver)
|
|
71
|
+
min_str = ".".join(str(n) for n in MIN_GIT)
|
|
72
|
+
if git_ok:
|
|
73
|
+
print_status_line("✓", t("doctor_git_label"), git_str, ok_flag=True)
|
|
74
|
+
else:
|
|
75
|
+
print_status_line("✗", t("doctor_git_label"), git_str, ok_flag=False)
|
|
76
|
+
warn(t("git_too_old", ver=git_str, min=min_str))
|
|
77
|
+
|
|
78
|
+
py_str = ".".join(str(n) for n in python_ver)
|
|
79
|
+
if python_ok:
|
|
80
|
+
print_status_line("✓", t("doctor_python_label"), py_str, ok_flag=True)
|
|
81
|
+
else:
|
|
82
|
+
print_status_line("✗", t("doctor_python_label"), py_str, ok_flag=False)
|
|
83
|
+
warn(t("python_too_old", ver=py_str))
|
|
84
|
+
|
|
85
|
+
print_status_line("✓", t("platform_label", name=platform_name), "", ok_flag=True)
|
|
86
|
+
|
|
87
|
+
if not fsmonitor_supported:
|
|
88
|
+
warn(t("fsmonitor_not_supported"))
|
|
89
|
+
|
|
90
|
+
print_blank()
|
|
91
|
+
print_header(t("optional_tools"))
|
|
92
|
+
for tool, found in optional.items():
|
|
93
|
+
if found:
|
|
94
|
+
print_status_line("✓", tool, "", ok_flag=True)
|
|
95
|
+
else:
|
|
96
|
+
desc_key, install = _TOOL_INFO.get(tool, ("", f"brew install {tool}"))
|
|
97
|
+
desc = t(desc_key) if desc_key else ""
|
|
98
|
+
print_status_line("–", tool, "", ok_flag=False)
|
|
99
|
+
print_kv(t("doctor_purpose_label"), desc)
|
|
100
|
+
print_kv(t("doctor_install_label"), install)
|
|
101
|
+
|
|
102
|
+
print_blank()
|
|
103
|
+
print_header(t("gpg_title"))
|
|
104
|
+
if gpg["ready"]:
|
|
105
|
+
print_status_line("✓", t("gpg_ready_msg"), "", ok_flag=True)
|
|
106
|
+
elif not gpg["gpg_binary"]:
|
|
107
|
+
print_status_line("✗", t("gpg_not_installed"), "", ok_flag=False)
|
|
108
|
+
print_dim(t("gpg_install_instruction"))
|
|
109
|
+
elif not gpg["gpgsign_enabled"]:
|
|
110
|
+
print_status_line("–", t("gpg_not_enabled"), "", ok_flag=False)
|
|
111
|
+
print_dim(t("gpg_enable_instruction"))
|
|
112
|
+
elif not gpg["signing_key_set"]:
|
|
113
|
+
print_status_line("✗", t("gpg_no_signing_key"), "", ok_flag=False)
|
|
114
|
+
print_dim(t("gpg_key_instruction"))
|
|
115
|
+
|
|
116
|
+
return 0 if result["ok"] else 1
|
gitwise/git.py
ADDED
|
@@ -0,0 +1,254 @@
|
|
|
1
|
+
"""Thin wrappers over subprocess for git operations."""
|
|
2
|
+
|
|
3
|
+
import functools
|
|
4
|
+
import os
|
|
5
|
+
import re
|
|
6
|
+
import subprocess
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
|
|
9
|
+
_NESTED_QUANTIFIER_RE = re.compile(
|
|
10
|
+
r"(\([^)]*[+*][^)]*\)[+*{])"
|
|
11
|
+
r"|(\[[^\]]*[+*][^\]]*\][+*{])"
|
|
12
|
+
r"|(\(.+\|.+\)[+*{])"
|
|
13
|
+
)
|
|
14
|
+
|
|
15
|
+
_GREP_MAX_LEN = 200
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
_GIT_ENV = {**os.environ, "LC_ALL": "C", "GIT_TERMINAL_PROMPT": "0"}
|
|
19
|
+
|
|
20
|
+
_DEFAULT_TIMEOUT = 120
|
|
21
|
+
|
|
22
|
+
_NETWORK_TIMEOUT = 60
|
|
23
|
+
|
|
24
|
+
_CMD_TIMEOUTS: dict[str, int] = {
|
|
25
|
+
"diff-tree": 30,
|
|
26
|
+
"diff": 30,
|
|
27
|
+
"log": 30,
|
|
28
|
+
"status": 10,
|
|
29
|
+
"branch": 10,
|
|
30
|
+
"stash": 10,
|
|
31
|
+
"ls-files": 10,
|
|
32
|
+
"rev-list": 30,
|
|
33
|
+
"shortlog": 30,
|
|
34
|
+
"grep": 30,
|
|
35
|
+
"for-each-ref": 10,
|
|
36
|
+
"rev-parse": 10,
|
|
37
|
+
"config": 5,
|
|
38
|
+
"tag": 10,
|
|
39
|
+
"commit": 30,
|
|
40
|
+
"fetch": _NETWORK_TIMEOUT,
|
|
41
|
+
"push": _NETWORK_TIMEOUT,
|
|
42
|
+
"pull": _NETWORK_TIMEOUT,
|
|
43
|
+
"clone": _NETWORK_TIMEOUT,
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def _get_timeout(cmd: str | None = None) -> int:
|
|
48
|
+
val = os.environ.get("GITWISE_GIT_TIMEOUT", "")
|
|
49
|
+
if val.isdigit():
|
|
50
|
+
return int(val)
|
|
51
|
+
if cmd and cmd in _CMD_TIMEOUTS:
|
|
52
|
+
return _CMD_TIMEOUTS[cmd]
|
|
53
|
+
return _DEFAULT_TIMEOUT
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def run(
|
|
57
|
+
args: list[str],
|
|
58
|
+
cwd: Path | None = None,
|
|
59
|
+
check: bool = False,
|
|
60
|
+
timeout: int | None = None,
|
|
61
|
+
) -> subprocess.CompletedProcess[str]:
|
|
62
|
+
from .output import debug
|
|
63
|
+
|
|
64
|
+
cmd_name = args[0] if args else None
|
|
65
|
+
actual_timeout = timeout if timeout is not None else _get_timeout(cmd_name)
|
|
66
|
+
debug(f"git {' '.join(args)}")
|
|
67
|
+
return subprocess.run(
|
|
68
|
+
["git"] + args,
|
|
69
|
+
capture_output=True,
|
|
70
|
+
text=True,
|
|
71
|
+
encoding="utf-8",
|
|
72
|
+
errors="replace",
|
|
73
|
+
cwd=cwd,
|
|
74
|
+
check=check,
|
|
75
|
+
env=_GIT_ENV,
|
|
76
|
+
timeout=actual_timeout,
|
|
77
|
+
)
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
def config(key: str, cwd: Path | None = None) -> str | None:
|
|
81
|
+
result = run(["config", "--get", key], cwd=cwd, check=False)
|
|
82
|
+
return result.stdout.strip() if result.returncode == 0 else None
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
def config_all(key: str, cwd: Path | None = None) -> list[str]:
|
|
86
|
+
result = run(["config", "--get-all", key], cwd=cwd, check=False)
|
|
87
|
+
if result.returncode != 0:
|
|
88
|
+
return []
|
|
89
|
+
return [line.strip() for line in result.stdout.splitlines() if line.strip()]
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
def is_repo(path: Path | None = None) -> bool:
|
|
93
|
+
result = run(["rev-parse", "--git-dir"], cwd=path, check=False)
|
|
94
|
+
return result.returncode == 0
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
def repo_root(path: Path | None = None) -> Path | None:
|
|
98
|
+
result = run(["rev-parse", "--show-toplevel"], cwd=path, check=False)
|
|
99
|
+
return Path(result.stdout.strip()) if result.returncode == 0 else None
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
def git_dir(cwd: Path | None = None) -> Path | None:
|
|
103
|
+
r = run(["rev-parse", "--absolute-git-dir"], cwd=cwd, check=False)
|
|
104
|
+
return Path(r.stdout.strip()) if r.returncode == 0 else None
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
def current_branch(cwd: Path | None = None) -> str | None:
|
|
108
|
+
r = run(["branch", "--show-current"], cwd=cwd, check=False)
|
|
109
|
+
val = r.stdout.strip()
|
|
110
|
+
return val if (r.returncode == 0 and val) else None
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
def stale_branches(cwd: Path | None = None) -> list[str]:
|
|
114
|
+
"""Returns branch names whose upstream is [gone]."""
|
|
115
|
+
r = run(
|
|
116
|
+
["for-each-ref", "--format=%(refname:short)\t%(upstream:track)", "refs/heads/"],
|
|
117
|
+
cwd=cwd,
|
|
118
|
+
check=False,
|
|
119
|
+
)
|
|
120
|
+
if r.returncode != 0:
|
|
121
|
+
return []
|
|
122
|
+
result = []
|
|
123
|
+
for line in r.stdout.splitlines():
|
|
124
|
+
parts = line.split("\t", 1)
|
|
125
|
+
if len(parts) == 2 and "[gone]" in parts[1]:
|
|
126
|
+
result.append(parts[0])
|
|
127
|
+
return result
|
|
128
|
+
|
|
129
|
+
|
|
130
|
+
def worktree_branches(cwd: Path | None = None) -> set[str]:
|
|
131
|
+
"""Returns set of branch names currently checked out in any worktree."""
|
|
132
|
+
r = run(["worktree", "list", "--porcelain"], cwd=cwd, check=False)
|
|
133
|
+
if r.returncode != 0:
|
|
134
|
+
return set()
|
|
135
|
+
return {
|
|
136
|
+
line.removeprefix("branch refs/heads/")
|
|
137
|
+
for line in r.stdout.splitlines()
|
|
138
|
+
if line.startswith("branch refs/heads/")
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
|
|
142
|
+
def gpg_status(cwd: Path | None = None) -> dict[str, bool]:
|
|
143
|
+
"""Returns GPG signing readiness for cwd (falls back to global git config)."""
|
|
144
|
+
import shutil
|
|
145
|
+
|
|
146
|
+
gpg_bin = bool(shutil.which("gpg") or shutil.which("gpg2"))
|
|
147
|
+
gpgsign = config("commit.gpgsign", cwd=cwd)
|
|
148
|
+
signing_key = config("user.signingkey", cwd=cwd)
|
|
149
|
+
return {
|
|
150
|
+
"gpg_binary": gpg_bin,
|
|
151
|
+
"gpgsign_enabled": gpgsign == "true",
|
|
152
|
+
"signing_key_set": bool(signing_key),
|
|
153
|
+
"ready": gpg_bin and gpgsign == "true" and bool(signing_key),
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
|
|
157
|
+
@functools.lru_cache(maxsize=1)
|
|
158
|
+
def version() -> tuple[int, int, int]:
|
|
159
|
+
result = run(["--version"], check=False)
|
|
160
|
+
# "git version 2.45.0" -> (2, 45, 0)
|
|
161
|
+
parts = result.stdout.strip().split()
|
|
162
|
+
if len(parts) >= 3:
|
|
163
|
+
nums = parts[2].split(".")
|
|
164
|
+
try:
|
|
165
|
+
return (
|
|
166
|
+
int(nums[0]),
|
|
167
|
+
int(nums[1]) if len(nums) > 1 else 0,
|
|
168
|
+
int(nums[2]) if len(nums) > 2 else 0,
|
|
169
|
+
)
|
|
170
|
+
except (ValueError, IndexError):
|
|
171
|
+
from .output import debug as _debug
|
|
172
|
+
|
|
173
|
+
_debug(f"git version parse failed: {result.stdout.strip()!r}")
|
|
174
|
+
return (0, 0, 0)
|
|
175
|
+
|
|
176
|
+
|
|
177
|
+
def supports_config_hooks(cwd: Path | None = None) -> bool:
|
|
178
|
+
if version() < (2, 36, 0):
|
|
179
|
+
return False
|
|
180
|
+
result = run(["hook", "run", "--ignore-missing", "pre-commit"], cwd=cwd, check=False)
|
|
181
|
+
return result.returncode == 0
|
|
182
|
+
|
|
183
|
+
|
|
184
|
+
def validate_ref(ref: str) -> bool:
|
|
185
|
+
return bool(ref) and not ref.startswith("-")
|
|
186
|
+
|
|
187
|
+
|
|
188
|
+
def validate_branch_name(name: str) -> bool:
|
|
189
|
+
if not name or name.startswith("-"):
|
|
190
|
+
return False
|
|
191
|
+
result = run(["check-ref-format", f"refs/heads/{name}"], check=False)
|
|
192
|
+
return result.returncode == 0
|
|
193
|
+
|
|
194
|
+
|
|
195
|
+
def validate_grep_pattern(pattern: str) -> bool:
|
|
196
|
+
if len(pattern) > _GREP_MAX_LEN:
|
|
197
|
+
return False
|
|
198
|
+
if _NESTED_QUANTIFIER_RE.search(pattern):
|
|
199
|
+
return False
|
|
200
|
+
try:
|
|
201
|
+
re.compile(pattern)
|
|
202
|
+
except re.error:
|
|
203
|
+
return False
|
|
204
|
+
return True
|
|
205
|
+
|
|
206
|
+
|
|
207
|
+
def validate_author_pattern(pattern: str) -> bool:
|
|
208
|
+
if len(pattern) > _GREP_MAX_LEN:
|
|
209
|
+
return False
|
|
210
|
+
if "\n" in pattern or "\r" in pattern or "\x00" in pattern:
|
|
211
|
+
return False
|
|
212
|
+
if _NESTED_QUANTIFIER_RE.search(pattern):
|
|
213
|
+
return False
|
|
214
|
+
try:
|
|
215
|
+
re.compile(pattern)
|
|
216
|
+
except re.error:
|
|
217
|
+
return False
|
|
218
|
+
return True
|
|
219
|
+
|
|
220
|
+
|
|
221
|
+
PROTECTED_BRANCHES: frozenset[str] = frozenset(
|
|
222
|
+
{"main", "master", "develop", "dev", "trunk", "release"}
|
|
223
|
+
)
|
|
224
|
+
|
|
225
|
+
|
|
226
|
+
def require_root(path: Path | None = None) -> tuple[Path, None] | tuple[None, int]:
|
|
227
|
+
"""Validate git repo and return (root, None) or (None, exit_code)."""
|
|
228
|
+
from .i18n import t
|
|
229
|
+
from .output import error
|
|
230
|
+
|
|
231
|
+
root = repo_root(path)
|
|
232
|
+
if root is None:
|
|
233
|
+
error(t("not_a_git_repo"))
|
|
234
|
+
return None, 1
|
|
235
|
+
return root, None
|
|
236
|
+
|
|
237
|
+
|
|
238
|
+
def has_remote(cwd: Path | None = None) -> bool:
|
|
239
|
+
r = run(["remote"], cwd=cwd, check=False)
|
|
240
|
+
return r.returncode == 0 and bool(r.stdout.strip())
|
|
241
|
+
|
|
242
|
+
|
|
243
|
+
def has_upstream(cwd: Path | None = None) -> bool:
|
|
244
|
+
r = run(["rev-parse", "--abbrev-ref", "--symbolic-full-name", "@{u}"], cwd=cwd, check=False)
|
|
245
|
+
return r.returncode == 0 and bool(r.stdout.strip())
|
|
246
|
+
|
|
247
|
+
|
|
248
|
+
def has_commit_graph(cwd: Path | None = None) -> bool:
|
|
249
|
+
gd = git_dir(cwd)
|
|
250
|
+
if gd is None:
|
|
251
|
+
return False
|
|
252
|
+
return (gd / "objects" / "info" / "commit-graph").exists() or (
|
|
253
|
+
gd / "objects" / "info" / "commit-graphs" / "commit-graph-chain"
|
|
254
|
+
).exists()
|