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/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
+ ]