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/context.py ADDED
@@ -0,0 +1,163 @@
1
+ """gitwise context — enriched snapshot for LLMs (tree, contributors, topology, file types, TODO/FIXME)."""
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 info, print_blank, print_bracket, print_dim, print_header, print_json
9
+
10
+
11
+ def _directory_tree(root: Path, max_depth: int = 3) -> list[str]:
12
+ lines: list[str] = []
13
+ skip = {
14
+ ".git",
15
+ "__pycache__",
16
+ "node_modules",
17
+ ".venv",
18
+ ".mypy_cache",
19
+ ".pytest_cache",
20
+ ".ruff_cache",
21
+ }
22
+
23
+ def _walk(path: Path, prefix: str, depth: int) -> None:
24
+ if depth > max_depth:
25
+ return
26
+ try:
27
+ entries = sorted(path.iterdir(), key=lambda e: (e.is_file(), e.name))
28
+ except PermissionError:
29
+ return
30
+ dirs = [e for e in entries if e.is_dir() and e.name not in skip]
31
+ files = [e for e in entries if e.is_file() and e.name not in skip]
32
+ children = dirs + files
33
+ for i, child in enumerate(children):
34
+ is_last = i == len(children) - 1
35
+ connector = "└── " if is_last else "├── "
36
+ lines.append(f"{prefix}{connector}{child.name}")
37
+ if child.is_dir():
38
+ extension = " " if is_last else "│ "
39
+ _walk(child, prefix + extension, depth + 1)
40
+
41
+ _walk(root, "", 0)
42
+ return lines
43
+
44
+
45
+ def _top_contributors(root: Path, count: int = 5) -> list[dict[str, str | int]]:
46
+ r = git_run(["shortlog", "-sne", "HEAD"], cwd=root, check=False)
47
+ if r.returncode != 0:
48
+ return []
49
+ contributors: list[dict[str, str | int]] = []
50
+ for line in r.stdout.strip().splitlines()[:count]:
51
+ parts = line.strip().split("\t", 1)
52
+ if len(parts) == 2:
53
+ contributors.append({"commits": int(parts[0].strip()), "author": parts[1].strip()})
54
+ return contributors
55
+
56
+
57
+ def _file_type_breakdown(root: Path) -> dict[str, int]:
58
+ r = git_run(["ls-tree", "-r", "--name-only", "HEAD"], cwd=root, check=False)
59
+ if r.returncode != 0:
60
+ return {}
61
+ counts: dict[str, int] = {}
62
+ for line in r.stdout.splitlines():
63
+ name = line.strip()
64
+ ext = name.rsplit(".", 1)[-1].lower() if "." in name else "(no ext)"
65
+ counts[ext] = counts.get(ext, 0) + 1
66
+ return dict(sorted(counts.items(), key=lambda x: -x[1])[:15])
67
+
68
+
69
+ def _todo_fixme_counts(root: Path) -> dict[str, int]:
70
+ def _count_pattern(pattern: str) -> int:
71
+ r = git_run(
72
+ ["grep", "-c", "-e", pattern, "HEAD", "--", "."],
73
+ cwd=root,
74
+ check=False,
75
+ )
76
+ if r.returncode != 0:
77
+ return 0
78
+ total = 0
79
+ for line in r.stdout.splitlines():
80
+ try:
81
+ total += int(line.rsplit(":", 1)[-1])
82
+ except (ValueError, IndexError):
83
+ continue
84
+ return total
85
+
86
+ return {"todo": _count_pattern("TODO"), "fixme": _count_pattern("FIXME")}
87
+
88
+
89
+ def _branch_topology(root: Path) -> dict[str, list[str]]:
90
+ r = git_run(["branch", "-a", "--format=%(refname)"], cwd=root, check=False)
91
+ if r.returncode != 0:
92
+ return {"local": [], "remote": []}
93
+ local: list[str] = []
94
+ remote: list[str] = []
95
+ for line in r.stdout.splitlines():
96
+ ref = line.strip()
97
+ if ref.startswith("refs/heads/"):
98
+ local.append(ref.removeprefix("refs/heads/"))
99
+ elif ref.startswith("refs/remotes/"):
100
+ remote.append(ref.removeprefix("refs/remotes/"))
101
+ return {"local": local, "remote": remote}
102
+
103
+
104
+ def run_context(*, as_json: bool = False) -> int:
105
+ root, err = require_root()
106
+ if err:
107
+ return err
108
+ if root is None:
109
+ return 1
110
+
111
+ tree = _directory_tree(root)
112
+ contributors = _top_contributors(root)
113
+ file_types = _file_type_breakdown(root)
114
+ todo_fixme = _todo_fixme_counts(root)
115
+ topology = _branch_topology(root)
116
+
117
+ if as_json:
118
+ from .health import compute_health
119
+
120
+ h = compute_health(root)
121
+ print_json(
122
+ {
123
+ "v": 2,
124
+ "ok": True,
125
+ "tree": tree,
126
+ "contributors": contributors,
127
+ "file_types": file_types,
128
+ "todo_fixme": todo_fixme,
129
+ "branches": topology,
130
+ "health": {"score": h["score"], "grade": h["grade"]},
131
+ }
132
+ )
133
+ else:
134
+ print_header(t("ctx_directory_tree"))
135
+ for ln in tree[:50]:
136
+ info(f" {ln}")
137
+ if len(tree) > 50:
138
+ print_dim(t("ctx_more_entries", count=str(len(tree) - 50)))
139
+ print_blank()
140
+ if contributors:
141
+ print_bracket(t("ctx_top_contributors"))
142
+ for c in contributors:
143
+ print_dim(f" {c['commits']:>5} {c['author']}")
144
+ print_blank()
145
+ if file_types:
146
+ print_bracket(t("ctx_file_types"))
147
+ for ext, count in list(file_types.items())[:10]:
148
+ print_dim(f" .{ext}: {count}")
149
+ print_blank()
150
+ if todo_fixme["todo"] or todo_fixme["fixme"]:
151
+ print_bracket(
152
+ t("ctx_todo_fixme", todo=str(todo_fixme["todo"]), fixme=str(todo_fixme["fixme"]))
153
+ )
154
+ print_blank()
155
+ print_bracket(
156
+ t(
157
+ "ctx_branches",
158
+ local=str(len(topology["local"])),
159
+ remote=str(len(topology["remote"])),
160
+ )
161
+ )
162
+
163
+ return 0
gitwise/design.py ADDED
@@ -0,0 +1,383 @@
1
+ """Design system tokens and utilities for gitwise CLI output.
2
+
3
+ Defines the visual identity: dual themes (Light/Dark), Gitwise Orange accent,
4
+ WCAG AA compliant colors, and text layout utilities.
5
+
6
+ Rich library handles all rendering. This module provides color definitions,
7
+ text utilities, and the argparse help formatter only.
8
+ """
9
+
10
+ import argparse
11
+ import os
12
+ import re
13
+ import shutil
14
+ import unicodedata
15
+ from collections.abc import Callable
16
+ from typing import Literal
17
+
18
+ from .i18n import t
19
+
20
+ ColorDepth = Literal["truecolor", "256color", "16color"]
21
+
22
+ DARK_FG = "#CDD6F4"
23
+ DARK_BG = "#1E1E2E"
24
+ DARK_SECONDARY = "#9399B2"
25
+ DARK_MUTED = "#8B8FA8"
26
+ DARK_DIM = "#A6ADC8"
27
+ DARK_ACCENT = "#E69138"
28
+ DARK_SUCCESS = "#A6E3A1"
29
+ DARK_ERROR = "#F38BA8"
30
+ DARK_WARNING = "#F9E2AF"
31
+ DARK_BRAND = "#89B4FA"
32
+
33
+ LIGHT_FG = "#1A1A1A"
34
+ LIGHT_BG = "#F8F6F1"
35
+ LIGHT_SECONDARY = "#585B70"
36
+ LIGHT_MUTED = "#6C7086"
37
+ LIGHT_DIM = "#64646E"
38
+ LIGHT_ACCENT = "#C45200"
39
+ LIGHT_SUCCESS = "#2E7D32"
40
+ LIGHT_ERROR = "#C62828"
41
+ LIGHT_WARNING = "#BF360C"
42
+ LIGHT_BRAND = "#1565C0"
43
+
44
+ ACCENT_HEX = "#E69138"
45
+
46
+ MIN_WIDTH = 80
47
+ DEFAULT_WIDTH = 100
48
+ MAX_WIDTH = 120
49
+ BRACKET_LEFT = "["
50
+ BRACKET_RIGHT = "]"
51
+ SEPARATOR = " "
52
+
53
+ ANSI_RESET = "\033[0m"
54
+ ANSI_BOLD = "\033[1m"
55
+
56
+
57
+ def hex_to_ansi_fg(hex_color: str) -> str:
58
+ h = hex_color.lstrip("#")
59
+ if len(h) < 6:
60
+ return ""
61
+ r, g, b = int(h[0:2], 16), int(h[2:4], 16), int(h[4:6], 16)
62
+ return f"\033[38;2;{r};{g};{b}m"
63
+
64
+
65
+ class ThemeTokens:
66
+ __slots__ = (
67
+ "accent",
68
+ "bg",
69
+ "brand",
70
+ "dim",
71
+ "error",
72
+ "fg",
73
+ "secondary",
74
+ "success",
75
+ "warning",
76
+ )
77
+
78
+ def __init__(
79
+ self,
80
+ fg: str,
81
+ bg: str,
82
+ secondary: str,
83
+ accent: str,
84
+ success: str,
85
+ error: str,
86
+ warning: str = "",
87
+ brand: str = "",
88
+ dim: str = "",
89
+ ) -> None:
90
+ self.fg = fg
91
+ self.bg = bg
92
+ self.secondary = secondary
93
+ self.accent = accent
94
+ self.success = success
95
+ self.error = error
96
+ self.warning = warning
97
+ self.brand = brand
98
+ self.dim = dim
99
+
100
+
101
+ _DARK_THEME = ThemeTokens(
102
+ fg=DARK_FG,
103
+ bg=DARK_BG,
104
+ secondary=DARK_SECONDARY,
105
+ accent=DARK_ACCENT,
106
+ success=DARK_SUCCESS,
107
+ error=DARK_ERROR,
108
+ warning=DARK_WARNING,
109
+ brand=DARK_BRAND,
110
+ dim=DARK_DIM,
111
+ )
112
+
113
+ _LIGHT_THEME = ThemeTokens(
114
+ fg=LIGHT_FG,
115
+ bg=LIGHT_BG,
116
+ secondary=LIGHT_SECONDARY,
117
+ accent=LIGHT_ACCENT,
118
+ success=LIGHT_SUCCESS,
119
+ error=LIGHT_ERROR,
120
+ warning=LIGHT_WARNING,
121
+ brand=LIGHT_BRAND,
122
+ dim=LIGHT_DIM,
123
+ )
124
+
125
+ _THEMES: dict[str, ThemeTokens] = {"dark": _DARK_THEME, "light": _LIGHT_THEME}
126
+
127
+
128
+ def detect_color_depth() -> ColorDepth:
129
+ if os.environ.get("NO_COLOR", "") != "" or os.environ.get("GITWISE_NO_COLOR", "").lower() in (
130
+ "1",
131
+ "true",
132
+ ):
133
+ return "16color"
134
+ if (
135
+ os.environ.get("CLICOLOR_FORCE", "").lower() in ("1", "true")
136
+ or os.environ.get("FORCE_COLOR", "") != ""
137
+ ):
138
+ colorterm = os.environ.get("COLORTERM", "").lower()
139
+ if colorterm.startswith(("truecolor", "24bit")):
140
+ return "truecolor"
141
+ return "256color"
142
+ colorterm = os.environ.get("COLORTERM", "").lower()
143
+ if colorterm.startswith(("truecolor", "24bit")):
144
+ return "truecolor"
145
+ term = os.environ.get("TERM", "").lower()
146
+ if "256color" in term or "xterm-256color" in term:
147
+ return "256color"
148
+ return "16color"
149
+
150
+
151
+ def detect_terminal_width() -> int:
152
+ width = shutil.get_terminal_size(fallback=(DEFAULT_WIDTH, 24)).columns
153
+ return max(MIN_WIDTH, min(width, MAX_WIDTH))
154
+
155
+
156
+ def build_theme(theme: str) -> ThemeTokens:
157
+ return _THEMES.get(theme, _DARK_THEME)
158
+
159
+
160
+ def truncate(text: str, width: int, ellipsis: str = "...") -> str:
161
+ vl = visible_length(text)
162
+ if vl <= width:
163
+ return text
164
+ available = width - visible_length(ellipsis)
165
+ if available <= 0:
166
+ return ellipsis[:width]
167
+ stripped = strip_ansi(text)
168
+ res: list[str] = []
169
+ curr_w = 0
170
+ for c in stripped:
171
+ w = 2 if unicodedata.east_asian_width(c) in ("W", "F") else 1
172
+ if curr_w + w > available:
173
+ break
174
+ res.append(c)
175
+ curr_w += w
176
+ return "".join(res) + ellipsis
177
+
178
+
179
+ def pad_right(text: str, width: int) -> str:
180
+ vl = visible_length(text)
181
+ if vl >= width:
182
+ return text
183
+ return text + " " * (width - vl)
184
+
185
+
186
+ def pad_left(text: str, width: int) -> str:
187
+ vl = visible_length(text)
188
+ if vl >= width:
189
+ return text
190
+ return " " * (width - vl) + text
191
+
192
+
193
+ _CSI_FINAL_BYTES = frozenset(chr(c) for c in range(0x40, 0x7F))
194
+
195
+
196
+ def strip_ansi(text: str) -> str:
197
+ result: list[str] = []
198
+ i = 0
199
+ while i < len(text):
200
+ if text[i] == "\033" and i + 1 < len(text) and text[i + 1] == "[":
201
+ j = i + 2
202
+ while j < len(text) and text[j] not in _CSI_FINAL_BYTES:
203
+ j += 1
204
+ i = j + 1
205
+ else:
206
+ result.append(text[i])
207
+ i += 1
208
+ return "".join(result)
209
+
210
+
211
+ def visible_length(text: str) -> int:
212
+ return sum(2 if unicodedata.east_asian_width(c) in ("W", "F") else 1 for c in strip_ansi(text))
213
+
214
+
215
+ class GitwiseHelpFormatter(argparse.RawDescriptionHelpFormatter):
216
+ def __init__(
217
+ self,
218
+ prog: str,
219
+ indent_increment: int = 2,
220
+ max_help_position: int = 24,
221
+ width: int | None = None,
222
+ ) -> None:
223
+ super().__init__(
224
+ prog,
225
+ indent_increment=indent_increment,
226
+ max_help_position=max_help_position,
227
+ width=width,
228
+ )
229
+
230
+ def _format_action(self, action: argparse.Action) -> str:
231
+ parts = super()._format_action(action)
232
+ tokens = _get_tokens_for_help()
233
+ if tokens is None:
234
+ return parts
235
+ return _colorize_help_line(parts, tokens)
236
+
237
+ def start_section(self, heading: str | None) -> None:
238
+ if heading:
239
+ tokens = _get_tokens_for_help()
240
+ if tokens:
241
+ heading = f"{ANSI_BOLD}{hex_to_ansi_fg(tokens.accent)}{heading}{ANSI_RESET}"
242
+ super().start_section(heading)
243
+
244
+
245
+ def _get_tokens_for_help() -> ThemeTokens | None:
246
+ try:
247
+ if os.environ.get("NO_COLOR", "") != "":
248
+ return None
249
+ if os.environ.get("GITWISE_NO_COLOR", "").lower() in ("1", "true"):
250
+ return None
251
+ import sys
252
+
253
+ if not sys.stdout.isatty():
254
+ return None
255
+ theme = os.environ.get("GITWISE_THEME", "auto")
256
+ if theme == "auto":
257
+ theme = "dark"
258
+ return build_theme(theme)
259
+ except (OSError, KeyError, ValueError):
260
+ return None
261
+
262
+
263
+ _OPT_RE = re.compile(r"^(\s*)(-{1,2}[\w.\-]+(?:,\s*-{1,2}[\w.\-]+)*)")
264
+
265
+
266
+ def _colorize_help_line(line: str, tokens: ThemeTokens) -> str:
267
+ m = _OPT_RE.match(line)
268
+ if not m:
269
+ return line
270
+ indent, opts = m.group(1), m.group(2)
271
+ rest = line[m.end() :]
272
+ return f"{indent}{hex_to_ansi_fg(tokens.accent)}{opts}{ANSI_RESET}{rest}"
273
+
274
+
275
+ class GitwiseRichHelpFormatter(argparse.RawDescriptionHelpFormatter):
276
+ """Rich-argparse bridge that preserves gitwise theme and no-color behavior."""
277
+
278
+ _FORMATTER: type[argparse.RawDescriptionHelpFormatter] | None = None
279
+ _DEFAULT_GROUP_NAME_FORMATTER: Callable[[str], object] | None = None
280
+ _NO_COLOR_ENV_KEY: tuple[str, str] | None = None
281
+ _NO_COLOR_ENABLED = False
282
+ _THEME_ENV_VALUE: str | None = None
283
+ _THEME_NAME = "dark"
284
+
285
+ def __new__(
286
+ cls,
287
+ prog: str,
288
+ indent_increment: int = 2,
289
+ max_help_position: int = 24,
290
+ width: int | None = None,
291
+ ) -> argparse.RawDescriptionHelpFormatter:
292
+ if cls._no_color_enabled():
293
+ return GitwiseHelpFormatter(
294
+ prog,
295
+ indent_increment=indent_increment,
296
+ max_help_position=max_help_position,
297
+ width=width,
298
+ )
299
+
300
+ formatter_cls = cls._formatter_cls()
301
+ help_formatter = formatter_cls(
302
+ prog,
303
+ indent_increment=indent_increment,
304
+ max_help_position=max_help_position,
305
+ width=width,
306
+ )
307
+ cls._apply_theme(help_formatter)
308
+ return help_formatter
309
+
310
+ @classmethod
311
+ def _formatter_cls(cls) -> type[argparse.RawDescriptionHelpFormatter]:
312
+ formatter = cls._FORMATTER
313
+ if formatter is not None:
314
+ return formatter
315
+
316
+ import importlib
317
+
318
+ try:
319
+ rich_argparse = importlib.import_module("rich_argparse")
320
+ except ModuleNotFoundError:
321
+ return GitwiseHelpFormatter
322
+ formatter = rich_argparse.RawDescriptionRichHelpFormatter
323
+ cls._FORMATTER = formatter
324
+ return formatter
325
+
326
+ @classmethod
327
+ def _no_color_enabled(cls) -> bool:
328
+ env_key = (os.environ.get("NO_COLOR", ""), os.environ.get("GITWISE_NO_COLOR", "").lower())
329
+ if env_key != cls._NO_COLOR_ENV_KEY:
330
+ cls._NO_COLOR_ENV_KEY = env_key
331
+ cls._NO_COLOR_ENABLED = env_key[0] != "" or env_key[1] in ("1", "true")
332
+ return cls._NO_COLOR_ENABLED
333
+
334
+ @classmethod
335
+ def _theme_name(cls) -> str:
336
+ env_theme = os.environ.get("GITWISE_THEME", "").lower()
337
+ if env_theme != cls._THEME_ENV_VALUE:
338
+ cls._THEME_ENV_VALUE = env_theme
339
+ cls._THEME_NAME = env_theme if env_theme in {"dark", "light"} else "dark"
340
+ return cls._THEME_NAME
341
+
342
+ @classmethod
343
+ def _install_group_name_localization(
344
+ cls, formatter_cls: type[argparse.RawDescriptionHelpFormatter]
345
+ ) -> None:
346
+ if cls._DEFAULT_GROUP_NAME_FORMATTER is not None:
347
+ return
348
+
349
+ formatter_obj = getattr(formatter_cls, "group_name_formatter", str.title)
350
+ if not callable(formatter_obj):
351
+ return
352
+
353
+ default_group_formatter: Callable[[str], object] = formatter_obj
354
+ cls._DEFAULT_GROUP_NAME_FORMATTER = default_group_formatter
355
+
356
+ def localized_group_formatter(name: object) -> str:
357
+ normalized = str(name).lower().replace(" ", "_")
358
+ if normalized in {"options", "positional_arguments"}:
359
+ return t(f"help_group_{normalized}")
360
+ return str(default_group_formatter(str(name)))
361
+
362
+ setattr(formatter_cls, "group_name_formatter", localized_group_formatter) # noqa: B010
363
+
364
+ @classmethod
365
+ def _apply_theme(cls, help_formatter: argparse.RawDescriptionHelpFormatter) -> None:
366
+ theme_tokens = build_theme(cls._theme_name())
367
+
368
+ styles = getattr(help_formatter, "styles", None)
369
+ if isinstance(styles, dict):
370
+ styles.update(
371
+ {
372
+ "argparse.args": theme_tokens.accent,
373
+ "argparse.groups": theme_tokens.brand,
374
+ "argparse.metavar": theme_tokens.secondary,
375
+ "argparse.prog": theme_tokens.dim,
376
+ "argparse.help": theme_tokens.fg,
377
+ "argparse.text": theme_tokens.fg,
378
+ "argparse.syntax": f"bold {theme_tokens.accent}",
379
+ "argparse.default": f"italic {theme_tokens.secondary}",
380
+ }
381
+ )
382
+
383
+ cls._install_group_name_localization(type(help_formatter))