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.
Files changed (125) hide show
  1. gitwise/__init__.py +11 -0
  2. gitwise/__main__.py +113 -0
  3. gitwise/_cli_completions.py +88 -0
  4. gitwise/_cli_dispatch.py +469 -0
  5. gitwise/_cli_introspection.py +275 -0
  6. gitwise/_cli_parser.py +345 -0
  7. gitwise/_cli_setup_agents.py +439 -0
  8. gitwise/_i18n_data.json +1934 -0
  9. gitwise/_paths.py +22 -0
  10. gitwise/_runtime_config.py +246 -0
  11. gitwise/audit.py +338 -0
  12. gitwise/branches.py +183 -0
  13. gitwise/clean.py +197 -0
  14. gitwise/commit.py +142 -0
  15. gitwise/conflicts.py +112 -0
  16. gitwise/context.py +163 -0
  17. gitwise/design.py +383 -0
  18. gitwise/diff.py +309 -0
  19. gitwise/doctor.py +116 -0
  20. gitwise/git.py +254 -0
  21. gitwise/health.py +345 -0
  22. gitwise/i18n.py +99 -0
  23. gitwise/log.py +329 -0
  24. gitwise/merge.py +193 -0
  25. gitwise/optimize.py +212 -0
  26. gitwise/output.py +652 -0
  27. gitwise/pick.py +102 -0
  28. gitwise/pr.py +543 -0
  29. gitwise/py.typed +0 -0
  30. gitwise/schema.py +49 -0
  31. gitwise/setup.py +551 -0
  32. gitwise/setup_agents/__init__.py +36 -0
  33. gitwise/setup_agents/adapters/__init__.py +17 -0
  34. gitwise/setup_agents/adapters/aider.py +5 -0
  35. gitwise/setup_agents/adapters/base.py +5 -0
  36. gitwise/setup_agents/adapters/codex.py +5 -0
  37. gitwise/setup_agents/adapters/continue_adapter.py +5 -0
  38. gitwise/setup_agents/adapters/cursor.py +5 -0
  39. gitwise/setup_agents/adapters/opencode.py +5 -0
  40. gitwise/setup_agents/adapters/pi.py +5 -0
  41. gitwise/setup_agents/exec.py +449 -0
  42. gitwise/setup_agents/format.py +164 -0
  43. gitwise/setup_agents/plan.py +254 -0
  44. gitwise/setup_agents/plan_gitfiles.py +167 -0
  45. gitwise/setup_agents/plan_skills.py +256 -0
  46. gitwise/setup_agents/providers/__init__.py +96 -0
  47. gitwise/setup_agents/providers/aider.py +11 -0
  48. gitwise/setup_agents/providers/base.py +79 -0
  49. gitwise/setup_agents/providers/claude.py +408 -0
  50. gitwise/setup_agents/providers/codex.py +11 -0
  51. gitwise/setup_agents/providers/continue_adapter.py +11 -0
  52. gitwise/setup_agents/providers/cursor.py +11 -0
  53. gitwise/setup_agents/providers/opencode.py +11 -0
  54. gitwise/setup_agents/providers/pi.py +11 -0
  55. gitwise/setup_agents/state.py +141 -0
  56. gitwise/setup_agents/types.py +48 -0
  57. gitwise/share/agents/skills/git-audit/SKILL.md +25 -0
  58. gitwise/share/agents/skills/git-clean/SKILL.md +22 -0
  59. gitwise/share/agents/skills/git-optimize/SKILL.md +21 -0
  60. gitwise/share/aider/CONVENTIONS.md.template +8 -0
  61. gitwise/share/aider/aider.conf.yml.template +4 -0
  62. gitwise/share/claude/CLAUDE.md.template +9 -0
  63. gitwise/share/claude/rules/gitwise.md +16 -0
  64. gitwise/share/claude/settings.json.template +47 -0
  65. gitwise/share/claude/skills/git-audit/SKILL.md +25 -0
  66. gitwise/share/claude/skills/git-clean/SKILL.md +22 -0
  67. gitwise/share/claude/skills/git-optimize/SKILL.md +21 -0
  68. gitwise/share/codex/agents/gitwise.toml.template +18 -0
  69. gitwise/share/continue/rules/gitwise.md.template +14 -0
  70. gitwise/share/cursor/rules/gitwise.mdc.template +16 -0
  71. gitwise/share/git-config-modern.txt +48 -0
  72. gitwise/share/hooks/commit-msg +22 -0
  73. gitwise/share/hooks/pre-commit +19 -0
  74. gitwise/share/opencode/agents/gitwise.md.template +14 -0
  75. gitwise/share/pi/skills/gitwise.md.template +14 -0
  76. gitwise/share/schemas/v1/input/audit.json +40 -0
  77. gitwise/share/schemas/v1/input/branches.json +51 -0
  78. gitwise/share/schemas/v1/input/clean.json +52 -0
  79. gitwise/share/schemas/v1/input/commands.json +36 -0
  80. gitwise/share/schemas/v1/input/commit.json +63 -0
  81. gitwise/share/schemas/v1/input/completions.json +51 -0
  82. gitwise/share/schemas/v1/input/conflicts.json +46 -0
  83. gitwise/share/schemas/v1/input/context.json +36 -0
  84. gitwise/share/schemas/v1/input/diff.json +56 -0
  85. gitwise/share/schemas/v1/input/doctor.json +36 -0
  86. gitwise/share/schemas/v1/input/health.json +36 -0
  87. gitwise/share/schemas/v1/input/log.json +71 -0
  88. gitwise/share/schemas/v1/input/merge.json +63 -0
  89. gitwise/share/schemas/v1/input/optimize.json +44 -0
  90. gitwise/share/schemas/v1/input/pick.json +63 -0
  91. gitwise/share/schemas/v1/input/pr.json +51 -0
  92. gitwise/share/schemas/v1/input/schema.json +48 -0
  93. gitwise/share/schemas/v1/input/setup-agents.json +108 -0
  94. gitwise/share/schemas/v1/input/setup.json +55 -0
  95. gitwise/share/schemas/v1/input/show.json +46 -0
  96. gitwise/share/schemas/v1/input/snapshot.json +36 -0
  97. gitwise/share/schemas/v1/input/stash.json +68 -0
  98. gitwise/share/schemas/v1/input/status.json +36 -0
  99. gitwise/share/schemas/v1/input/suggest.json +36 -0
  100. gitwise/share/schemas/v1/input/summarize.json +44 -0
  101. gitwise/share/schemas/v1/input/sync.json +55 -0
  102. gitwise/share/schemas/v1/input/tag.json +73 -0
  103. gitwise/share/schemas/v1/input/undo.json +60 -0
  104. gitwise/share/schemas/v1/input/update.json +40 -0
  105. gitwise/share/schemas/v1/input/worktree.json +50 -0
  106. gitwise/show.py +118 -0
  107. gitwise/snapshot.py +110 -0
  108. gitwise/stash.py +188 -0
  109. gitwise/status.py +93 -0
  110. gitwise/suggest.py +148 -0
  111. gitwise/summarize.py +202 -0
  112. gitwise/sync.py +257 -0
  113. gitwise/tag.py +252 -0
  114. gitwise/undo.py +145 -0
  115. gitwise/update.py +42 -0
  116. gitwise/utils/__init__.py +1 -0
  117. gitwise/utils/git_output.py +51 -0
  118. gitwise/utils/json_envelope.py +58 -0
  119. gitwise/utils/parsing.py +34 -0
  120. gitwise/worktree.py +182 -0
  121. gitwise_cli-0.24.2.dist-info/METADATA +151 -0
  122. gitwise_cli-0.24.2.dist-info/RECORD +125 -0
  123. gitwise_cli-0.24.2.dist-info/WHEEL +4 -0
  124. gitwise_cli-0.24.2.dist-info/entry_points.txt +2 -0
  125. 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()