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/health.py ADDED
@@ -0,0 +1,345 @@
1
+ """gitwise health — repo health score (0-100) with grade and breakdown."""
2
+
3
+ from pathlib import Path
4
+ from typing import TypedDict
5
+
6
+ from .git import (
7
+ gpg_status,
8
+ has_commit_graph,
9
+ has_remote,
10
+ has_upstream,
11
+ require_root,
12
+ )
13
+ from .git import run as git_run
14
+ from .i18n import t
15
+ from .output import print_header, print_json, print_status_line
16
+ from .utils.json_envelope import ok_envelope
17
+ from .utils.parsing import non_empty_lines, to_int
18
+
19
+
20
+ class HealthDetails(TypedDict):
21
+ has_remote: bool
22
+ has_upstream: bool
23
+ gpg_ready: bool
24
+ stale_branches: int
25
+ stashes: int
26
+ untracked: int
27
+ commits: int
28
+ branches: int
29
+ commit_graph: bool
30
+
31
+
32
+ class HealthResult(TypedDict):
33
+ score: int
34
+ grade: str
35
+ breakdown: dict[str, int]
36
+ details: HealthDetails
37
+
38
+
39
+ def _untracked_count(cwd: Path) -> int:
40
+ r = git_run(["ls-files", "--others", "--exclude-standard"], cwd=cwd, check=False)
41
+ return len(non_empty_lines(r.stdout)) if r.returncode == 0 else 0
42
+
43
+
44
+ def _stash_count(cwd: Path) -> int:
45
+ r = git_run(["stash", "list"], cwd=cwd, check=False)
46
+ return len(non_empty_lines(r.stdout)) if r.returncode == 0 else 0
47
+
48
+
49
+ def _commit_count(cwd: Path) -> int:
50
+ r = git_run(["rev-list", "--count", "HEAD"], cwd=cwd, check=False)
51
+ return to_int(r.stdout, default=0) if r.returncode == 0 else 0
52
+
53
+
54
+ def _branch_info(cwd: Path) -> tuple[int, list[str]]:
55
+ r = git_run(
56
+ ["for-each-ref", "--format=%(refname:short) %(upstream:track)", "refs/heads/"],
57
+ cwd=cwd,
58
+ check=False,
59
+ )
60
+ if r.returncode != 0:
61
+ return 0, []
62
+ branches: list[str] = []
63
+ stale: list[str] = []
64
+ for line in r.stdout.splitlines():
65
+ parts = line.strip().split(" ", 1)
66
+ name = parts[0] if parts else ""
67
+ if name:
68
+ branches.append(name)
69
+ if len(parts) >= 2 and "[gone]" in parts[1]:
70
+ stale.append(name)
71
+ return len(branches), stale
72
+
73
+
74
+ def _breakdown_labels() -> dict[str, str]:
75
+ return {
76
+ "remote": t("health_remote"),
77
+ "upstream": t("health_upstream"),
78
+ "gpg_signing": t("health_gpg_signing"),
79
+ "stale_branches": t("health_stale_branches"),
80
+ "commit_graph": t("health_commit_graph"),
81
+ "old_stashes": t("health_old_stashes"),
82
+ "untracked_clutter": t("health_untracked_clutter"),
83
+ "no_commits": t("health_no_commits"),
84
+ "too_many_branches": t("health_too_many_branches"),
85
+ }
86
+
87
+
88
+ _GRADE_MAP = [(90, "A"), (75, "B"), (60, "C"), (40, "D"), (0, "F")]
89
+
90
+
91
+ def _grade(score: int) -> str:
92
+ for threshold, letter in _GRADE_MAP:
93
+ if score >= threshold:
94
+ return letter
95
+ return "F"
96
+
97
+
98
+ def _apply_remote_penalties(
99
+ *, score: int, breakdown: dict[str, int], root: Path
100
+ ) -> tuple[int, bool, bool]:
101
+ has_rem = has_remote(root)
102
+ has_up = has_upstream(root) if has_rem else False
103
+ if not has_rem:
104
+ score -= 20
105
+ breakdown["remote"] = -20
106
+ if has_rem and not has_up:
107
+ score -= 10
108
+ breakdown["upstream"] = -10
109
+ return score, has_rem, has_up
110
+
111
+
112
+ def _apply_gpg_penalty(*, score: int, breakdown: dict[str, int], root: Path) -> tuple[int, dict]:
113
+ gpg = gpg_status(root)
114
+ if not gpg["gpgsign_enabled"]:
115
+ score -= 10
116
+ breakdown["gpg_signing"] = -10
117
+ return score, gpg
118
+
119
+
120
+ def _apply_branch_penalties(
121
+ *,
122
+ score: int,
123
+ breakdown: dict[str, int],
124
+ root: Path,
125
+ stale_override: list[str] | None,
126
+ ) -> tuple[int, int, list[str]]:
127
+ branch_count, stale = _branch_info(root) if stale_override is None else (0, stale_override)
128
+ if stale:
129
+ penalty = min(len(stale) * 5, 15)
130
+ score -= penalty
131
+ breakdown["stale_branches"] = -penalty
132
+ if branch_count > 15:
133
+ penalty = min((branch_count - 15) * 2, 10)
134
+ score -= penalty
135
+ breakdown["too_many_branches"] = -penalty
136
+ return score, branch_count, stale
137
+
138
+
139
+ def _apply_commit_graph_penalty(
140
+ *,
141
+ score: int,
142
+ breakdown: dict[str, int],
143
+ root: Path,
144
+ commit_graph_override: bool | None,
145
+ ) -> tuple[int, bool]:
146
+ has_cg = commit_graph_override if commit_graph_override is not None else has_commit_graph(root)
147
+ if not has_cg:
148
+ score -= 5
149
+ breakdown["commit_graph"] = -5
150
+ return score, has_cg
151
+
152
+
153
+ def _apply_repo_size_penalties(
154
+ *, score: int, breakdown: dict[str, int], root: Path
155
+ ) -> tuple[int, int, int, int]:
156
+ stashes = _stash_count(root)
157
+ if stashes > 3:
158
+ penalty = min((stashes - 3) * 2, 10)
159
+ score -= penalty
160
+ breakdown["old_stashes"] = -penalty
161
+
162
+ untracked = _untracked_count(root)
163
+ if untracked > 20:
164
+ penalty = min((untracked - 20), 10)
165
+ score -= penalty
166
+ breakdown["untracked_clutter"] = -penalty
167
+
168
+ commits = _commit_count(root)
169
+ if commits == 0:
170
+ score -= 20
171
+ breakdown["no_commits"] = -20
172
+
173
+ return score, stashes, untracked, commits
174
+
175
+
176
+ def _remote_state(
177
+ *,
178
+ root: Path,
179
+ score: int,
180
+ breakdown: dict[str, int],
181
+ has_remote_override: bool | None,
182
+ has_upstream_override: bool | None,
183
+ ) -> tuple[int, bool, bool]:
184
+ if has_remote_override is not None or has_upstream_override is not None:
185
+ has_rem = has_remote_override if has_remote_override is not None else has_remote(root)
186
+ has_up = (
187
+ has_upstream_override
188
+ if has_upstream_override is not None
189
+ else (has_upstream(root) if has_rem else False)
190
+ )
191
+ if not has_rem:
192
+ score -= 20
193
+ breakdown["remote"] = -20
194
+ if has_rem and not has_up:
195
+ score -= 10
196
+ breakdown["upstream"] = -10
197
+ return score, has_rem, has_up
198
+ return _apply_remote_penalties(score=score, breakdown=breakdown, root=root)
199
+
200
+
201
+ def _gpg_state(
202
+ *,
203
+ root: Path,
204
+ score: int,
205
+ breakdown: dict[str, int],
206
+ gpg_override: dict | None,
207
+ ) -> tuple[int, dict]:
208
+ if gpg_override is not None:
209
+ gpg = gpg_override
210
+ if not gpg["gpgsign_enabled"]:
211
+ score -= 10
212
+ breakdown["gpg_signing"] = -10
213
+ return score, gpg
214
+ return _apply_gpg_penalty(score=score, breakdown=breakdown, root=root)
215
+
216
+
217
+ def _build_health_result(
218
+ *,
219
+ score: int,
220
+ breakdown: dict[str, int],
221
+ has_rem: bool,
222
+ has_up: bool,
223
+ gpg: dict,
224
+ stale: list[str],
225
+ stashes: int,
226
+ untracked: int,
227
+ commits: int,
228
+ branches: int,
229
+ has_cg: bool,
230
+ ) -> HealthResult:
231
+ bounded_score = max(0, min(100, score))
232
+ return {
233
+ "score": bounded_score,
234
+ "grade": _grade(bounded_score),
235
+ "breakdown": breakdown,
236
+ "details": {
237
+ "has_remote": has_rem,
238
+ "has_upstream": has_up,
239
+ "gpg_ready": gpg["ready"],
240
+ "stale_branches": len(stale),
241
+ "stashes": stashes,
242
+ "untracked": untracked,
243
+ "commits": commits,
244
+ "branches": branches,
245
+ "commit_graph": has_cg,
246
+ },
247
+ }
248
+
249
+
250
+ def _remaining_health_state(
251
+ *,
252
+ root: Path,
253
+ score: int,
254
+ breakdown: dict[str, int],
255
+ stale_override: list[str] | None,
256
+ commit_graph_override: bool | None,
257
+ ) -> tuple[int, int, list[str], bool, int, int, int]:
258
+ score, branches, stale = _apply_branch_penalties(
259
+ score=score,
260
+ breakdown=breakdown,
261
+ root=root,
262
+ stale_override=stale_override,
263
+ )
264
+ score, has_cg = _apply_commit_graph_penalty(
265
+ score=score,
266
+ breakdown=breakdown,
267
+ root=root,
268
+ commit_graph_override=commit_graph_override,
269
+ )
270
+ score, stashes, untracked, commits = _apply_repo_size_penalties(
271
+ score=score,
272
+ breakdown=breakdown,
273
+ root=root,
274
+ )
275
+ return score, branches, stale, has_cg, stashes, untracked, commits
276
+
277
+
278
+ def compute_health(
279
+ root: Path,
280
+ *,
281
+ _gpg_override: dict | None = None,
282
+ _has_commit_graph: bool | None = None,
283
+ _has_remote: bool | None = None,
284
+ _has_upstream: bool | None = None,
285
+ _stale_branches: list[str] | None = None,
286
+ ) -> HealthResult:
287
+ score = 100
288
+ breakdown: dict[str, int] = {}
289
+ score, has_rem, has_up = _remote_state(
290
+ root=root,
291
+ score=score,
292
+ breakdown=breakdown,
293
+ has_remote_override=_has_remote,
294
+ has_upstream_override=_has_upstream,
295
+ )
296
+ score, gpg = _gpg_state(
297
+ root=root,
298
+ score=score,
299
+ breakdown=breakdown,
300
+ gpg_override=_gpg_override,
301
+ )
302
+
303
+ score, branches, stale, has_cg, stashes, untracked, commits = _remaining_health_state(
304
+ root=root,
305
+ score=score,
306
+ breakdown=breakdown,
307
+ stale_override=_stale_branches,
308
+ commit_graph_override=_has_commit_graph,
309
+ )
310
+
311
+ return _build_health_result(
312
+ score=score,
313
+ breakdown=breakdown,
314
+ has_rem=has_rem,
315
+ has_up=has_up,
316
+ gpg=gpg,
317
+ stale=stale,
318
+ stashes=stashes,
319
+ untracked=untracked,
320
+ commits=commits,
321
+ branches=branches,
322
+ has_cg=has_cg,
323
+ )
324
+
325
+
326
+ def run_health(*, as_json: bool = False) -> int:
327
+ root, err = require_root()
328
+ if err:
329
+ return err
330
+ if root is None:
331
+ return 1
332
+
333
+ h = compute_health(root)
334
+
335
+ if as_json:
336
+ print_json(ok_envelope(payload=h))
337
+ else:
338
+ print_header(t("health_label", score=str(h["score"]), grade=h["grade"]))
339
+ if h["breakdown"]:
340
+ for key, delta in h["breakdown"].items():
341
+ label = _breakdown_labels().get(key) or key
342
+ is_ok = delta == 0
343
+ print_status_line("✓" if is_ok else "✗", label, str(delta), ok_flag=is_ok)
344
+
345
+ return 0
gitwise/i18n.py ADDED
@@ -0,0 +1,99 @@
1
+ """Zero-dependency i18n: es/en string catalog with auto locale detection."""
2
+
3
+ import json
4
+ import os
5
+ from pathlib import Path
6
+ from typing import Literal
7
+
8
+ Locale = Literal["es", "en"]
9
+ OutputMode = Literal["human", "agent"]
10
+
11
+ _CACHE: dict[str, str] = {}
12
+
13
+
14
+ def _load_strings() -> dict[str, dict[str, str]]:
15
+ data_path = Path(__file__).with_name("_i18n_data.json")
16
+ with open(data_path, encoding="utf-8") as f:
17
+ return json.load(f)
18
+
19
+
20
+ _STRINGS: dict[str, dict[str, str]] = _load_strings()
21
+
22
+
23
+ def _detect_locale() -> Locale:
24
+ lang = os.environ.get("GITWISE_LANG", "").lower()[:2]
25
+ if lang in ("es", "en"):
26
+ return lang
27
+ for var in ("LC_MESSAGES", "LC_ALL", "LANG"):
28
+ val = os.environ.get(var, "").lower()
29
+ if val.startswith("es"):
30
+ return "es"
31
+ if val.startswith("en"):
32
+ return "en"
33
+ return "en"
34
+
35
+
36
+ def _detect_output_mode() -> OutputMode:
37
+ mode = os.environ.get("GITWISE_OUTPUT", "").lower()
38
+ if mode in ("human", "agent"):
39
+ return mode
40
+ if os.environ.get("GITWISE_AGENT", "").lower() in ("1", "true"):
41
+ return "agent"
42
+ if not os.environ.get("TERM", ""):
43
+ return "agent"
44
+ return "human"
45
+
46
+
47
+ class _I18nState:
48
+ __slots__ = ("locale", "mode")
49
+
50
+ def __init__(self) -> None:
51
+ self.locale: Locale = _detect_locale()
52
+ self.mode: OutputMode = _detect_output_mode()
53
+
54
+
55
+ _state = _I18nState()
56
+
57
+
58
+ def get_locale() -> Locale:
59
+ return _state.locale
60
+
61
+
62
+ def get_mode() -> OutputMode:
63
+ return _state.mode
64
+
65
+
66
+ def set_locale(locale: Locale) -> None:
67
+ _state.locale = locale
68
+ _CACHE.clear()
69
+
70
+
71
+ def set_mode(mode: OutputMode) -> None:
72
+ _state.mode = mode
73
+ _CACHE.clear()
74
+
75
+
76
+ def t(key: str, **kwargs: str) -> str:
77
+ cached_key = f"{_state.locale}:{key}:{json.dumps(kwargs, sort_keys=True, default=str)}"
78
+ if cached_key in _CACHE and "GITWISE_DEBUG" not in os.environ:
79
+ return _CACHE[cached_key]
80
+ entry = _STRINGS.get(key)
81
+ if entry is None:
82
+ return key
83
+ template = entry.get(_state.locale, entry.get("en", key))
84
+ try:
85
+ result = template.format(**kwargs) if kwargs else template
86
+ except (KeyError, IndexError, ValueError):
87
+ result = template
88
+ _CACHE[cached_key] = result
89
+ return result
90
+
91
+
92
+ def confirm_responses() -> set[str]:
93
+ if _state.locale == "es":
94
+ return {"s", "si", "sí", "y", "yes"}
95
+ return {"y", "yes", "s", "si"}
96
+
97
+
98
+ def reset_cache() -> None:
99
+ _CACHE.clear()