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/tag.py
ADDED
|
@@ -0,0 +1,252 @@
|
|
|
1
|
+
"""gitwise tag — semver-aware tag management (list/create/delete)."""
|
|
2
|
+
|
|
3
|
+
import re
|
|
4
|
+
from pathlib import Path
|
|
5
|
+
|
|
6
|
+
from .git import require_root, validate_ref
|
|
7
|
+
from .git import run as git_run
|
|
8
|
+
from .i18n import t
|
|
9
|
+
from .output import (
|
|
10
|
+
confirm,
|
|
11
|
+
error,
|
|
12
|
+
ok,
|
|
13
|
+
print_bracket,
|
|
14
|
+
print_header,
|
|
15
|
+
print_json,
|
|
16
|
+
print_table,
|
|
17
|
+
warn,
|
|
18
|
+
)
|
|
19
|
+
from .utils.json_envelope import error_envelope, ok_envelope
|
|
20
|
+
|
|
21
|
+
_SEMVER_RE = re.compile(r"^v?(\d+)\.(\d+)\.(\d+)(-[a-zA-Z0-9.]+)?(\+[a-zA-Z0-9.]+)?$")
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def _list_tags(root: Path) -> list[dict[str, str]]:
|
|
25
|
+
r = git_run(
|
|
26
|
+
[
|
|
27
|
+
"for-each-ref",
|
|
28
|
+
"--format=%(refname:short)\t%(objectname:short)\t%(creatordate:iso)",
|
|
29
|
+
"refs/tags/",
|
|
30
|
+
],
|
|
31
|
+
cwd=root,
|
|
32
|
+
check=False,
|
|
33
|
+
)
|
|
34
|
+
if r.returncode != 0 or not r.stdout.strip():
|
|
35
|
+
return []
|
|
36
|
+
tags: list[dict[str, str]] = []
|
|
37
|
+
for line in r.stdout.splitlines():
|
|
38
|
+
parts = line.split("\t", 2)
|
|
39
|
+
if len(parts) < 3:
|
|
40
|
+
continue
|
|
41
|
+
tags.append({"name": parts[0], "sha": parts[1], "date": parts[2]})
|
|
42
|
+
return tags
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def _latest_semver(root: Path) -> dict[str, str] | None:
|
|
46
|
+
tags = _list_tags(root)
|
|
47
|
+
semver_tags = [tg for tg in tags if _SEMVER_RE.match(tg["name"])]
|
|
48
|
+
if not semver_tags:
|
|
49
|
+
return None
|
|
50
|
+
|
|
51
|
+
def _sort_key(tg: dict[str, str]) -> tuple[int, ...]:
|
|
52
|
+
m = _SEMVER_RE.match(tg["name"])
|
|
53
|
+
if not m:
|
|
54
|
+
return (0, 0, 0)
|
|
55
|
+
return (int(m.group(1)), int(m.group(2)), int(m.group(3)))
|
|
56
|
+
|
|
57
|
+
semver_tags.sort(key=_sort_key, reverse=True)
|
|
58
|
+
return semver_tags[0]
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def _bump_version(version: str, part: str) -> str:
|
|
62
|
+
m = _SEMVER_RE.match(version)
|
|
63
|
+
if not m:
|
|
64
|
+
return version
|
|
65
|
+
major, minor, patch = int(m.group(1)), int(m.group(2)), int(m.group(3))
|
|
66
|
+
pre = m.group(4) or ""
|
|
67
|
+
build = m.group(5) or ""
|
|
68
|
+
prefix = "v" if version.startswith("v") else ""
|
|
69
|
+
if part == "major":
|
|
70
|
+
major += 1
|
|
71
|
+
minor = patch = 0
|
|
72
|
+
elif part == "minor":
|
|
73
|
+
minor += 1
|
|
74
|
+
patch = 0
|
|
75
|
+
else:
|
|
76
|
+
patch += 1
|
|
77
|
+
return f"{prefix}{major}.{minor}.{patch}{pre}{build}"
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
def _print_tag_list(tags: list[dict[str, str]]) -> None:
|
|
81
|
+
if not tags:
|
|
82
|
+
ok(t("tag_empty"))
|
|
83
|
+
return
|
|
84
|
+
columns = [
|
|
85
|
+
(t("col_tag"), "name"),
|
|
86
|
+
(t("col_sha"), "sha"),
|
|
87
|
+
(t("col_date"), "date"),
|
|
88
|
+
]
|
|
89
|
+
rows: list[list[str]] = []
|
|
90
|
+
highlight_rows: set[int] = set()
|
|
91
|
+
for i, tag_item in enumerate(tags):
|
|
92
|
+
rows.append([tag_item["name"], tag_item["sha"], tag_item["date"]])
|
|
93
|
+
if _SEMVER_RE.match(tag_item["name"]):
|
|
94
|
+
highlight_rows.add(i)
|
|
95
|
+
print_table(
|
|
96
|
+
title=t("tag_list_title"),
|
|
97
|
+
columns=columns,
|
|
98
|
+
rows=rows,
|
|
99
|
+
highlight_rows=highlight_rows,
|
|
100
|
+
)
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
def _resolve_tag_name(
|
|
104
|
+
*,
|
|
105
|
+
root: Path,
|
|
106
|
+
bump: str | None,
|
|
107
|
+
name: str | None,
|
|
108
|
+
) -> str | None:
|
|
109
|
+
if bump:
|
|
110
|
+
latest = _latest_semver(root)
|
|
111
|
+
base = latest["name"] if latest else "0.0.0"
|
|
112
|
+
return _bump_version(base, bump)
|
|
113
|
+
return name
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
def _run_tag_list(*, root: Path, as_json: bool) -> int:
|
|
117
|
+
tags = _list_tags(root)
|
|
118
|
+
if as_json:
|
|
119
|
+
print_json(ok_envelope(tags=tags, count=len(tags)))
|
|
120
|
+
return 0
|
|
121
|
+
_print_tag_list(tags)
|
|
122
|
+
return 0
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
def _run_tag_latest(*, root: Path, as_json: bool) -> int:
|
|
126
|
+
latest = _latest_semver(root)
|
|
127
|
+
if as_json:
|
|
128
|
+
print_json(ok_envelope(latest=latest))
|
|
129
|
+
return 0
|
|
130
|
+
if latest:
|
|
131
|
+
print_header(t("tag_latest_title"))
|
|
132
|
+
print_bracket(latest["name"], latest["sha"])
|
|
133
|
+
else:
|
|
134
|
+
warn(t("tag_no_semver"))
|
|
135
|
+
return 0
|
|
136
|
+
|
|
137
|
+
|
|
138
|
+
def _run_tag_create(
|
|
139
|
+
*,
|
|
140
|
+
root: Path,
|
|
141
|
+
bump: str | None,
|
|
142
|
+
name: str | None,
|
|
143
|
+
message: str | None,
|
|
144
|
+
dry_run: bool,
|
|
145
|
+
as_json: bool,
|
|
146
|
+
) -> int:
|
|
147
|
+
tag_name = _resolve_tag_name(root=root, bump=bump, name=name)
|
|
148
|
+
if not tag_name:
|
|
149
|
+
error(t("tag_name_required"))
|
|
150
|
+
return 1
|
|
151
|
+
if not validate_ref(tag_name):
|
|
152
|
+
error(t("invalid_ref", ref=tag_name))
|
|
153
|
+
return 1
|
|
154
|
+
|
|
155
|
+
args = ["tag", tag_name]
|
|
156
|
+
if message:
|
|
157
|
+
args = ["tag", "-a", tag_name, "-m", message]
|
|
158
|
+
|
|
159
|
+
if dry_run:
|
|
160
|
+
ok(t("tag_create_dry", name=tag_name))
|
|
161
|
+
return 0
|
|
162
|
+
|
|
163
|
+
result = git_run(args, cwd=root, check=False)
|
|
164
|
+
if result.returncode != 0:
|
|
165
|
+
err = t("git_command_failed", cmd="tag", error=result.stderr.strip())
|
|
166
|
+
if as_json:
|
|
167
|
+
print_json(error_envelope(error=err))
|
|
168
|
+
else:
|
|
169
|
+
error(err)
|
|
170
|
+
return 1
|
|
171
|
+
|
|
172
|
+
if as_json:
|
|
173
|
+
print_json(ok_envelope(created=tag_name))
|
|
174
|
+
return 0
|
|
175
|
+
ok(t("tag_created", name=tag_name))
|
|
176
|
+
return 0
|
|
177
|
+
|
|
178
|
+
|
|
179
|
+
def _run_tag_delete(
|
|
180
|
+
*,
|
|
181
|
+
root: Path,
|
|
182
|
+
name: str | None,
|
|
183
|
+
yes: bool,
|
|
184
|
+
dry_run: bool,
|
|
185
|
+
as_json: bool,
|
|
186
|
+
) -> int:
|
|
187
|
+
if not name:
|
|
188
|
+
error(t("tag_name_required"))
|
|
189
|
+
return 1
|
|
190
|
+
if not validate_ref(name):
|
|
191
|
+
error(t("invalid_ref", ref=name))
|
|
192
|
+
return 1
|
|
193
|
+
if dry_run:
|
|
194
|
+
ok(t("tag_delete_dry", name=name))
|
|
195
|
+
return 0
|
|
196
|
+
if not yes and not confirm(t("confirm_tag_delete", name=name)):
|
|
197
|
+
warn(t("aborted"))
|
|
198
|
+
return 1
|
|
199
|
+
|
|
200
|
+
result = git_run(["tag", "-d", name], cwd=root, check=False)
|
|
201
|
+
if result.returncode != 0:
|
|
202
|
+
err = t("git_command_failed", cmd="tag -d", error=result.stderr.strip())
|
|
203
|
+
if as_json:
|
|
204
|
+
print_json(error_envelope(error=err))
|
|
205
|
+
else:
|
|
206
|
+
error(err)
|
|
207
|
+
return 1
|
|
208
|
+
|
|
209
|
+
if as_json:
|
|
210
|
+
print_json(ok_envelope(deleted=name))
|
|
211
|
+
return 0
|
|
212
|
+
ok(t("tag_deleted", name=name))
|
|
213
|
+
return 0
|
|
214
|
+
|
|
215
|
+
|
|
216
|
+
def run_tag(
|
|
217
|
+
action: str = "list",
|
|
218
|
+
name: str | None = None,
|
|
219
|
+
*,
|
|
220
|
+
bump: str | None = None,
|
|
221
|
+
message: str | None = None,
|
|
222
|
+
dry_run: bool = False,
|
|
223
|
+
yes: bool = False,
|
|
224
|
+
as_json: bool = False,
|
|
225
|
+
) -> int:
|
|
226
|
+
root, err = require_root()
|
|
227
|
+
if err:
|
|
228
|
+
return err
|
|
229
|
+
if root is None:
|
|
230
|
+
return 1
|
|
231
|
+
|
|
232
|
+
if action == "list":
|
|
233
|
+
return _run_tag_list(root=root, as_json=as_json)
|
|
234
|
+
|
|
235
|
+
if action == "latest":
|
|
236
|
+
return _run_tag_latest(root=root, as_json=as_json)
|
|
237
|
+
|
|
238
|
+
if action == "create":
|
|
239
|
+
return _run_tag_create(
|
|
240
|
+
root=root,
|
|
241
|
+
bump=bump,
|
|
242
|
+
name=name,
|
|
243
|
+
message=message,
|
|
244
|
+
dry_run=dry_run,
|
|
245
|
+
as_json=as_json,
|
|
246
|
+
)
|
|
247
|
+
|
|
248
|
+
if action == "delete":
|
|
249
|
+
return _run_tag_delete(root=root, name=name, yes=yes, dry_run=dry_run, as_json=as_json)
|
|
250
|
+
|
|
251
|
+
error(t("tag_unknown_action", action=action))
|
|
252
|
+
return 1
|
gitwise/undo.py
ADDED
|
@@ -0,0 +1,145 @@
|
|
|
1
|
+
"""gitwise undo — reflog-based undo to any previous HEAD state."""
|
|
2
|
+
|
|
3
|
+
from .git import require_root, validate_ref
|
|
4
|
+
from .git import run as git_run
|
|
5
|
+
from .i18n import t
|
|
6
|
+
from .output import confirm, error, print_bracket, print_dim, print_header, print_json
|
|
7
|
+
from .utils.json_envelope import ok_envelope
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
def _resolve_undo_target(
|
|
11
|
+
*, ref: str | None, entries: list[dict[str, str]], steps: int
|
|
12
|
+
) -> str | None:
|
|
13
|
+
if ref:
|
|
14
|
+
if not validate_ref(ref):
|
|
15
|
+
error(t("invalid_ref", ref=ref))
|
|
16
|
+
return None
|
|
17
|
+
return ref
|
|
18
|
+
if len(entries) >= steps + 1:
|
|
19
|
+
return entries[steps]["hash"]
|
|
20
|
+
error(t("undo_not_enough_history"))
|
|
21
|
+
return None
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def _print_undo_dry_run(*, target: str, soft: bool) -> None:
|
|
25
|
+
print_header(t("undo_dry_run_title"))
|
|
26
|
+
mode = "--soft" if soft else "--hard"
|
|
27
|
+
print_bracket(f"git reset {mode}", target[:12])
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def _reset_to_target(*, root, target: str, soft: bool) -> int:
|
|
31
|
+
args = ["reset", "--soft" if soft else "--hard", target]
|
|
32
|
+
result = git_run(args, cwd=root, check=False)
|
|
33
|
+
if result.returncode != 0:
|
|
34
|
+
error(result.stderr.strip())
|
|
35
|
+
return 1
|
|
36
|
+
return 0
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def _load_reflog_entries(*, root, steps: int) -> list[dict[str, str]] | None:
|
|
40
|
+
result = git_run(
|
|
41
|
+
["reflog", "--format=%H|gd-ref:%gd|gs:%gs|msg:%s", f"--max-count={steps + 10}"],
|
|
42
|
+
cwd=root,
|
|
43
|
+
check=False,
|
|
44
|
+
)
|
|
45
|
+
if result.returncode != 0:
|
|
46
|
+
error(t("undo_reflog_failed"))
|
|
47
|
+
return None
|
|
48
|
+
entries = _parse_reflog(result.stdout)
|
|
49
|
+
if not entries:
|
|
50
|
+
error(t("undo_no_entries"))
|
|
51
|
+
return None
|
|
52
|
+
return entries
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def _run_undo_dry_run(
|
|
56
|
+
*, as_json: bool, target: str, soft: bool, entries: list[dict[str, str]], steps: int
|
|
57
|
+
) -> int:
|
|
58
|
+
if as_json:
|
|
59
|
+
print_json(
|
|
60
|
+
ok_envelope(
|
|
61
|
+
target=target,
|
|
62
|
+
soft=soft,
|
|
63
|
+
dry_run=True,
|
|
64
|
+
entries=entries[: steps + 1],
|
|
65
|
+
)
|
|
66
|
+
)
|
|
67
|
+
else:
|
|
68
|
+
_print_undo_dry_run(target=target, soft=soft)
|
|
69
|
+
return 0
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def _confirm_hard_reset(*, soft: bool, yes: bool, target: str) -> bool:
|
|
73
|
+
if soft or yes:
|
|
74
|
+
return True
|
|
75
|
+
if confirm(t("undo_confirm_hard", ref=target[:12])):
|
|
76
|
+
return True
|
|
77
|
+
print_dim(t("cancelled"))
|
|
78
|
+
return False
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
def _report_undo_complete(*, as_json: bool, target: str, soft: bool) -> int:
|
|
82
|
+
if as_json:
|
|
83
|
+
print_json(ok_envelope(target=target, soft=soft))
|
|
84
|
+
return 0
|
|
85
|
+
print_header(t("undo_complete_title"))
|
|
86
|
+
print_bracket(t("undo_reset_to"), target[:12])
|
|
87
|
+
return 0
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
def _parse_reflog(raw: str) -> list[dict[str, str]]:
|
|
91
|
+
entries: list[dict[str, str]] = []
|
|
92
|
+
for line in raw.splitlines():
|
|
93
|
+
parts = line.split("|", 3)
|
|
94
|
+
if len(parts) >= 4:
|
|
95
|
+
entries.append(
|
|
96
|
+
{
|
|
97
|
+
"hash": parts[0].strip(),
|
|
98
|
+
"ref": parts[1].strip(),
|
|
99
|
+
"action": parts[2].strip(),
|
|
100
|
+
"message": parts[3].strip(),
|
|
101
|
+
}
|
|
102
|
+
)
|
|
103
|
+
return entries
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
def run_undo(
|
|
107
|
+
*,
|
|
108
|
+
ref: str | None = None,
|
|
109
|
+
soft: bool = False,
|
|
110
|
+
steps: int = 1,
|
|
111
|
+
dry_run: bool = False,
|
|
112
|
+
yes: bool = False,
|
|
113
|
+
as_json: bool = False,
|
|
114
|
+
) -> int:
|
|
115
|
+
root, err = require_root()
|
|
116
|
+
if err:
|
|
117
|
+
return err
|
|
118
|
+
if root is None:
|
|
119
|
+
return 1
|
|
120
|
+
|
|
121
|
+
entries = _load_reflog_entries(root=root, steps=steps)
|
|
122
|
+
if entries is None:
|
|
123
|
+
return 1
|
|
124
|
+
|
|
125
|
+
target = _resolve_undo_target(ref=ref, entries=entries, steps=steps)
|
|
126
|
+
if target is None:
|
|
127
|
+
return 1
|
|
128
|
+
|
|
129
|
+
if dry_run:
|
|
130
|
+
return _run_undo_dry_run(
|
|
131
|
+
as_json=as_json,
|
|
132
|
+
target=target,
|
|
133
|
+
soft=soft,
|
|
134
|
+
entries=entries,
|
|
135
|
+
steps=steps,
|
|
136
|
+
)
|
|
137
|
+
|
|
138
|
+
if not _confirm_hard_reset(soft=soft, yes=yes, target=target):
|
|
139
|
+
return 0
|
|
140
|
+
|
|
141
|
+
reset_rc = _reset_to_target(root=root, target=target, soft=soft)
|
|
142
|
+
if reset_rc != 0:
|
|
143
|
+
return reset_rc
|
|
144
|
+
|
|
145
|
+
return _report_undo_complete(as_json=as_json, target=target, soft=soft)
|
gitwise/update.py
ADDED
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
"""Update gitwise via git pull."""
|
|
2
|
+
|
|
3
|
+
from pathlib import Path
|
|
4
|
+
|
|
5
|
+
from .git import run as git_run
|
|
6
|
+
from .i18n import t
|
|
7
|
+
from .output import error, info, print_dim, print_header, print_json
|
|
8
|
+
from .utils.json_envelope import error_envelope, ok_envelope
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
def run_update(*, dry_run: bool = False, as_json: bool = False) -> int:
|
|
12
|
+
install_dir = Path(__file__).parent.parent
|
|
13
|
+
if not (install_dir / ".git").is_dir():
|
|
14
|
+
if as_json:
|
|
15
|
+
print_json(error_envelope(error=t("update_requires_git_clone")))
|
|
16
|
+
else:
|
|
17
|
+
error(t("update_requires_git_clone"))
|
|
18
|
+
return 1
|
|
19
|
+
if dry_run:
|
|
20
|
+
if as_json:
|
|
21
|
+
print_json(ok_envelope(dry_run=True, dir=str(install_dir)))
|
|
22
|
+
return 0
|
|
23
|
+
print_dim(t("update_dry_run", dir=str(install_dir)))
|
|
24
|
+
return 0
|
|
25
|
+
print_header(t("updating_from", dir=str(install_dir)))
|
|
26
|
+
r = git_run(["pull", "--ff-only"], cwd=install_dir, check=False)
|
|
27
|
+
if r.returncode == 0 and r.stdout.strip() and r.stdout.strip() != "Already up to date.":
|
|
28
|
+
if as_json:
|
|
29
|
+
print_json(ok_envelope(updated=True, output=r.stdout.strip()))
|
|
30
|
+
return 0
|
|
31
|
+
for line in r.stdout.strip().splitlines():
|
|
32
|
+
info(line)
|
|
33
|
+
elif r.returncode != 0:
|
|
34
|
+
if as_json:
|
|
35
|
+
print_json(error_envelope(error=r.stderr.strip() or t("error_updating")))
|
|
36
|
+
return 1
|
|
37
|
+
error(r.stderr.strip() or t("error_updating"))
|
|
38
|
+
return r.returncode
|
|
39
|
+
elif as_json:
|
|
40
|
+
print_json(ok_envelope(updated=False, output=t("already_up_to_date")))
|
|
41
|
+
return 0
|
|
42
|
+
return r.returncode
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"""Shared utility modules for gitwise."""
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
"""Reusable parsers for git command output formats."""
|
|
2
|
+
|
|
3
|
+
|
|
4
|
+
def parse_diffstat_entries(raw: str, *, default_status: str | None = None) -> list[dict[str, str]]:
|
|
5
|
+
entries: list[dict[str, str]] = []
|
|
6
|
+
for line in raw.splitlines():
|
|
7
|
+
if "|" not in line:
|
|
8
|
+
continue
|
|
9
|
+
path_raw, changes_raw = line.split("|", 1)
|
|
10
|
+
path = path_raw.strip()
|
|
11
|
+
changes = changes_raw.strip()
|
|
12
|
+
if not path:
|
|
13
|
+
continue
|
|
14
|
+
entry: dict[str, str] = {"path": path, "changes": changes}
|
|
15
|
+
if default_status is not None:
|
|
16
|
+
entry["status"] = default_status
|
|
17
|
+
entries.append(entry)
|
|
18
|
+
return entries
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def parse_name_status_entries(raw: str) -> list[dict[str, str]]:
|
|
22
|
+
entries: list[dict[str, str]] = []
|
|
23
|
+
for line in raw.splitlines():
|
|
24
|
+
parts = line.split("\t")
|
|
25
|
+
if len(parts) < 2:
|
|
26
|
+
continue
|
|
27
|
+
status = parts[0].strip()
|
|
28
|
+
code = status[:1].upper() if status else ""
|
|
29
|
+
if code in {"R", "C"} and len(parts) >= 3:
|
|
30
|
+
old_path = parts[1].strip()
|
|
31
|
+
path = parts[2].strip()
|
|
32
|
+
if not path:
|
|
33
|
+
continue
|
|
34
|
+
entry: dict[str, str] = {"status": status, "path": path}
|
|
35
|
+
if code and code != status:
|
|
36
|
+
entry["code"] = code
|
|
37
|
+
if old_path:
|
|
38
|
+
entry["old_path"] = old_path
|
|
39
|
+
if len(status) > 1 and status[1:].isdigit():
|
|
40
|
+
entry["score"] = status[1:]
|
|
41
|
+
entries.append(entry)
|
|
42
|
+
continue
|
|
43
|
+
|
|
44
|
+
path = parts[-1].strip()
|
|
45
|
+
if not path:
|
|
46
|
+
continue
|
|
47
|
+
entry = {"status": status, "path": path}
|
|
48
|
+
if code and code != status:
|
|
49
|
+
entry["code"] = code
|
|
50
|
+
entries.append(entry)
|
|
51
|
+
return entries
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
"""Helpers for standardized JSON envelopes in CLI commands."""
|
|
2
|
+
|
|
3
|
+
from collections.abc import Mapping
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
def ok_envelope(
|
|
7
|
+
*,
|
|
8
|
+
payload: Mapping[str, object] | None = None,
|
|
9
|
+
version: int = 2,
|
|
10
|
+
**extra: object,
|
|
11
|
+
) -> dict[str, object]:
|
|
12
|
+
data: dict[str, object] = {}
|
|
13
|
+
if payload is not None:
|
|
14
|
+
data.update(payload)
|
|
15
|
+
data.update(extra)
|
|
16
|
+
data["v"] = version
|
|
17
|
+
data["ok"] = True
|
|
18
|
+
return data
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def error_envelope(
|
|
22
|
+
*,
|
|
23
|
+
error: str,
|
|
24
|
+
code: str | None = None,
|
|
25
|
+
hint: str | None = None,
|
|
26
|
+
payload: Mapping[str, object] | None = None,
|
|
27
|
+
version: int = 2,
|
|
28
|
+
**extra: object,
|
|
29
|
+
) -> dict[str, object]:
|
|
30
|
+
data: dict[str, object] = {}
|
|
31
|
+
if payload is not None:
|
|
32
|
+
data.update(payload)
|
|
33
|
+
data.update(extra)
|
|
34
|
+
data["v"] = version
|
|
35
|
+
data["ok"] = False
|
|
36
|
+
data["error"] = error
|
|
37
|
+
err_item: dict[str, object] = {
|
|
38
|
+
"code": code or "error",
|
|
39
|
+
"message": error,
|
|
40
|
+
}
|
|
41
|
+
if hint:
|
|
42
|
+
err_item["hint"] = hint
|
|
43
|
+
data["errors"] = [err_item]
|
|
44
|
+
return data
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def passthrough_envelope(
|
|
48
|
+
*,
|
|
49
|
+
payload: Mapping[str, object] | None = None,
|
|
50
|
+
version: int = 2,
|
|
51
|
+
**extra: object,
|
|
52
|
+
) -> dict[str, object]:
|
|
53
|
+
data: dict[str, object] = {}
|
|
54
|
+
if payload is not None:
|
|
55
|
+
data.update(payload)
|
|
56
|
+
data.update(extra)
|
|
57
|
+
data["v"] = version
|
|
58
|
+
return data
|
gitwise/utils/parsing.py
ADDED
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
"""Shared parsing helpers for CLI text/number normalization."""
|
|
2
|
+
|
|
3
|
+
|
|
4
|
+
def non_empty_lines(text: str) -> list[str]:
|
|
5
|
+
return [line for line in text.splitlines() if line.strip()]
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
def stripped_non_empty_lines(text: str) -> list[str]:
|
|
9
|
+
return [line.strip() for line in text.splitlines() if line.strip()]
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
def to_int(value: object, *, default: int = 0) -> int:
|
|
13
|
+
if isinstance(value, int):
|
|
14
|
+
return value
|
|
15
|
+
try:
|
|
16
|
+
return int(str(value).strip())
|
|
17
|
+
except (ValueError, TypeError):
|
|
18
|
+
return default
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def parse_two_ints(text: str) -> tuple[int, int] | None:
|
|
22
|
+
parts = text.strip().split()
|
|
23
|
+
if len(parts) != 2:
|
|
24
|
+
return None
|
|
25
|
+
try:
|
|
26
|
+
return int(parts[0]), int(parts[1])
|
|
27
|
+
except (ValueError, TypeError):
|
|
28
|
+
return None
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def dict_list(value: object) -> list[dict[str, object]]:
|
|
32
|
+
if not isinstance(value, list):
|
|
33
|
+
return []
|
|
34
|
+
return [item for item in value if isinstance(item, dict)]
|