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/_paths.py
ADDED
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
"""Centralized path resolution for share/ data directory.
|
|
2
|
+
|
|
3
|
+
Resolves the share/ directory location for both installed (pip) and
|
|
4
|
+
development (repo root) environments.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
|
|
9
|
+
_PACKAGE_DIR = Path(__file__).resolve().parent
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
def share_dir() -> Path:
|
|
13
|
+
"""Resolve the share/ directory.
|
|
14
|
+
|
|
15
|
+
When installed via pip (hatchling force-include), share/ lives inside
|
|
16
|
+
the package at gitwise/share/. When running from a repo checkout,
|
|
17
|
+
share/ is a sibling of the package directory at project root.
|
|
18
|
+
"""
|
|
19
|
+
installed = _PACKAGE_DIR / "share"
|
|
20
|
+
if installed.is_dir():
|
|
21
|
+
return installed
|
|
22
|
+
return _PACKAGE_DIR.parent / "share"
|
|
@@ -0,0 +1,246 @@
|
|
|
1
|
+
"""Runtime configuration: immutable settings detected from environment at import time."""
|
|
2
|
+
|
|
3
|
+
import os
|
|
4
|
+
import select
|
|
5
|
+
import shutil
|
|
6
|
+
import sys
|
|
7
|
+
|
|
8
|
+
if sys.platform != "win32":
|
|
9
|
+
import termios
|
|
10
|
+
else:
|
|
11
|
+
termios = None # type: ignore[assignment,misc]
|
|
12
|
+
from .design import ColorDepth, ThemeTokens, build_theme
|
|
13
|
+
|
|
14
|
+
_BRIGHTNESS_THRESHOLD = 0.5
|
|
15
|
+
_OSC_TIMEOUT = 0.5
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def _drain_fd(fd: int, timeout: float = 0.1) -> None:
|
|
19
|
+
while True:
|
|
20
|
+
ready, _, _ = select.select([fd], [], [], timeout)
|
|
21
|
+
if not ready:
|
|
22
|
+
break
|
|
23
|
+
try:
|
|
24
|
+
os.read(fd, 256)
|
|
25
|
+
except OSError:
|
|
26
|
+
break
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def _query_bg_color() -> str | None:
|
|
30
|
+
if os.environ.get("NO_COLOR", "") != "":
|
|
31
|
+
return None
|
|
32
|
+
if os.environ.get("GITWISE_NO_COLOR", "").lower() in ("1", "true"):
|
|
33
|
+
return None
|
|
34
|
+
term = os.environ.get("TERM", "")
|
|
35
|
+
if term.startswith(("screen", "tmux", "dumb")):
|
|
36
|
+
return None
|
|
37
|
+
if not sys.stdout.isatty():
|
|
38
|
+
return None
|
|
39
|
+
try:
|
|
40
|
+
fd = os.open("/dev/tty", os.O_RDWR | os.O_NOCTTY)
|
|
41
|
+
except OSError:
|
|
42
|
+
return None
|
|
43
|
+
try:
|
|
44
|
+
if termios is None:
|
|
45
|
+
return None
|
|
46
|
+
old = termios.tcgetattr(fd)
|
|
47
|
+
new = list(old)
|
|
48
|
+
new[3] = new[3] & ~(termios.ECHO | termios.ICANON)
|
|
49
|
+
termios.tcsetattr(fd, termios.TCSADRAIN, new)
|
|
50
|
+
try:
|
|
51
|
+
os.write(fd, b"\x1b]11;?\x1b\\")
|
|
52
|
+
resp = _read_osc_response(fd)
|
|
53
|
+
finally:
|
|
54
|
+
termios.tcsetattr(fd, termios.TCSADRAIN, old)
|
|
55
|
+
_drain_fd(fd)
|
|
56
|
+
if resp is None:
|
|
57
|
+
return None
|
|
58
|
+
return _parse_osc_color(resp)
|
|
59
|
+
except (OSError, ValueError):
|
|
60
|
+
return None
|
|
61
|
+
finally:
|
|
62
|
+
try:
|
|
63
|
+
os.close(fd)
|
|
64
|
+
except OSError as e:
|
|
65
|
+
if os.environ.get("GITWISE_DEBUG", "").lower() in ("1", "true"):
|
|
66
|
+
sys.stderr.write(f"[gitwise debug] tty close failed: {e}\n")
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def _read_osc_response(fd: int) -> str | None:
|
|
70
|
+
buf = bytearray()
|
|
71
|
+
osc_received = False
|
|
72
|
+
while len(buf) < 50:
|
|
73
|
+
ready, _, _ = select.select([fd], [], [], _OSC_TIMEOUT)
|
|
74
|
+
if not ready:
|
|
75
|
+
break
|
|
76
|
+
try:
|
|
77
|
+
ch = os.read(fd, 1)
|
|
78
|
+
except OSError:
|
|
79
|
+
break
|
|
80
|
+
if not ch:
|
|
81
|
+
break
|
|
82
|
+
buf.extend(ch)
|
|
83
|
+
decoded = buf.decode("ascii", errors="ignore")
|
|
84
|
+
if "\x1b]" in decoded and not osc_received:
|
|
85
|
+
osc_received = True
|
|
86
|
+
if osc_received:
|
|
87
|
+
if decoded.endswith("\x1b\\") or decoded.endswith("\x07"):
|
|
88
|
+
return decoded
|
|
89
|
+
if "\x1b[" in decoded[decoded.index("\x1b]") + 1 :]:
|
|
90
|
+
return decoded[: decoded.index("\x1b[", decoded.index("\x1b]") + 1)]
|
|
91
|
+
return None
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
def _parse_osc_color(resp: str) -> str | None:
|
|
95
|
+
idx = resp.find("\x1b]")
|
|
96
|
+
if idx == -1:
|
|
97
|
+
return None
|
|
98
|
+
after_osc = resp[idx + 2 :]
|
|
99
|
+
semicolon = after_osc.find(";")
|
|
100
|
+
if semicolon == -1:
|
|
101
|
+
return None
|
|
102
|
+
color_part = after_osc[semicolon + 1 :]
|
|
103
|
+
color_part = color_part.rstrip("\x07").rstrip("\x1b\\").strip()
|
|
104
|
+
for terminator in ("\x1b[", "\x1b"):
|
|
105
|
+
tidx = color_part.find(terminator)
|
|
106
|
+
if tidx >= 0:
|
|
107
|
+
color_part = color_part[:tidx]
|
|
108
|
+
if color_part.startswith("rgb:"):
|
|
109
|
+
parts = color_part[4:].split("/")
|
|
110
|
+
if len(parts) == 3:
|
|
111
|
+
r, g, b = (
|
|
112
|
+
_parse_rgb_component(parts[0]),
|
|
113
|
+
_parse_rgb_component(parts[1]),
|
|
114
|
+
_parse_rgb_component(parts[2]),
|
|
115
|
+
)
|
|
116
|
+
return f"#{r:02x}{g:02x}{b:02x}"
|
|
117
|
+
elif color_part.startswith("#"):
|
|
118
|
+
clean = color_part.rstrip()
|
|
119
|
+
for terminator in ("\x1b[", "\x1b"):
|
|
120
|
+
tidx = clean.find(terminator)
|
|
121
|
+
if tidx >= 0:
|
|
122
|
+
clean = clean[:tidx]
|
|
123
|
+
return clean
|
|
124
|
+
return None
|
|
125
|
+
|
|
126
|
+
|
|
127
|
+
def _parse_rgb_component(s: str) -> int:
|
|
128
|
+
val = int(s, 16)
|
|
129
|
+
if len(s) > 2:
|
|
130
|
+
val = val >> (4 * (len(s) - 2))
|
|
131
|
+
return val & 0xFF
|
|
132
|
+
|
|
133
|
+
|
|
134
|
+
def _relative_luminance(hex_color: str) -> float:
|
|
135
|
+
h = hex_color.lstrip("#")
|
|
136
|
+
r, g, b = int(h[0:2], 16) / 255, int(h[2:4], 16) / 255, int(h[4:6], 16) / 255
|
|
137
|
+
|
|
138
|
+
def linearize(c: float) -> float:
|
|
139
|
+
return c / 12.92 if c <= 0.04045 else ((c + 0.055) / 1.055) ** 2.4
|
|
140
|
+
|
|
141
|
+
return 0.2126 * linearize(r) + 0.7152 * linearize(g) + 0.0722 * linearize(b)
|
|
142
|
+
|
|
143
|
+
|
|
144
|
+
def _is_dark_background(bg_hex: str) -> bool:
|
|
145
|
+
return _relative_luminance(bg_hex) < _BRIGHTNESS_THRESHOLD
|
|
146
|
+
|
|
147
|
+
|
|
148
|
+
class RuntimeConfig:
|
|
149
|
+
__slots__ = (
|
|
150
|
+
"_theme_tokens",
|
|
151
|
+
"color_depth",
|
|
152
|
+
"debug",
|
|
153
|
+
"has_bat",
|
|
154
|
+
"has_delta",
|
|
155
|
+
"is_tty",
|
|
156
|
+
"terminal_width",
|
|
157
|
+
"theme",
|
|
158
|
+
)
|
|
159
|
+
|
|
160
|
+
_theme_tokens: ThemeTokens
|
|
161
|
+
color_depth: ColorDepth
|
|
162
|
+
debug: bool
|
|
163
|
+
has_bat: bool
|
|
164
|
+
has_delta: bool
|
|
165
|
+
is_tty: bool
|
|
166
|
+
terminal_width: int
|
|
167
|
+
theme: str
|
|
168
|
+
|
|
169
|
+
def __init__(self) -> None:
|
|
170
|
+
self.has_bat = bool(shutil.which("bat"))
|
|
171
|
+
self.has_delta = bool(shutil.which("delta"))
|
|
172
|
+
self.theme = self._detect_theme()
|
|
173
|
+
self.is_tty = sys.stdout.isatty()
|
|
174
|
+
self.debug = os.environ.get("GITWISE_DEBUG", "").lower() in ("1", "true")
|
|
175
|
+
self.terminal_width = self._detect_terminal_width()
|
|
176
|
+
self.color_depth = self._detect_color_depth()
|
|
177
|
+
self._theme_tokens = build_theme(self.theme)
|
|
178
|
+
|
|
179
|
+
@property
|
|
180
|
+
def theme_tokens(self) -> ThemeTokens:
|
|
181
|
+
return self._theme_tokens
|
|
182
|
+
|
|
183
|
+
@staticmethod
|
|
184
|
+
def _detect_theme() -> str:
|
|
185
|
+
explicit = os.environ.get("GITWISE_THEME", "").lower()
|
|
186
|
+
if explicit in ("dark", "light"):
|
|
187
|
+
return explicit
|
|
188
|
+
|
|
189
|
+
cli_theme = os.environ.get("CLITHEME", "").split(":")[0].lower()
|
|
190
|
+
if cli_theme in ("dark", "light"):
|
|
191
|
+
return cli_theme
|
|
192
|
+
|
|
193
|
+
bg_hex = _query_bg_color()
|
|
194
|
+
if bg_hex is not None:
|
|
195
|
+
return "dark" if _is_dark_background(bg_hex) else "light"
|
|
196
|
+
|
|
197
|
+
colorfgbg = os.environ.get("COLORFGBG", "")
|
|
198
|
+
if colorfgbg:
|
|
199
|
+
parts = colorfgbg.split(";")
|
|
200
|
+
if len(parts) >= 2:
|
|
201
|
+
bg = parts[-1]
|
|
202
|
+
if bg in ("0", "8", "16"):
|
|
203
|
+
return "dark"
|
|
204
|
+
return "light"
|
|
205
|
+
|
|
206
|
+
fg_bg = os.environ.get("FG_BG", "")
|
|
207
|
+
if fg_bg:
|
|
208
|
+
parts = fg_bg.split(";")
|
|
209
|
+
if len(parts) >= 2:
|
|
210
|
+
bg = parts[-1]
|
|
211
|
+
if bg in ("0", "8", "16"):
|
|
212
|
+
return "dark"
|
|
213
|
+
return "light"
|
|
214
|
+
|
|
215
|
+
return "dark"
|
|
216
|
+
|
|
217
|
+
@staticmethod
|
|
218
|
+
def _detect_terminal_width() -> int:
|
|
219
|
+
from .design import MAX_WIDTH, MIN_WIDTH, detect_terminal_width
|
|
220
|
+
|
|
221
|
+
env_width = os.environ.get("GITWISE_WIDTH", "")
|
|
222
|
+
if env_width.isdigit():
|
|
223
|
+
w = int(env_width)
|
|
224
|
+
return max(MIN_WIDTH, min(w, MAX_WIDTH))
|
|
225
|
+
return detect_terminal_width()
|
|
226
|
+
|
|
227
|
+
@staticmethod
|
|
228
|
+
def _detect_color_depth() -> ColorDepth:
|
|
229
|
+
from .design import detect_color_depth
|
|
230
|
+
|
|
231
|
+
return detect_color_depth()
|
|
232
|
+
|
|
233
|
+
|
|
234
|
+
_runtime_config: RuntimeConfig | None = None
|
|
235
|
+
|
|
236
|
+
|
|
237
|
+
def get_runtime_config() -> RuntimeConfig:
|
|
238
|
+
global _runtime_config
|
|
239
|
+
if _runtime_config is None:
|
|
240
|
+
_runtime_config = RuntimeConfig()
|
|
241
|
+
return _runtime_config
|
|
242
|
+
|
|
243
|
+
|
|
244
|
+
def reset_runtime_config() -> None:
|
|
245
|
+
global _runtime_config
|
|
246
|
+
_runtime_config = None
|
gitwise/audit.py
ADDED
|
@@ -0,0 +1,338 @@
|
|
|
1
|
+
"""Read-only repo diagnostics with human/JSON dual output."""
|
|
2
|
+
|
|
3
|
+
import platform
|
|
4
|
+
import shutil
|
|
5
|
+
import subprocess
|
|
6
|
+
from datetime import datetime, timezone
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
from typing import Any
|
|
9
|
+
|
|
10
|
+
from .git import (
|
|
11
|
+
gpg_status,
|
|
12
|
+
has_commit_graph,
|
|
13
|
+
has_remote,
|
|
14
|
+
has_upstream,
|
|
15
|
+
require_root,
|
|
16
|
+
stale_branches,
|
|
17
|
+
)
|
|
18
|
+
from .git import run as git_run
|
|
19
|
+
from .i18n import t
|
|
20
|
+
from .output import (
|
|
21
|
+
debug,
|
|
22
|
+
ok,
|
|
23
|
+
print_blank,
|
|
24
|
+
print_bracket,
|
|
25
|
+
print_dim,
|
|
26
|
+
print_error_styled,
|
|
27
|
+
print_header,
|
|
28
|
+
print_json,
|
|
29
|
+
status,
|
|
30
|
+
)
|
|
31
|
+
|
|
32
|
+
_STALE_DAYS = 30
|
|
33
|
+
_LARGE_BLOB_MIN_BYTES = 1_000_000 # 1MB
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def _check_fsmonitor(cwd: Path) -> bool:
|
|
37
|
+
r = git_run(["config", "--get", "core.fsmonitor"], cwd=cwd, check=False)
|
|
38
|
+
return r.returncode == 0 and r.stdout.strip().lower() in ("true", "1")
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def _find_old_stashes(cwd: Path) -> list[dict]:
|
|
42
|
+
r = git_run(
|
|
43
|
+
["reflog", "show", "--format=%gd|%ci|%gs", "stash"],
|
|
44
|
+
cwd=cwd,
|
|
45
|
+
check=False,
|
|
46
|
+
)
|
|
47
|
+
if r.returncode != 0 or not r.stdout.strip():
|
|
48
|
+
return []
|
|
49
|
+
now = datetime.now(timezone.utc)
|
|
50
|
+
old: list[dict] = []
|
|
51
|
+
for line in r.stdout.splitlines():
|
|
52
|
+
parts = line.split("|", 2)
|
|
53
|
+
if len(parts) < 3:
|
|
54
|
+
continue
|
|
55
|
+
ref, date_str, subject = parts
|
|
56
|
+
try:
|
|
57
|
+
date = datetime.strptime(date_str.strip(), "%Y-%m-%d %H:%M:%S %z")
|
|
58
|
+
if date.tzinfo is None:
|
|
59
|
+
date = date.replace(tzinfo=timezone.utc)
|
|
60
|
+
age = (now - date).days
|
|
61
|
+
if age >= _STALE_DAYS:
|
|
62
|
+
old.append({"ref": ref.strip(), "age_days": age, "subject": subject.strip()})
|
|
63
|
+
except (ValueError, TypeError):
|
|
64
|
+
debug(f"stash date parse failed: {line!r}")
|
|
65
|
+
continue
|
|
66
|
+
return old
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def _find_large_blobs(cwd: Path, top_n: int = 3) -> list[dict]:
|
|
70
|
+
if git_run(["rev-parse", "HEAD"], cwd=cwd, check=False).returncode != 0:
|
|
71
|
+
return []
|
|
72
|
+
r = git_run(["ls-tree", "-r", "--long", "HEAD"], cwd=cwd, check=False)
|
|
73
|
+
if r.returncode != 0:
|
|
74
|
+
return []
|
|
75
|
+
blobs: list[dict] = []
|
|
76
|
+
for line in r.stdout.splitlines():
|
|
77
|
+
parts = line.split()
|
|
78
|
+
if len(parts) < 5:
|
|
79
|
+
continue
|
|
80
|
+
try:
|
|
81
|
+
size = int(parts[3])
|
|
82
|
+
path = " ".join(parts[4:])
|
|
83
|
+
if size >= _LARGE_BLOB_MIN_BYTES:
|
|
84
|
+
blobs.append({"path": path, "size": size, "size_mb": round(size / 1_048_576, 2)})
|
|
85
|
+
except (ValueError, IndexError):
|
|
86
|
+
debug(f"ls-tree line parse failed: {line!r}")
|
|
87
|
+
continue
|
|
88
|
+
blobs.sort(key=lambda b: b["size"], reverse=True)
|
|
89
|
+
return blobs[:top_n]
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
def _check_mixed_staging(cwd: Path) -> bool:
|
|
93
|
+
r = git_run(["status", "--porcelain"], cwd=cwd, check=False)
|
|
94
|
+
if r.returncode != 0:
|
|
95
|
+
return False
|
|
96
|
+
has_staged = has_unstaged = False
|
|
97
|
+
for line in r.stdout.splitlines():
|
|
98
|
+
if len(line) >= 2:
|
|
99
|
+
if line[0] not in (" ", "?", "!"):
|
|
100
|
+
has_staged = True
|
|
101
|
+
if line[1] not in (" ", "?", "!"):
|
|
102
|
+
has_unstaged = True
|
|
103
|
+
return has_staged and has_unstaged
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
def _run_git_sizer(cwd: Path) -> dict | None:
|
|
107
|
+
if not shutil.which("git-sizer"):
|
|
108
|
+
return None
|
|
109
|
+
r = subprocess.run(
|
|
110
|
+
["git-sizer", "--threshold=2", "--json"],
|
|
111
|
+
cwd=cwd,
|
|
112
|
+
capture_output=True,
|
|
113
|
+
text=True,
|
|
114
|
+
check=False,
|
|
115
|
+
)
|
|
116
|
+
if r.returncode not in (0, 1):
|
|
117
|
+
return None
|
|
118
|
+
import json
|
|
119
|
+
|
|
120
|
+
try:
|
|
121
|
+
return json.loads(r.stdout)
|
|
122
|
+
except (json.JSONDecodeError, ValueError):
|
|
123
|
+
return None
|
|
124
|
+
|
|
125
|
+
|
|
126
|
+
def _check_gpg_findings(gpg: dict) -> list[dict]:
|
|
127
|
+
if gpg["gpgsign_enabled"] and not gpg["gpg_binary"]:
|
|
128
|
+
return [
|
|
129
|
+
{
|
|
130
|
+
"type": "gpg_binary_missing",
|
|
131
|
+
"severity": "high",
|
|
132
|
+
"message": t("gpg_binary_missing_audit"),
|
|
133
|
+
"fix": t("gpg_binary_missing_fix"),
|
|
134
|
+
"cost_of_fix": t("trivial"),
|
|
135
|
+
"cost_of_ignore": t("gpg_binary_missing_cost"),
|
|
136
|
+
}
|
|
137
|
+
]
|
|
138
|
+
if gpg["gpgsign_enabled"] and not gpg["signing_key_set"]:
|
|
139
|
+
return [
|
|
140
|
+
{
|
|
141
|
+
"type": "gpg_key_missing",
|
|
142
|
+
"severity": "high",
|
|
143
|
+
"message": t("gpg_key_missing_audit"),
|
|
144
|
+
"fix": t("gpg_key_missing_fix"),
|
|
145
|
+
"cost_of_fix": t("trivial"),
|
|
146
|
+
"cost_of_ignore": t("gpg_key_missing_cost"),
|
|
147
|
+
}
|
|
148
|
+
]
|
|
149
|
+
if not gpg["gpgsign_enabled"]:
|
|
150
|
+
return [
|
|
151
|
+
{
|
|
152
|
+
"type": "gpg_not_configured",
|
|
153
|
+
"severity": "info",
|
|
154
|
+
"message": t("gpg_not_configured_audit"),
|
|
155
|
+
"fix": t("gpg_not_configured_fix"),
|
|
156
|
+
"cost_of_fix": t("gpg_not_configured_fix_cost"),
|
|
157
|
+
"cost_of_ignore": t("gpg_not_configured_cost"),
|
|
158
|
+
}
|
|
159
|
+
]
|
|
160
|
+
return []
|
|
161
|
+
|
|
162
|
+
|
|
163
|
+
_SEVERITY_ICON = {"critical": "🔴", "high": "🟠", "medium": "🟡", "low": "🔵", "info": "ℹ️"}
|
|
164
|
+
|
|
165
|
+
|
|
166
|
+
def run_audit(*, quick: bool = False, as_json: bool = False) -> int:
|
|
167
|
+
root, err = require_root()
|
|
168
|
+
if err:
|
|
169
|
+
return err
|
|
170
|
+
if root is None:
|
|
171
|
+
return 1
|
|
172
|
+
cwd = root
|
|
173
|
+
|
|
174
|
+
findings: list[dict[str, Any]] = []
|
|
175
|
+
|
|
176
|
+
gpg = gpg_status(cwd)
|
|
177
|
+
findings.extend(_check_gpg_findings(gpg))
|
|
178
|
+
|
|
179
|
+
stale = stale_branches(cwd)
|
|
180
|
+
if stale:
|
|
181
|
+
findings.append(
|
|
182
|
+
{
|
|
183
|
+
"type": "stale_branches",
|
|
184
|
+
"severity": "medium",
|
|
185
|
+
"count": len(stale),
|
|
186
|
+
"branches": stale,
|
|
187
|
+
"message": t("stale_branches_audit", count=str(len(stale))),
|
|
188
|
+
"fix": t("clean_fix"),
|
|
189
|
+
"cost_of_fix": t("stale_branches_fix_cost"),
|
|
190
|
+
"cost_of_ignore": t("stale_branches_cost"),
|
|
191
|
+
}
|
|
192
|
+
)
|
|
193
|
+
|
|
194
|
+
has_commit_graph_val = has_commit_graph(cwd)
|
|
195
|
+
if not has_commit_graph_val:
|
|
196
|
+
findings.append(
|
|
197
|
+
{
|
|
198
|
+
"type": "missing_commit_graph",
|
|
199
|
+
"severity": "medium",
|
|
200
|
+
"message": t("commit_graph_ausente"),
|
|
201
|
+
"fix": t("commit_graph_fix"),
|
|
202
|
+
"cost_of_fix": t("commit_graph_fix_cost"),
|
|
203
|
+
"cost_of_ignore": t("commit_graph_cost"),
|
|
204
|
+
}
|
|
205
|
+
)
|
|
206
|
+
|
|
207
|
+
if platform.system() == "Darwin":
|
|
208
|
+
if not _check_fsmonitor(cwd):
|
|
209
|
+
findings.append(
|
|
210
|
+
{
|
|
211
|
+
"type": "fsmonitor_disabled",
|
|
212
|
+
"severity": "low",
|
|
213
|
+
"message": t("fsmonitor_disabled"),
|
|
214
|
+
"fix": t("fsmonitor_fix"),
|
|
215
|
+
"cost_of_fix": t("fsmonitor_fix_cost"),
|
|
216
|
+
"cost_of_ignore": t("fsmonitor_cost"),
|
|
217
|
+
}
|
|
218
|
+
)
|
|
219
|
+
|
|
220
|
+
with status(t("status_audit_stashes")):
|
|
221
|
+
old_stashes = _find_old_stashes(cwd)
|
|
222
|
+
if old_stashes:
|
|
223
|
+
findings.append(
|
|
224
|
+
{
|
|
225
|
+
"type": "old_stashes",
|
|
226
|
+
"severity": "low",
|
|
227
|
+
"count": len(old_stashes),
|
|
228
|
+
"stashes": old_stashes,
|
|
229
|
+
"message": t(
|
|
230
|
+
"old_stashes_msg", count=str(len(old_stashes)), days=str(_STALE_DAYS)
|
|
231
|
+
),
|
|
232
|
+
"fix": t("stash_fix"),
|
|
233
|
+
"cost_of_fix": t("stash_fix_cost"),
|
|
234
|
+
"cost_of_ignore": t("stash_cost"),
|
|
235
|
+
}
|
|
236
|
+
)
|
|
237
|
+
|
|
238
|
+
large_blobs: list[dict] = []
|
|
239
|
+
if not quick:
|
|
240
|
+
with status(t("status_audit_blobs")):
|
|
241
|
+
large_blobs = _find_large_blobs(cwd)
|
|
242
|
+
if large_blobs:
|
|
243
|
+
findings.append(
|
|
244
|
+
{
|
|
245
|
+
"type": "large_blobs",
|
|
246
|
+
"severity": "low",
|
|
247
|
+
"count": len(large_blobs),
|
|
248
|
+
"blobs": large_blobs,
|
|
249
|
+
"message": t("large_blobs", count=str(len(large_blobs))),
|
|
250
|
+
"fix": t("large_blobs_fix"),
|
|
251
|
+
"cost_of_fix": t("large_blobs_fix_cost"),
|
|
252
|
+
"cost_of_ignore": t("large_blobs_cost"),
|
|
253
|
+
}
|
|
254
|
+
)
|
|
255
|
+
|
|
256
|
+
if _check_mixed_staging(cwd):
|
|
257
|
+
findings.append(
|
|
258
|
+
{
|
|
259
|
+
"type": "mixed_staging",
|
|
260
|
+
"severity": "info",
|
|
261
|
+
"message": t("mixed_staging"),
|
|
262
|
+
"fix": t("mixed_staging_fix"),
|
|
263
|
+
"cost_of_fix": t("mixed_staging_fix_cost"),
|
|
264
|
+
"cost_of_ignore": t("mixed_staging_cost"),
|
|
265
|
+
}
|
|
266
|
+
)
|
|
267
|
+
|
|
268
|
+
if quick:
|
|
269
|
+
sizer = None
|
|
270
|
+
else:
|
|
271
|
+
with status(t("status_audit_sizer")):
|
|
272
|
+
sizer = _run_git_sizer(cwd)
|
|
273
|
+
|
|
274
|
+
_has_remote = has_remote(cwd)
|
|
275
|
+
_has_upstream = has_upstream(cwd)
|
|
276
|
+
if _has_remote and not _has_upstream:
|
|
277
|
+
findings.append(
|
|
278
|
+
{
|
|
279
|
+
"type": "no_upstream",
|
|
280
|
+
"severity": "low",
|
|
281
|
+
"message": t("no_upstream_audit"),
|
|
282
|
+
"fix": t("no_upstream_fix"),
|
|
283
|
+
"cost_of_ignore": t("trivial"),
|
|
284
|
+
}
|
|
285
|
+
)
|
|
286
|
+
|
|
287
|
+
from .health import compute_health
|
|
288
|
+
|
|
289
|
+
health = compute_health(
|
|
290
|
+
cwd,
|
|
291
|
+
_gpg_override=gpg,
|
|
292
|
+
_has_commit_graph=has_commit_graph_val,
|
|
293
|
+
_has_remote=_has_remote,
|
|
294
|
+
_has_upstream=_has_upstream,
|
|
295
|
+
_stale_branches=stale,
|
|
296
|
+
)
|
|
297
|
+
|
|
298
|
+
has_issues = any(f["severity"] in ("critical", "high", "medium") for f in findings)
|
|
299
|
+
|
|
300
|
+
result: dict[str, Any] = {
|
|
301
|
+
"v": 2,
|
|
302
|
+
"ok": not has_issues,
|
|
303
|
+
"quick": quick,
|
|
304
|
+
"findings": findings,
|
|
305
|
+
"summary": {
|
|
306
|
+
"stale_branches": len(stale),
|
|
307
|
+
"commit_graph": has_commit_graph_val,
|
|
308
|
+
"old_stashes": len(old_stashes),
|
|
309
|
+
"large_blobs": len(large_blobs),
|
|
310
|
+
"gpg_ready": gpg["ready"],
|
|
311
|
+
},
|
|
312
|
+
"git_sizer": sizer,
|
|
313
|
+
"health": {"score": health["score"], "grade": health["grade"]},
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
if as_json:
|
|
317
|
+
print_json(result)
|
|
318
|
+
return 0 if not has_issues else 1
|
|
319
|
+
|
|
320
|
+
if not findings:
|
|
321
|
+
ok(t("repo_good_shape", suffix=" (quick)" if quick else ""))
|
|
322
|
+
return 0
|
|
323
|
+
|
|
324
|
+
print_header(t("diagnostic", suffix=" quick" if quick else "", count=str(len(findings))))
|
|
325
|
+
print_blank()
|
|
326
|
+
for f in findings:
|
|
327
|
+
sev = f["severity"]
|
|
328
|
+
if sev in ("critical", "high"):
|
|
329
|
+
print_error_styled(f" [{sev.upper()}] {f['message']}")
|
|
330
|
+
elif sev == "medium":
|
|
331
|
+
print_bracket(sev.upper(), f["message"])
|
|
332
|
+
else:
|
|
333
|
+
print_dim(f" [{sev.upper()}] {f['message']}")
|
|
334
|
+
print_dim(f" {t('audit_fix_label')}: `{f['fix']}`")
|
|
335
|
+
print_dim(f" {t('audit_ignore_label')}: {f['cost_of_ignore']}")
|
|
336
|
+
print_blank()
|
|
337
|
+
|
|
338
|
+
return 0 if not has_issues else 1
|