aicp-cli 0.3.0__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.
- aicp/__init__.py +18 -0
- aicp/_keyreader.py +210 -0
- aicp/_skills_data/commit.md +90 -0
- aicp/_skills_data/safe-git-push/SKILL.md +55 -0
- aicp/_skills_data/safe-git-push/scripts/safe_push.py +248 -0
- aicp/_utils.py +183 -0
- aicp/budget.py +158 -0
- aicp/cli.py +595 -0
- aicp/config.py +434 -0
- aicp/contracts.py +144 -0
- aicp/gitflow.py +456 -0
- aicp/i18n.py +228 -0
- aicp/menu.py +1013 -0
- aicp/notify.py +87 -0
- aicp/present.py +303 -0
- aicp/quota.py +115 -0
- aicp/runner.py +560 -0
- aicp/secrets.py +291 -0
- aicp/skills.py +679 -0
- aicp/timing.py +116 -0
- aicp_cli-0.3.0.dist-info/METADATA +298 -0
- aicp_cli-0.3.0.dist-info/RECORD +25 -0
- aicp_cli-0.3.0.dist-info/WHEEL +4 -0
- aicp_cli-0.3.0.dist-info/entry_points.txt +2 -0
- aicp_cli-0.3.0.dist-info/licenses/LICENSE +20 -0
aicp/gitflow.py
ADDED
|
@@ -0,0 +1,456 @@
|
|
|
1
|
+
"""Everything aicp asks git, and the guards that refuse when git can't answer.
|
|
2
|
+
|
|
3
|
+
Port of ``_aicp_branch``, ``_aicp_diverged``, ``_aicp_preflight``,
|
|
4
|
+
``_aicp_precheck``, ``_aicp_commit_panel``, ``_aicp_undo`` and the RESULT-table
|
|
5
|
+
block of ``aicp()`` in ``~/scripts/bin/aicp``.
|
|
6
|
+
|
|
7
|
+
One rule runs through all of it: **every figure is re-read from git, never
|
|
8
|
+
taken from an AI CLI's own output** — a CLI that says "pushed!" proves
|
|
9
|
+
nothing. Its corollary is the one that keeps biting: a FAILED FETCH IS NOT
|
|
10
|
+
"IN SYNC". A fetch that fails (offline, auth expired, remote deleted) leaves
|
|
11
|
+
the remote-tracking ref exactly where the last successful fetch left it, so
|
|
12
|
+
it still resolves and ``rev-list`` still answers — about the past. The
|
|
13
|
+
dangerous answer is the stale "0 ahead, 0 behind", which reads as "already in
|
|
14
|
+
sync" and skips the push entirely. Every path here treats "cannot verify" as
|
|
15
|
+
a failure, never as a quiet success.
|
|
16
|
+
|
|
17
|
+
This module and :mod:`aicp.secrets` deliberately know nothing about the
|
|
18
|
+
runner, the config loader, the settings menu, or the skills installer;
|
|
19
|
+
messages are returned as line lists for the caller to print, so nothing here
|
|
20
|
+
needs a terminal either.
|
|
21
|
+
"""
|
|
22
|
+
|
|
23
|
+
from __future__ import annotations
|
|
24
|
+
|
|
25
|
+
import os
|
|
26
|
+
import re
|
|
27
|
+
import subprocess
|
|
28
|
+
from collections.abc import Sequence
|
|
29
|
+
from dataclasses import dataclass
|
|
30
|
+
from pathlib import Path
|
|
31
|
+
|
|
32
|
+
from ._utils import BOLD, DIM, GREEN, RED, RESET, YELLOW, have, run_interruptible
|
|
33
|
+
from .i18n import t
|
|
34
|
+
from .secrets import report_lines, scan
|
|
35
|
+
|
|
36
|
+
__all__ = [
|
|
37
|
+
"RemoteState",
|
|
38
|
+
"ResultSummary",
|
|
39
|
+
"commit_rows",
|
|
40
|
+
"current_branch",
|
|
41
|
+
"diverged",
|
|
42
|
+
"precheck",
|
|
43
|
+
"preflight",
|
|
44
|
+
"remote_for",
|
|
45
|
+
"remote_state",
|
|
46
|
+
"resolve_tz",
|
|
47
|
+
"result_summary",
|
|
48
|
+
"undo",
|
|
49
|
+
]
|
|
50
|
+
|
|
51
|
+
DEFAULT_TZ = "Asia/Taipei"
|
|
52
|
+
DEFAULT_TZ_LABEL = "UTC+8"
|
|
53
|
+
# AICP_TZ ends up in a real `TZ=` assignment handed to a child process, so its
|
|
54
|
+
# shape is pinned to an IANA-zone-looking name rather than passed through.
|
|
55
|
+
_TZ_RE = re.compile(r"^[A-Za-z][A-Za-z0-9_+-]*(/[A-Za-z0-9_+-]+)*$")
|
|
56
|
+
#: Abbreviation length every displayed hash in this project commits to.
|
|
57
|
+
ABBREV = 8
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def _git(cwd: Path | str | None, *args: str) -> subprocess.CompletedProcess[str]:
|
|
61
|
+
try:
|
|
62
|
+
return subprocess.run(
|
|
63
|
+
["git", *args], cwd=cwd, capture_output=True, text=True, check=False
|
|
64
|
+
)
|
|
65
|
+
except OSError: # git missing, or cwd gone
|
|
66
|
+
return subprocess.CompletedProcess(args, 1, "", "")
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def _out(cwd: Path | str | None, *args: str) -> str:
|
|
70
|
+
done = _git(cwd, *args)
|
|
71
|
+
return done.stdout.strip() if done.returncode == 0 else ""
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
# ── branch resolution ────────────────────────────────────────────────────────
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
def current_branch(cwd: Path | str | None = None) -> str | None:
|
|
78
|
+
"""The checked-out branch, or ``None`` on a detached HEAD / non-repo.
|
|
79
|
+
|
|
80
|
+
Shared by the main flow and ``--undo``: both refuse outright rather than
|
|
81
|
+
guess which branch a detached HEAD "meant".
|
|
82
|
+
"""
|
|
83
|
+
return _out(cwd, "symbolic-ref", "--quiet", "--short", "HEAD") or None
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
def not_on_branch_lines() -> list[str]:
|
|
87
|
+
return [
|
|
88
|
+
f"{RED}"
|
|
89
|
+
+ t("not_on_branch", "✗ not on a branch (detached HEAD, or not a git repo)")
|
|
90
|
+
+ RESET
|
|
91
|
+
]
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
def remote_for(branch: str, cwd: Path | str | None = None) -> str:
|
|
95
|
+
"""``branch.<name>.remote``, defaulting to ``origin``."""
|
|
96
|
+
return _out(cwd, "config", "--get", f"branch.{branch}.remote") or "origin"
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
# ── ahead / behind, and the fetch that has to be believed ────────────────────
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
@dataclass(frozen=True)
|
|
103
|
+
class RemoteState:
|
|
104
|
+
"""The result of comparing HEAD with ``<remote>/<branch>``.
|
|
105
|
+
|
|
106
|
+
*fetched* is False when the fetch itself failed, *resolved* when the
|
|
107
|
+
remote-tracking ref does not exist. Either one means the counts describe
|
|
108
|
+
the past (or nothing), which is why :attr:`in_sync` requires both.
|
|
109
|
+
"""
|
|
110
|
+
|
|
111
|
+
fetched: bool
|
|
112
|
+
resolved: bool
|
|
113
|
+
ahead: int = 0
|
|
114
|
+
behind: int = 0
|
|
115
|
+
|
|
116
|
+
@property
|
|
117
|
+
def in_sync(self) -> bool:
|
|
118
|
+
return self.fetched and self.resolved and self.ahead == 0 and self.behind == 0
|
|
119
|
+
|
|
120
|
+
@property
|
|
121
|
+
def verifiable(self) -> bool:
|
|
122
|
+
return self.resolved
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
def fetch(remote: str, branch: str, cwd: Path | str | None = None) -> bool:
|
|
126
|
+
"""``git fetch -q <remote> <branch>``; True on success.
|
|
127
|
+
|
|
128
|
+
Interruptible: fetching a large repo is the longest silent wait in a run,
|
|
129
|
+
and the zsh original got a working Ctrl+C there for free from a global
|
|
130
|
+
trap that this port has to arrange for itself.
|
|
131
|
+
"""
|
|
132
|
+
try:
|
|
133
|
+
done = run_interruptible(
|
|
134
|
+
["git", "fetch", "-q", remote, branch],
|
|
135
|
+
cwd=cwd,
|
|
136
|
+
stdout=subprocess.DEVNULL,
|
|
137
|
+
stderr=subprocess.DEVNULL,
|
|
138
|
+
)
|
|
139
|
+
except OSError:
|
|
140
|
+
return False
|
|
141
|
+
return done.returncode == 0
|
|
142
|
+
|
|
143
|
+
|
|
144
|
+
def remote_state(
|
|
145
|
+
remote: str, branch: str, cwd: Path | str | None = None, *, do_fetch: bool = True
|
|
146
|
+
) -> RemoteState:
|
|
147
|
+
"""Fetch, then count ahead/behind against ``<remote>/<branch>``."""
|
|
148
|
+
fetched = fetch(remote, branch, cwd) if do_fetch else True
|
|
149
|
+
resolved = (
|
|
150
|
+
_git(cwd, "rev-parse", "--verify", "--quiet", f"{remote}/{branch}").returncode == 0
|
|
151
|
+
)
|
|
152
|
+
if not resolved:
|
|
153
|
+
return RemoteState(fetched=fetched, resolved=False)
|
|
154
|
+
counts = _out(cwd, "rev-list", "--left-right", "--count", f"{remote}/{branch}...HEAD")
|
|
155
|
+
behind, _, ahead = counts.partition("\t")
|
|
156
|
+
return RemoteState(
|
|
157
|
+
fetched=fetched,
|
|
158
|
+
resolved=True,
|
|
159
|
+
ahead=int(ahead or 0),
|
|
160
|
+
behind=int(behind or 0),
|
|
161
|
+
)
|
|
162
|
+
|
|
163
|
+
|
|
164
|
+
def diverged(remote: str, branch: str, cwd: Path | str | None = None) -> bool:
|
|
165
|
+
"""True when there is something to push or something to rebase onto —
|
|
166
|
+
and also when the answer cannot be established at all, so an unreachable
|
|
167
|
+
or never-pushed branch still reaches the step that handles a remote
|
|
168
|
+
problem instead of being silently skipped."""
|
|
169
|
+
return not remote_state(remote, branch, cwd).in_sync
|
|
170
|
+
|
|
171
|
+
|
|
172
|
+
# ── preflight: fail before the expensive part, not after it ──────────────────
|
|
173
|
+
|
|
174
|
+
|
|
175
|
+
def config_path() -> Path:
|
|
176
|
+
"""``$AICP_CONFIG``, else ``~/.aicp/config.json`` — resolved at call time
|
|
177
|
+
(env only; naming the file this port would read is the whole job here,
|
|
178
|
+
and reading its contents belongs to a different module entirely)."""
|
|
179
|
+
override = os.environ.get("AICP_CONFIG")
|
|
180
|
+
return Path(override) if override else Path.home() / ".aicp" / "config.json"
|
|
181
|
+
|
|
182
|
+
|
|
183
|
+
def preflight(chain: Sequence[str]) -> tuple[bool, list[str]]:
|
|
184
|
+
"""``(ok, lines)`` — is any CLI in *chain* installed at all?
|
|
185
|
+
|
|
186
|
+
Called only from the paths that actually need a CLI, never once at the
|
|
187
|
+
top: a clean, in-sync repo answers both of aicp's questions from git
|
|
188
|
+
alone and succeeds with no AI CLI installed. A blanket pre-flight would
|
|
189
|
+
turn that into a failure — a regression wearing a check's clothing.
|
|
190
|
+
"""
|
|
191
|
+
if any(have(cli) for cli in chain):
|
|
192
|
+
return True, []
|
|
193
|
+
return False, [
|
|
194
|
+
f"{RED}" + t("no_cli_found", "✗ no AI CLI found on PATH") + RESET,
|
|
195
|
+
f"{DIM}" + t("no_cli_looked_for", " looked for: %s", ", ".join(chain)) + RESET,
|
|
196
|
+
f"{DIM}"
|
|
197
|
+
+ t("no_cli_install", " install one, or set AICP_CLI_ORDER in %s", config_path())
|
|
198
|
+
+ RESET,
|
|
199
|
+
]
|
|
200
|
+
|
|
201
|
+
|
|
202
|
+
def precheck(
|
|
203
|
+
chain: Sequence[str],
|
|
204
|
+
cwd: Path | str | None = None,
|
|
205
|
+
*,
|
|
206
|
+
do_commit: bool = True,
|
|
207
|
+
) -> tuple[bool, list[str]]:
|
|
208
|
+
"""``(proceed, lines)`` — ask git what is pending, and only if something
|
|
209
|
+
is, check a CLI exists and scan for secrets.
|
|
210
|
+
|
|
211
|
+
The order is the point: git first because it is cheap and can end the
|
|
212
|
+
question outright; the CLI check before the scan so a machine with no CLI
|
|
213
|
+
does not pay for reading the whole tree; the scan last because it is the
|
|
214
|
+
expensive one.
|
|
215
|
+
"""
|
|
216
|
+
if not _out(cwd, "status", "--porcelain"):
|
|
217
|
+
return True, []
|
|
218
|
+
if not do_commit:
|
|
219
|
+
return True, []
|
|
220
|
+
ok, lines = preflight(chain)
|
|
221
|
+
if not ok:
|
|
222
|
+
return False, lines
|
|
223
|
+
result = scan(cwd)
|
|
224
|
+
return result.ok, report_lines(result)
|
|
225
|
+
|
|
226
|
+
|
|
227
|
+
# ── NEW COMMITS panel ────────────────────────────────────────────────────────
|
|
228
|
+
|
|
229
|
+
|
|
230
|
+
def resolve_tz() -> str:
|
|
231
|
+
"""``$AICP_TZ`` if it looks like an IANA zone name, else the default.
|
|
232
|
+
|
|
233
|
+
Validated rather than trusted: the value is handed to a child process as
|
|
234
|
+
a real ``TZ=`` assignment.
|
|
235
|
+
"""
|
|
236
|
+
raw = os.environ.get("AICP_TZ") or DEFAULT_TZ
|
|
237
|
+
return raw if _TZ_RE.match(raw) else DEFAULT_TZ
|
|
238
|
+
|
|
239
|
+
|
|
240
|
+
def tz_label() -> str:
|
|
241
|
+
return os.environ.get("AICP_TZ_LABEL") or DEFAULT_TZ_LABEL
|
|
242
|
+
|
|
243
|
+
|
|
244
|
+
def commit_rows(
|
|
245
|
+
before: str, after: str, cwd: Path | str | None = None
|
|
246
|
+
) -> list[tuple[str, str]]:
|
|
247
|
+
"""``(hash, "MM-DD HH:MM subject")`` per commit in ``before..after``.
|
|
248
|
+
|
|
249
|
+
Oldest first, 8-char hashes, and the SUBJECT ONLY — the body is
|
|
250
|
+
deliberately never shown, being multi-line prose that would wreck the
|
|
251
|
+
frame. The fixed-width timestamp leads so every subject starts at the
|
|
252
|
+
same column; the zone is named once in the panel title instead of on
|
|
253
|
+
every row. Rows go straight into ``present.render(..., mode="panel",
|
|
254
|
+
zebra=True)``, which bands from the second row down.
|
|
255
|
+
"""
|
|
256
|
+
env = dict(os.environ, TZ=resolve_tz())
|
|
257
|
+
try:
|
|
258
|
+
done = subprocess.run(
|
|
259
|
+
[
|
|
260
|
+
"git",
|
|
261
|
+
"log",
|
|
262
|
+
"--no-color",
|
|
263
|
+
"--reverse",
|
|
264
|
+
f"--abbrev={ABBREV}",
|
|
265
|
+
"--date=format-local:%m-%d %H:%M",
|
|
266
|
+
"--format=%h%x1f%ad%x1f%s",
|
|
267
|
+
f"{before}..{after}",
|
|
268
|
+
],
|
|
269
|
+
cwd=cwd,
|
|
270
|
+
capture_output=True,
|
|
271
|
+
text=True,
|
|
272
|
+
check=False,
|
|
273
|
+
env=env,
|
|
274
|
+
)
|
|
275
|
+
except OSError:
|
|
276
|
+
return []
|
|
277
|
+
if done.returncode != 0:
|
|
278
|
+
return []
|
|
279
|
+
rows = []
|
|
280
|
+
for line in done.stdout.splitlines():
|
|
281
|
+
short, _, rest = line.partition("\x1f")
|
|
282
|
+
date, _, subject = rest.partition("\x1f")
|
|
283
|
+
rows.append((short, f"{date} {subject}"))
|
|
284
|
+
return rows
|
|
285
|
+
|
|
286
|
+
|
|
287
|
+
# ── the git-verified RESULT table ────────────────────────────────────────────
|
|
288
|
+
|
|
289
|
+
|
|
290
|
+
@dataclass(frozen=True)
|
|
291
|
+
class ResultSummary:
|
|
292
|
+
"""What actually happened, read back from git after the CLIs have run.
|
|
293
|
+
|
|
294
|
+
``ahead``/``behind`` carry ``-1`` as the "comparison impossible" sentinel
|
|
295
|
+
(the remote-tracking ref does not resolve) — never 0, which would claim a
|
|
296
|
+
push that never happened.
|
|
297
|
+
"""
|
|
298
|
+
|
|
299
|
+
repo: str
|
|
300
|
+
branch: str
|
|
301
|
+
remote: str
|
|
302
|
+
created: int
|
|
303
|
+
ahead: int
|
|
304
|
+
behind: int
|
|
305
|
+
fetch_failed: bool
|
|
306
|
+
|
|
307
|
+
@property
|
|
308
|
+
def verifiable(self) -> bool:
|
|
309
|
+
return self.ahead >= 0
|
|
310
|
+
|
|
311
|
+
@property
|
|
312
|
+
def in_sync(self) -> bool:
|
|
313
|
+
return self.verifiable and self.ahead == 0 and self.behind == 0
|
|
314
|
+
|
|
315
|
+
@property
|
|
316
|
+
def fetch_note(self) -> str:
|
|
317
|
+
"""The caveat printed above the table when the fetch failed.
|
|
318
|
+
|
|
319
|
+
A push updates its own remote-tracking ref locally, so Ahead/Behind
|
|
320
|
+
are usually still right after a failed fetch; what they can no longer
|
|
321
|
+
account for is anything the remote gained meanwhile. The numbers are
|
|
322
|
+
still shown — with this said out loud rather than presented as
|
|
323
|
+
freshly verified.
|
|
324
|
+
"""
|
|
325
|
+
if not self.fetch_failed:
|
|
326
|
+
return ""
|
|
327
|
+
return (
|
|
328
|
+
f"{YELLOW}"
|
|
329
|
+
+ t(
|
|
330
|
+
"fetch_note",
|
|
331
|
+
"⚠ git fetch %s %s failed — Ahead/Behind below are read from the last known remote state, not a fresh one",
|
|
332
|
+
self.remote,
|
|
333
|
+
self.branch,
|
|
334
|
+
)
|
|
335
|
+
+ RESET
|
|
336
|
+
)
|
|
337
|
+
|
|
338
|
+
@property
|
|
339
|
+
def status(self) -> str:
|
|
340
|
+
if not self.verifiable:
|
|
341
|
+
return t("state_remote_missing", "✗ %s/%s not found", self.remote, self.branch)
|
|
342
|
+
if self.in_sync:
|
|
343
|
+
return t("state_in_sync", "✅ in sync")
|
|
344
|
+
return t("state_not_pushed", "⚠️ not pushed")
|
|
345
|
+
|
|
346
|
+
def rows(self) -> list[tuple[str, str]]:
|
|
347
|
+
"""Plain (label, value) pairs for ``present.render(..., "RESULT")``."""
|
|
348
|
+
cell = "?" if not self.verifiable else None
|
|
349
|
+
return [
|
|
350
|
+
(t("result_repo", "Repo"), self.repo),
|
|
351
|
+
(t("result_branch", "Branch"), self.branch),
|
|
352
|
+
(t("result_remote", "Remote"), f"{self.remote}/{self.branch}"),
|
|
353
|
+
(t("result_new_commits", "New commits"), str(self.created)),
|
|
354
|
+
(t("result_ahead", "Ahead"), cell or str(self.ahead)),
|
|
355
|
+
(t("result_behind", "Behind"), cell or str(self.behind)),
|
|
356
|
+
(t("result_status", "Status"), self.status),
|
|
357
|
+
]
|
|
358
|
+
|
|
359
|
+
|
|
360
|
+
def result_summary(
|
|
361
|
+
before: str,
|
|
362
|
+
after: str,
|
|
363
|
+
remote: str,
|
|
364
|
+
branch: str,
|
|
365
|
+
cwd: Path | str | None = None,
|
|
366
|
+
) -> ResultSummary:
|
|
367
|
+
"""Re-read the whole outcome from git: how many commits were really
|
|
368
|
+
created, and where HEAD really stands against the remote."""
|
|
369
|
+
created = _out(cwd, "rev-list", "--count", f"{before}..{after}")
|
|
370
|
+
toplevel = _out(cwd, "rev-parse", "--show-toplevel")
|
|
371
|
+
state = remote_state(remote, branch, cwd)
|
|
372
|
+
return ResultSummary(
|
|
373
|
+
repo=Path(toplevel).name if toplevel else "",
|
|
374
|
+
branch=branch,
|
|
375
|
+
remote=remote,
|
|
376
|
+
created=int(created or 0),
|
|
377
|
+
ahead=state.ahead if state.resolved else -1,
|
|
378
|
+
behind=state.behind if state.resolved else -1,
|
|
379
|
+
fetch_failed=not state.fetched,
|
|
380
|
+
)
|
|
381
|
+
|
|
382
|
+
|
|
383
|
+
# ── --undo ───────────────────────────────────────────────────────────────────
|
|
384
|
+
|
|
385
|
+
|
|
386
|
+
def undo(cwd: Path | str | None = None) -> tuple[bool, list[str]]:
|
|
387
|
+
"""``git reset --soft HEAD^`` — the escape hatch for a bad commit message
|
|
388
|
+
or a wrong stage. Returns ``(ok, lines)`` and runs no AI CLI on any path.
|
|
389
|
+
|
|
390
|
+
``--soft`` only rewinds HEAD and the index pointer, so the change lands
|
|
391
|
+
back in the index exactly as it was mid-edit. Refusal is the point: once
|
|
392
|
+
a commit reaches the remote, other clones or CI may already be building
|
|
393
|
+
on it, so this must never be the command that quietly rewinds shared
|
|
394
|
+
history. Four refusals, all with HEAD untouched:
|
|
395
|
+
|
|
396
|
+
* detached HEAD — there is no branch to compare against;
|
|
397
|
+
* no parent commit — nothing to reset onto;
|
|
398
|
+
* the commit is already on the remote;
|
|
399
|
+
* the remote cannot be verified (deleted remote, failed fetch, never
|
|
400
|
+
pushed). "Cannot verify" is not "verified safe": the whole point of the
|
|
401
|
+
guard is to never rewind a commit that might already be public, so an
|
|
402
|
+
unprovable case is treated as the risky one.
|
|
403
|
+
|
|
404
|
+
The ahead/behind comparison is spelled out here rather than delegated to
|
|
405
|
+
:func:`diverged`, which answers the wider "is there ANY difference"
|
|
406
|
+
(true on ahead OR behind OR unresolvable); undo needs the narrower
|
|
407
|
+
``ahead == 0``, i.e. HEAD carries nothing the remote doesn't have.
|
|
408
|
+
"""
|
|
409
|
+
branch = current_branch(cwd)
|
|
410
|
+
if branch is None:
|
|
411
|
+
return False, not_on_branch_lines()
|
|
412
|
+
|
|
413
|
+
if _git(cwd, "rev-parse", "--verify", "--quiet", "HEAD^").returncode != 0:
|
|
414
|
+
return False, [
|
|
415
|
+
f"{RED}"
|
|
416
|
+
+ t("undo_no_parent", "✗ nothing to undo — HEAD has no parent commit")
|
|
417
|
+
+ RESET
|
|
418
|
+
]
|
|
419
|
+
|
|
420
|
+
remote = remote_for(branch, cwd)
|
|
421
|
+
state = remote_state(remote, branch, cwd)
|
|
422
|
+
if not state.fetched or not state.resolved:
|
|
423
|
+
return False, [
|
|
424
|
+
f"{RED}"
|
|
425
|
+
+ t(
|
|
426
|
+
"undo_unverifiable",
|
|
427
|
+
"✗ refusing --undo: cannot verify %s/%s (unreachable, or never pushed) — could be rewriting pushed history",
|
|
428
|
+
remote,
|
|
429
|
+
branch,
|
|
430
|
+
)
|
|
431
|
+
+ RESET
|
|
432
|
+
]
|
|
433
|
+
if state.ahead == 0:
|
|
434
|
+
return False, [
|
|
435
|
+
f"{RED}"
|
|
436
|
+
+ t(
|
|
437
|
+
"undo_already_pushed",
|
|
438
|
+
"✗ refusing --undo: the last commit is already on %s/%s — undo would rewrite pushed history",
|
|
439
|
+
remote,
|
|
440
|
+
branch,
|
|
441
|
+
)
|
|
442
|
+
+ RESET
|
|
443
|
+
]
|
|
444
|
+
|
|
445
|
+
short = _out(cwd, "rev-parse", f"--short={ABBREV}", "HEAD")
|
|
446
|
+
subject = _out(cwd, "log", "-1", "--format=%s")
|
|
447
|
+
if _git(cwd, "reset", "--soft", "HEAD^").returncode != 0:
|
|
448
|
+
return False, [
|
|
449
|
+
f"{RED}" + t("undo_reset_failed", "✗ git reset --soft HEAD^ failed") + RESET
|
|
450
|
+
]
|
|
451
|
+
return True, [
|
|
452
|
+
f"{GREEN}{BOLD}" + t("undo_done", "✓ undone:") + f"{RESET} {YELLOW}{short}{RESET} {subject}",
|
|
453
|
+
f"{DIM}"
|
|
454
|
+
+ t("undo_note", " changes are back in the index — not lost, not pushed.")
|
|
455
|
+
+ RESET,
|
|
456
|
+
]
|