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/_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