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