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/__init__.py
ADDED
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
"""aicp — AI commit + push, with a git-verified result summary.
|
|
2
|
+
|
|
3
|
+
Python port of ``~/scripts/bin/aicp``. See ``aicp.contracts`` for the frozen
|
|
4
|
+
interface shared across this package's modules.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
from importlib.metadata import PackageNotFoundError, version
|
|
10
|
+
|
|
11
|
+
try:
|
|
12
|
+
# The distribution name, not the import name — these differ: `aicp` was
|
|
13
|
+
# taken on PyPI in 2021 by an unrelated project. Reading it from installed
|
|
14
|
+
# metadata keeps pyproject.toml the single source of truth, so the version
|
|
15
|
+
# in `--version` and the `--config` title can never drift from the release.
|
|
16
|
+
__version__ = version("aicp-cli")
|
|
17
|
+
except PackageNotFoundError: # pragma: no cover - unbuilt checkout
|
|
18
|
+
__version__ = "0+unknown"
|
aicp/_keyreader.py
ADDED
|
@@ -0,0 +1,210 @@
|
|
|
1
|
+
"""Raw single-key input, with a guaranteed non-blocking degradation.
|
|
2
|
+
|
|
3
|
+
Three surfaces, one module: POSIX ``termios``/``tty``, Windows ``msvcrt``,
|
|
4
|
+
and — the one that actually matters for correctness — a plain line-oriented
|
|
5
|
+
fallback whenever stdin is not a TTY.
|
|
6
|
+
|
|
7
|
+
**Why the fallback is load-bearing.** aicp runs in CI, in a pipe, and under
|
|
8
|
+
this project's own pytest suite, none of which have a terminal to press keys
|
|
9
|
+
on. A menu that reached for raw mode there would either raise (no ``fileno``)
|
|
10
|
+
or, far worse, block forever waiting for a keystroke that is never coming.
|
|
11
|
+
So :func:`is_interactive` is checked BEFORE any raw-mode call, and
|
|
12
|
+
:func:`read_line` treats EOF as "quit" rather than as something to wait on.
|
|
13
|
+
:func:`read_key` mirrors that: on a non-TTY it reads a line and maps it, and
|
|
14
|
+
an empty read is ``"quit"``, never a wait.
|
|
15
|
+
|
|
16
|
+
Key names returned are semantic (``up``/``down``/``left``/``right``/
|
|
17
|
+
``enter``/``quit``/``reset``/``yes``/``other``) so the menu never sees a byte.
|
|
18
|
+
"""
|
|
19
|
+
|
|
20
|
+
from __future__ import annotations
|
|
21
|
+
|
|
22
|
+
import contextlib
|
|
23
|
+
import sys
|
|
24
|
+
from collections.abc import Callable, Iterator
|
|
25
|
+
from typing import IO
|
|
26
|
+
|
|
27
|
+
__all__ = ["is_interactive", "key_session", "pending", "read_key", "read_line"]
|
|
28
|
+
|
|
29
|
+
#: What :func:`key_session` hands back: call it to wrap a prompt that reads a
|
|
30
|
+
#: typed line, which needs the line discipline the session suspends.
|
|
31
|
+
_Typed = Callable[[], "contextlib.AbstractContextManager[None]"]
|
|
32
|
+
|
|
33
|
+
# Windows sends arrows as a two-byte sequence introduced by one of these.
|
|
34
|
+
_WIN_PREFIXES = ("\x00", "\xe0")
|
|
35
|
+
_WIN_ARROWS = {"H": "up", "P": "down", "K": "left", "M": "right"}
|
|
36
|
+
_VT_ARROWS = {"A": "up", "B": "down", "C": "right", "D": "left"}
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def is_interactive(stdin: IO[str] | None = None, stdout: IO[str] | None = None) -> bool:
|
|
40
|
+
"""True only when BOTH streams are a real terminal.
|
|
41
|
+
|
|
42
|
+
Both, not either: a menu that repaints needs somewhere to draw as much as
|
|
43
|
+
it needs somewhere to read from, and a redirected stdout with a live
|
|
44
|
+
stdin still has no frame to walk the cursor back up through.
|
|
45
|
+
"""
|
|
46
|
+
streams = (stdin or sys.stdin, stdout or sys.stdout)
|
|
47
|
+
try:
|
|
48
|
+
return all(s is not None and s.isatty() for s in streams)
|
|
49
|
+
except (AttributeError, ValueError, OSError):
|
|
50
|
+
return False
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def read_line(stdin: IO[str] | None = None) -> str | None:
|
|
54
|
+
"""One stripped line, or ``None`` at EOF (never a block on a dead pipe)."""
|
|
55
|
+
stream = stdin or sys.stdin
|
|
56
|
+
try:
|
|
57
|
+
line = stream.readline()
|
|
58
|
+
except (OSError, ValueError):
|
|
59
|
+
return None
|
|
60
|
+
if line == "":
|
|
61
|
+
return None
|
|
62
|
+
return line.strip()
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def _classify(text: str) -> str:
|
|
66
|
+
"""Map typed text from the non-raw path onto a semantic key name."""
|
|
67
|
+
lowered = text.strip().lower()
|
|
68
|
+
if lowered in ("", "q", "quit"):
|
|
69
|
+
return "quit"
|
|
70
|
+
return {
|
|
71
|
+
"up": "up", "k": "up",
|
|
72
|
+
"down": "down", "j": "down",
|
|
73
|
+
"left": "left", "h": "left",
|
|
74
|
+
"right": "right", "l": "right",
|
|
75
|
+
"r": "reset",
|
|
76
|
+
"y": "yes",
|
|
77
|
+
}.get(lowered, "enter" if lowered in ("enter", "\n") else "other")
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
def _read_key_posix(stream: IO[str]) -> str:
|
|
81
|
+
import os
|
|
82
|
+
import termios
|
|
83
|
+
import tty
|
|
84
|
+
|
|
85
|
+
fd = stream.fileno()
|
|
86
|
+
saved = termios.tcgetattr(fd)
|
|
87
|
+
try:
|
|
88
|
+
# TCSANOW, not tty.setraw's TCSAFLUSH default: flushing discards
|
|
89
|
+
# typeahead, so a key pressed while the menu was still repainting
|
|
90
|
+
# would be silently swallowed rather than acted on next.
|
|
91
|
+
tty.setraw(fd, termios.TCSANOW)
|
|
92
|
+
ch = os.read(fd, 1).decode("utf-8", "replace")
|
|
93
|
+
if ch == "\x1b":
|
|
94
|
+
# An arrow is ESC [ X; a bare ESC is the user backing out. The
|
|
95
|
+
# follow-up bytes are already buffered by the terminal, so this
|
|
96
|
+
# read cannot hang on a real escape sequence.
|
|
97
|
+
rest = os.read(fd, 2).decode("utf-8", "replace")
|
|
98
|
+
return _VT_ARROWS.get(rest[-1:], "other") if rest.startswith("[") else "quit"
|
|
99
|
+
finally:
|
|
100
|
+
# Restoring the terminal is not optional and not only for the happy
|
|
101
|
+
# path: a shell left in -icanon -echo looks broken to whoever uses it
|
|
102
|
+
# next, so the restore is armed before raw mode is ever entered.
|
|
103
|
+
termios.tcsetattr(fd, termios.TCSADRAIN, saved)
|
|
104
|
+
return _from_char(ch)
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
def _read_key_windows() -> str:
|
|
108
|
+
import msvcrt
|
|
109
|
+
|
|
110
|
+
ch = msvcrt.getwch()
|
|
111
|
+
if ch in _WIN_PREFIXES:
|
|
112
|
+
return _WIN_ARROWS.get(msvcrt.getwch(), "other")
|
|
113
|
+
return _from_char(ch)
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
def _from_char(ch: str) -> str:
|
|
117
|
+
if ch in ("\r", "\n", " "):
|
|
118
|
+
return "enter"
|
|
119
|
+
# Raw mode disables ISIG, so Ctrl+C arrives as a byte rather than a
|
|
120
|
+
# signal — cancelling a menu changed nothing and is not a failure.
|
|
121
|
+
if ch in ("q", "Q", "\x03", "\x04", ""):
|
|
122
|
+
return "quit"
|
|
123
|
+
if ch in ("r", "R"):
|
|
124
|
+
return "reset"
|
|
125
|
+
if ch in ("y", "Y"):
|
|
126
|
+
return "yes"
|
|
127
|
+
return "other"
|
|
128
|
+
|
|
129
|
+
|
|
130
|
+
@contextlib.contextmanager
|
|
131
|
+
def key_session(stdin: IO[str], stdout: IO[str]) -> Iterator[_Typed]:
|
|
132
|
+
"""Hold the terminal in cbreak mode for a whole arrow-key session.
|
|
133
|
+
|
|
134
|
+
Raw mode per keypress leaves the terminal echoing **between** reads, and
|
|
135
|
+
a menu that animates spends real time between reads: a key pressed while
|
|
136
|
+
a frame is moving is echoed onto the screen by the terminal driver
|
|
137
|
+
itself, an Enter echoes a newline that pushes the whole frame down a
|
|
138
|
+
row, and every repaint after it walks the cursor up to the wrong place —
|
|
139
|
+
stacking a fresh header on screen for each one. That is the "hold Enter
|
|
140
|
+
and the panel multiplies" bug, and no amount of care in the drawing code
|
|
141
|
+
can fix it, because it is not the drawing code writing.
|
|
142
|
+
|
|
143
|
+
cbreak and not raw: ``tty.setraw`` also turns off output processing, and
|
|
144
|
+
a panel printed with no NL→CRNL translation comes out as a staircase.
|
|
145
|
+
cbreak touches the input side only — and leaves Ctrl+C a signal, which
|
|
146
|
+
the caller handles rather than reading as a byte.
|
|
147
|
+
|
|
148
|
+
Yields the context manager to wrap any prompt that reads a typed line:
|
|
149
|
+
:func:`read_line` needs the canonical mode this suspends.
|
|
150
|
+
"""
|
|
151
|
+
if sys.platform == "win32" or not is_interactive(stdin, stdout):
|
|
152
|
+
yield contextlib.nullcontext
|
|
153
|
+
return
|
|
154
|
+
import termios
|
|
155
|
+
import tty
|
|
156
|
+
|
|
157
|
+
fd = stdin.fileno()
|
|
158
|
+
saved = termios.tcgetattr(fd)
|
|
159
|
+
|
|
160
|
+
@contextlib.contextmanager
|
|
161
|
+
def typed() -> Iterator[None]:
|
|
162
|
+
termios.tcsetattr(fd, termios.TCSADRAIN, saved)
|
|
163
|
+
try:
|
|
164
|
+
yield
|
|
165
|
+
finally:
|
|
166
|
+
tty.setcbreak(fd, termios.TCSANOW)
|
|
167
|
+
|
|
168
|
+
try:
|
|
169
|
+
# TCSANOW, not setcbreak's TCSAFLUSH default: flushing discards
|
|
170
|
+
# typeahead, and a key pressed while the menu was still drawing is
|
|
171
|
+
# one the user meant, not one to swallow.
|
|
172
|
+
tty.setcbreak(fd, termios.TCSANOW)
|
|
173
|
+
yield typed
|
|
174
|
+
finally:
|
|
175
|
+
termios.tcsetattr(fd, termios.TCSADRAIN, saved)
|
|
176
|
+
|
|
177
|
+
|
|
178
|
+
def pending(stdin: IO[str]) -> bool:
|
|
179
|
+
"""Whether a keypress is already waiting to be read.
|
|
180
|
+
|
|
181
|
+
An animation is time the menu is not listening, so it asks: with another
|
|
182
|
+
key already queued, the frames still to draw are ones nobody will look
|
|
183
|
+
at, and dropping them is what keeps a held-down key feeling immediate
|
|
184
|
+
instead of replaying a backlog of slides.
|
|
185
|
+
"""
|
|
186
|
+
try:
|
|
187
|
+
if sys.platform == "win32":
|
|
188
|
+
import msvcrt
|
|
189
|
+
|
|
190
|
+
return msvcrt.kbhit()
|
|
191
|
+
import select
|
|
192
|
+
|
|
193
|
+
return bool(select.select([stdin], [], [], 0)[0])
|
|
194
|
+
except (AttributeError, ImportError, OSError, ValueError):
|
|
195
|
+
return False
|
|
196
|
+
|
|
197
|
+
|
|
198
|
+
def read_key(stdin: IO[str] | None = None, stdout: IO[str] | None = None) -> str:
|
|
199
|
+
"""One semantic key press. Falls back to a typed line on a non-TTY."""
|
|
200
|
+
stream = stdin or sys.stdin
|
|
201
|
+
if not is_interactive(stream, stdout):
|
|
202
|
+
line = read_line(stream)
|
|
203
|
+
return "quit" if line is None else _classify(line)
|
|
204
|
+
if sys.platform == "win32":
|
|
205
|
+
return _read_key_windows()
|
|
206
|
+
try:
|
|
207
|
+
return _read_key_posix(stream)
|
|
208
|
+
except (OSError, ValueError, ImportError):
|
|
209
|
+
line = read_line(stream)
|
|
210
|
+
return "quit" if line is None else _classify(line)
|
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
---
|
|
2
|
+
description: Stage and commit all changes, grouped into logical Conventional Commits batches.
|
|
3
|
+
model: sonnet
|
|
4
|
+
effort: medium
|
|
5
|
+
---
|
|
6
|
+
|
|
7
|
+
# Commit
|
|
8
|
+
|
|
9
|
+
Git commit current changes, split into logical batches if needed.
|
|
10
|
+
|
|
11
|
+
## Project override (check FIRST)
|
|
12
|
+
|
|
13
|
+
Skills/custom commands resolve **personal-over-project** on a name clash, so
|
|
14
|
+
this global `/commit` shadows a project's own `.claude/commands/commit.md`. The
|
|
15
|
+
global CLAUDE.md "same-name precedence" rule already requires deferring to the
|
|
16
|
+
project version when one exists — honor it: if `.claude/commands/commit.md`
|
|
17
|
+
exists in the project, READ it and follow it instead of this file (pass
|
|
18
|
+
`$ARGUMENTS` through unchanged), and skip the rest of this file.
|
|
19
|
+
|
|
20
|
+
Only when the project has **no** `commit.md` of its own: if `$ARGUMENTS` looks
|
|
21
|
+
like an issue-tracker key (`[A-Z]+-[0-9]+`) or a `.../browse/<KEY>` URL AND the
|
|
22
|
+
project defines `.claude/commands/commit-bug.md`, invoke `/commit-bug` with the
|
|
23
|
+
original `$ARGUMENTS` and stop; otherwise fall through to the standard flow.
|
|
24
|
+
|
|
25
|
+
## Workflow
|
|
26
|
+
|
|
27
|
+
1. Run `git status` + `git diff --stat` to survey all uncommitted changes. Read full
|
|
28
|
+
diffs only where grouping is not already obvious from the path — a file whose
|
|
29
|
+
concern is ambiguous, or one you suspect spans concerns (step 6). Never open the
|
|
30
|
+
full diff of the whole worktree up front.
|
|
31
|
+
2. **If any `.jsonl` files appear in the changes (modified, untracked, or staged), notify the user** — list the file(s), then skip them entirely and continue with all other commits as normal. Only perform git operations on `.jsonl` files (commit, discard, etc.) if the user explicitly names the file(s) and requests it.
|
|
32
|
+
3. **If `.gitignore` or `.gitignore_global` has any changes (new file, modified, or deleted), commit them alone first** before any other batch.
|
|
33
|
+
4. Group remaining changes into atomic batches — one concern per commit (feature code, tests, docs, config, etc.).
|
|
34
|
+
5. **Before staging each batch**, check `git status` for any pre-existing staged files (left column `M`/`A`/`D`). If any staged file does not belong to the current batch, run `git restore --staged <file>` to pull it out of the index first — otherwise it will be swept into the commit unintentionally.
|
|
35
|
+
6. For each file in a batch, decide the staging strategy:
|
|
36
|
+
- **All hunks belong to the same concern** → `git add <file>` (stage the whole file)
|
|
37
|
+
- **Hunks span multiple concerns** → stage only the relevant hunks via patch:
|
|
38
|
+
1. `git diff <file>` — capture the full diff
|
|
39
|
+
2. Extract only the hunk(s) belonging to this batch (preserve the file header lines and the correct `@@ … @@` context lines)
|
|
40
|
+
3. Write the partial patch to a temp file, then `git apply --cached <tmpfile>`
|
|
41
|
+
4. Commit; repeat for the remaining hunks in subsequent batches
|
|
42
|
+
|
|
43
|
+
Then write a compliant commit message and commit.
|
|
44
|
+
|
|
45
|
+
If all changes form one coherent unit, make one commit. Never bundle unrelated changes.
|
|
46
|
+
|
|
47
|
+
## Commit Message Format
|
|
48
|
+
|
|
49
|
+
```text
|
|
50
|
+
<type>[(<scope>)]: <description>
|
|
51
|
+
|
|
52
|
+
[body]
|
|
53
|
+
|
|
54
|
+
[footer(s)]
|
|
55
|
+
```
|
|
56
|
+
|
|
57
|
+
**Types:** `feat` `fix` `docs` `style` `refactor` `test` `perf` `build` `ci` `chore` `hotfix` `revert`
|
|
58
|
+
|
|
59
|
+
Pick one from that list — **never invent a new type** (`update`, `remove`, `config`, `improve` are all invalid). No exact match → take the closest: source changed but behavior unchanged → `refactor`; source untouched (deps, generated files, moves, config) → `chore`.
|
|
60
|
+
|
|
61
|
+
**Hard constraints on `<description>`:** max 50 characters; no trailing period; lowercase.
|
|
62
|
+
|
|
63
|
+
**Body:** wrap at 72 characters per line.
|
|
64
|
+
|
|
65
|
+
**Footer:** if the change relates to a tracked issue, add `Closes #<issue>` in the footer.
|
|
66
|
+
|
|
67
|
+
> **Capitalization conflict:** Conventional Commits uses all-lowercase `description`; the seven-rules style capitalizes the first letter. Default here is lowercase — pick one per project and apply consistently.
|
|
68
|
+
|
|
69
|
+
## Breaking Change Detection
|
|
70
|
+
|
|
71
|
+
Before composing each commit message, scan the staged diff. Mark the commit with `!` after the type/scope **and** add a `BREAKING CHANGE:` footer if **any** of these apply:
|
|
72
|
+
|
|
73
|
+
- **Schema rename/removal** — YAML/JSON/proto/GraphQL/TS-type fields renamed, removed, or restructured in a file other code/agents/scripts parse.
|
|
74
|
+
- **Public API removal/rename** — exported function, class, slash command, agent, skill, plugin entry, or event template deleted or renamed.
|
|
75
|
+
- **Required input change** — optional → required, or required param type changes incompatibly.
|
|
76
|
+
- **Output format change** — stdout, return value, file format, or notification event payload changes in a way callers must adapt to.
|
|
77
|
+
- **Config restructure** — `settings.json` / `manifest.txt` / `.env` keys renamed or removed when downstream consumers depend on them.
|
|
78
|
+
|
|
79
|
+
Example:
|
|
80
|
+
|
|
81
|
+
```text
|
|
82
|
+
refactor(scope)!: short description
|
|
83
|
+
|
|
84
|
+
Body explaining the change.
|
|
85
|
+
|
|
86
|
+
BREAKING CHANGE: <one-line consequence summary>
|
|
87
|
+
<details if needed; wrap at 72 chars>
|
|
88
|
+
```
|
|
89
|
+
|
|
90
|
+
If ambiguous, **ask the user** before committing: *"Is this change breaking for callers? (yes/no)"*
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: safe-git-push
|
|
3
|
+
description: Safely synchronize and push the current git branch. Always fetches before pushing, never uses `git pull`, never creates an accidental self-merge like "Merge remote-tracking branch 'origin/develop' into develop", preserves intentional cross-branch merge topology instead of flattening it with a blind rebase, and never force-pushes or rewrites shared history. Use this whenever a `git push` is needed, especially on a shared branch (develop/main) or after the remote may have moved.
|
|
4
|
+
---
|
|
5
|
+
|
|
6
|
+
# Safe Git Push
|
|
7
|
+
|
|
8
|
+
Fetch → compare local vs. remote by ancestry → integrate only if it can be
|
|
9
|
+
done without inventing a self-merge or flattening a real merge → push. All
|
|
10
|
+
of that logic lives in `scripts/safe_push.py`; this file just explains when
|
|
11
|
+
each branch of the decision runs and what to do with the result.
|
|
12
|
+
|
|
13
|
+
## Run it
|
|
14
|
+
|
|
15
|
+
```bash
|
|
16
|
+
python3 scripts/safe_push.py
|
|
17
|
+
# ambiguous remote (no upstream set, multiple remotes exist):
|
|
18
|
+
python3 scripts/safe_push.py --remote origin
|
|
19
|
+
```
|
|
20
|
+
|
|
21
|
+
Exit code `0` = pushed, fast-forwarded, or already in sync (message says
|
|
22
|
+
which). Exit code `1` = stopped without touching anything — read the
|
|
23
|
+
`Safe push aborted: <reason>` line on stderr and handle it by hand.
|
|
24
|
+
|
|
25
|
+
## Decision summary
|
|
26
|
+
|
|
27
|
+
| Situation after fetch | What the script does |
|
|
28
|
+
|---|---|
|
|
29
|
+
| Remote branch doesn't exist yet | First push, `--set-upstream` |
|
|
30
|
+
| Local == remote | Nothing to push, reports and exits |
|
|
31
|
+
| Local strictly ahead | Push directly, no rebase/merge |
|
|
32
|
+
| Local strictly behind | `git merge --ff-only`, then nothing to push |
|
|
33
|
+
| Diverged, local history has a likely self-merge not yet pushed | **Stops** — won't guess whether to keep or drop it |
|
|
34
|
+
| Diverged, otherwise | Backs up HEAD to a throwaway ref, `git rebase --rebase-merges=no-rebase-cousins <remote>` (preserves real cross-branch merges instead of flattening them), verifies the merge count and ancestry didn't change, then pushes |
|
|
35
|
+
| Rebase conflicts | Aborts the rebase, restores the branch, reports the conflicting files, stops |
|
|
36
|
+
| Push rejected (someone else pushed in between) | Re-fetches and re-analyzes, up to 2 retries, never force-pushes |
|
|
37
|
+
| Remote says protected branch / permission denied | Stops, tells you to use the normal PR/MR flow |
|
|
38
|
+
|
|
39
|
+
Never done, under any circumstance: `git pull`, a plain `git merge <remote-ref>`,
|
|
40
|
+
`git push --force`/`-f`/`--force-with-lease`/`--all`/`--mirror`/`--tags`/`--no-verify`,
|
|
41
|
+
`git reset --hard` on anything but the script's own just-created backup ref,
|
|
42
|
+
`git stash`/`clean`/`commit`, or switching branches. If any of those turns out
|
|
43
|
+
to be genuinely necessary, that's a deliberate follow-up task for you to run
|
|
44
|
+
yourself, not something this skill does silently.
|
|
45
|
+
|
|
46
|
+
## Known gaps (not automated — call out if they matter)
|
|
47
|
+
|
|
48
|
+
- Doesn't verify a submodule gitlink you're pushing already exists on the
|
|
49
|
+
submodule's own remote. Rare; check manually if the diff touches submodules.
|
|
50
|
+
- Doesn't try to auto-resolve rebase conflicts — always stops and hands them
|
|
51
|
+
back. Automating "safe" conflict resolution is exactly the kind of guess
|
|
52
|
+
this skill exists to avoid making.
|
|
53
|
+
- Signed commits: rebasing recreates commits, so if `commit.gpgsign` is on,
|
|
54
|
+
git re-signs them as part of the rebase; the script doesn't add anything
|
|
55
|
+
beyond leaving that setting alone.
|
|
@@ -0,0 +1,248 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""Safe git push: fetch, analyze the graph, integrate only when it can't
|
|
3
|
+
create an accidental self-merge or flatten intentional cross-branch merges,
|
|
4
|
+
then push. See ../SKILL.md for the decision summary.
|
|
5
|
+
|
|
6
|
+
Exit code 0 = pushed or already in sync. Exit code 1 = stopped (see stderr).
|
|
7
|
+
Never uses: git pull, git merge <remote-ref> (plain), git push --force*/--all/
|
|
8
|
+
--mirror/--tags/--no-verify, git reset --hard on anything but our own backup
|
|
9
|
+
ref, git stash/clean/commit, or a branch/checkout switch.
|
|
10
|
+
"""
|
|
11
|
+
import argparse
|
|
12
|
+
import re
|
|
13
|
+
import subprocess
|
|
14
|
+
import sys
|
|
15
|
+
import time
|
|
16
|
+
|
|
17
|
+
MAX_PUSH_RETRIES = 2
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def run(cmd, check=True):
|
|
21
|
+
p = subprocess.run(cmd, capture_output=True, text=True)
|
|
22
|
+
if check and p.returncode != 0:
|
|
23
|
+
raise RuntimeError(f"$ {' '.join(cmd)}\n{p.stderr.strip()}")
|
|
24
|
+
return p
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def run_ok(cmd):
|
|
28
|
+
return subprocess.run(cmd, capture_output=True, text=True).returncode == 0
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def stop(msg):
|
|
32
|
+
print(f"Safe push aborted: {msg}", file=sys.stderr)
|
|
33
|
+
print("No destructive git operation was performed; no shared history was rewritten.", file=sys.stderr)
|
|
34
|
+
sys.exit(1)
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def git_dir():
|
|
38
|
+
return run(["git", "rev-parse", "--git-dir"]).stdout.strip()
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def check_preconditions():
|
|
42
|
+
if not run_ok(["git", "rev-parse", "--is-inside-work-tree"]):
|
|
43
|
+
stop("not inside a git work tree.")
|
|
44
|
+
|
|
45
|
+
gd = git_dir()
|
|
46
|
+
import os
|
|
47
|
+
in_progress = [
|
|
48
|
+
name for name, path in {
|
|
49
|
+
"rebase (merge)": f"{gd}/rebase-merge",
|
|
50
|
+
"rebase (apply)": f"{gd}/rebase-apply",
|
|
51
|
+
"merge": f"{gd}/MERGE_HEAD",
|
|
52
|
+
"cherry-pick": f"{gd}/CHERRY_PICK_HEAD",
|
|
53
|
+
"revert": f"{gd}/REVERT_HEAD",
|
|
54
|
+
"bisect": f"{gd}/BISECT_LOG",
|
|
55
|
+
}.items() if os.path.exists(path)
|
|
56
|
+
]
|
|
57
|
+
if in_progress:
|
|
58
|
+
stop(f"a {in_progress[0]} is already in progress. Resolve or abort it first.")
|
|
59
|
+
|
|
60
|
+
branch_p = subprocess.run(["git", "symbolic-ref", "--quiet", "--short", "HEAD"],
|
|
61
|
+
capture_output=True, text=True)
|
|
62
|
+
if branch_p.returncode != 0:
|
|
63
|
+
stop("HEAD is detached; cannot safely guess which branch to push.")
|
|
64
|
+
branch = branch_p.stdout.strip()
|
|
65
|
+
|
|
66
|
+
dirty = run(["git", "status", "--porcelain"]).stdout.strip()
|
|
67
|
+
if dirty:
|
|
68
|
+
print("Note: working tree has uncommitted changes; they are never included "
|
|
69
|
+
"in a push and are left untouched.", file=sys.stderr)
|
|
70
|
+
|
|
71
|
+
if run(["git", "rev-parse", "--is-shallow-repository"]).stdout.strip() == "true":
|
|
72
|
+
print("Note: shallow repository; ancestry checks below may be incomplete.", file=sys.stderr)
|
|
73
|
+
|
|
74
|
+
return branch
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
def resolve_remote(branch, remote_override):
|
|
78
|
+
if remote_override:
|
|
79
|
+
return remote_override
|
|
80
|
+
remote = run(["git", "config", "--get", f"branch.{branch}.remote"], check=False).stdout.strip()
|
|
81
|
+
if remote:
|
|
82
|
+
return remote
|
|
83
|
+
remotes = [r for r in run(["git", "remote"]).stdout.splitlines() if r.strip()]
|
|
84
|
+
if len(remotes) == 1:
|
|
85
|
+
return remotes[0]
|
|
86
|
+
if not remotes:
|
|
87
|
+
stop("no git remote configured.")
|
|
88
|
+
stop(f"branch '{branch}' has no upstream and multiple remotes exist ({', '.join(remotes)}); "
|
|
89
|
+
f"rerun with --remote <name>.")
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
def fetch_branch(remote, branch):
|
|
93
|
+
p = subprocess.run(
|
|
94
|
+
["git", "fetch", remote, f"refs/heads/{branch}:refs/remotes/{remote}/{branch}"],
|
|
95
|
+
capture_output=True, text=True,
|
|
96
|
+
)
|
|
97
|
+
if p.returncode == 0:
|
|
98
|
+
return True # remote branch exists and was fetched
|
|
99
|
+
if "couldn't find remote ref" in p.stderr or "not found" in p.stderr.lower():
|
|
100
|
+
return False # remote branch doesn't exist yet -> first push
|
|
101
|
+
stop(f"fetch from '{remote}' failed:\n{p.stderr.strip()}")
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
def is_ancestor(maybe_ancestor, ref):
|
|
105
|
+
return run_ok(["git", "merge-base", "--is-ancestor", maybe_ancestor, ref])
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
SELF_MERGE_RE = re.compile(
|
|
109
|
+
r"^Merge (remote-tracking )?branch '([^']*/)?{branch}'(\s+of\s+\S+)?\s+into\s+{branch}$"
|
|
110
|
+
)
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
def find_suspicious_self_merge(remote_ref, branch):
|
|
114
|
+
log = run(["git", "log", "--merges", "--format=%H\t%s", f"{remote_ref}..HEAD"]).stdout
|
|
115
|
+
pat = re.compile(SELF_MERGE_RE.pattern.format(branch=re.escape(branch)))
|
|
116
|
+
for line in log.splitlines():
|
|
117
|
+
sha, _, subject = line.partition("\t")
|
|
118
|
+
if pat.match(subject.strip()):
|
|
119
|
+
return sha, subject.strip()
|
|
120
|
+
return None
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
def push(remote, branch, set_upstream):
|
|
124
|
+
cmd = ["git", "-c", "push.followTags=false", "push"]
|
|
125
|
+
if set_upstream:
|
|
126
|
+
cmd.append("--set-upstream")
|
|
127
|
+
cmd += [remote, f"HEAD:refs/heads/{branch}"]
|
|
128
|
+
return subprocess.run(cmd, capture_output=True, text=True)
|
|
129
|
+
|
|
130
|
+
|
|
131
|
+
def verify_pushed(remote, branch):
|
|
132
|
+
local_sha = run(["git", "rev-parse", "HEAD"]).stdout.strip()
|
|
133
|
+
remote_sha = run(["git", "ls-remote", remote, f"refs/heads/{branch}"]).stdout.split()[0]
|
|
134
|
+
if local_sha != remote_sha:
|
|
135
|
+
stop(f"push verification failed: remote is {remote_sha[:10]}, local HEAD is {local_sha[:10]}.")
|
|
136
|
+
return local_sha
|
|
137
|
+
|
|
138
|
+
|
|
139
|
+
def attempt(branch, remote, has_upstream):
|
|
140
|
+
remote_exists = fetch_branch(remote, branch)
|
|
141
|
+
|
|
142
|
+
if not remote_exists:
|
|
143
|
+
p = push(remote, branch, set_upstream=True)
|
|
144
|
+
if p.returncode != 0:
|
|
145
|
+
handle_push_failure(p.stderr)
|
|
146
|
+
sha = verify_pushed(remote, branch)
|
|
147
|
+
print(f"Safe push completed (first push).\nBranch: {branch}\nRemote: {remote}/{branch}\n"
|
|
148
|
+
f"Remote HEAD: {sha}")
|
|
149
|
+
return
|
|
150
|
+
|
|
151
|
+
remote_ref = f"{remote}/{branch}"
|
|
152
|
+
local_sha = run(["git", "rev-parse", "HEAD"]).stdout.strip()
|
|
153
|
+
remote_sha = run(["git", "rev-parse", remote_ref]).stdout.strip()
|
|
154
|
+
|
|
155
|
+
if local_sha == remote_sha:
|
|
156
|
+
print(f"Already synchronized with {remote_ref}. Nothing to push.")
|
|
157
|
+
return
|
|
158
|
+
|
|
159
|
+
if is_ancestor(remote_ref, "HEAD"):
|
|
160
|
+
pass # local ahead -> fall through to push directly
|
|
161
|
+
elif is_ancestor("HEAD", remote_ref):
|
|
162
|
+
run(["git", "merge", "--ff-only", remote_ref])
|
|
163
|
+
print(f"Fast-forwarded to {remote_ref}. Nothing to push.")
|
|
164
|
+
return
|
|
165
|
+
else:
|
|
166
|
+
integrate_diverged(remote_ref, branch)
|
|
167
|
+
|
|
168
|
+
p = push(remote, branch, set_upstream=not has_upstream)
|
|
169
|
+
if p.returncode != 0:
|
|
170
|
+
handle_push_failure(p.stderr)
|
|
171
|
+
sha = verify_pushed(remote, branch)
|
|
172
|
+
print(f"Safe push completed.\nBranch: {branch}\nRemote: {remote_ref}\nRemote HEAD: {sha}")
|
|
173
|
+
|
|
174
|
+
|
|
175
|
+
def integrate_diverged(remote_ref, branch):
|
|
176
|
+
suspicious = find_suspicious_self_merge(remote_ref, branch)
|
|
177
|
+
if suspicious:
|
|
178
|
+
sha, subject = suspicious
|
|
179
|
+
stop(f"local history contains a likely self-merge ({sha[:10]} \"{subject}\") "
|
|
180
|
+
f"not yet pushed. Not touching it automatically — review it by hand.")
|
|
181
|
+
|
|
182
|
+
old_head = run(["git", "rev-parse", "HEAD"]).stdout.strip()
|
|
183
|
+
backup_ref = f"refs/backup/safe-push/{branch}-{int(time.time())}"
|
|
184
|
+
run(["git", "update-ref", backup_ref, old_head])
|
|
185
|
+
|
|
186
|
+
old_merge_count = len(run(["git", "log", "--merges", "--format=%H", f"{remote_ref}..HEAD"]).stdout.splitlines())
|
|
187
|
+
|
|
188
|
+
p = subprocess.run(["git", "rebase", "--rebase-merges=no-rebase-cousins", remote_ref],
|
|
189
|
+
capture_output=True, text=True)
|
|
190
|
+
if p.returncode != 0:
|
|
191
|
+
conflicts = run(["git", "diff", "--name-only", "--diff-filter=U"], check=False).stdout.strip()
|
|
192
|
+
run(["git", "rebase", "--abort"], check=False)
|
|
193
|
+
stop("rebase hit conflicts and was aborted; branch restored to its pre-rebase state.\n"
|
|
194
|
+
f"Conflicting files:\n{conflicts}\nResolve manually, then rerun.")
|
|
195
|
+
|
|
196
|
+
ok = is_ancestor(remote_ref, "HEAD") and not run_ok(["test", "-d", f"{git_dir()}/rebase-merge"])
|
|
197
|
+
new_merge_count = len(run(["git", "log", "--merges", "--format=%H", f"{remote_ref}..HEAD"]).stdout.splitlines())
|
|
198
|
+
if not ok or new_merge_count != old_merge_count:
|
|
199
|
+
run(["git", "reset", "--hard", backup_ref])
|
|
200
|
+
stop("post-rebase verification failed (remote not an ancestor, or merge topology changed); "
|
|
201
|
+
f"restored from backup ref {backup_ref}.")
|
|
202
|
+
|
|
203
|
+
run(["git", "update-ref", "-d", backup_ref])
|
|
204
|
+
|
|
205
|
+
|
|
206
|
+
PROTECTED_MARKERS = ("protected branch", "permission denied", "not allowed to push")
|
|
207
|
+
|
|
208
|
+
|
|
209
|
+
def handle_push_failure(stderr):
|
|
210
|
+
low = stderr.lower()
|
|
211
|
+
if any(m in low for m in PROTECTED_MARKERS):
|
|
212
|
+
stop(f"remote rejected the push (protected branch):\n{stderr.strip()}\n"
|
|
213
|
+
"Use the repository's normal PR/MR workflow instead.")
|
|
214
|
+
if "non-fast-forward" in low or "fetch first" in low or "rejected" in low:
|
|
215
|
+
raise Retry(stderr)
|
|
216
|
+
stop(f"push failed:\n{stderr.strip()}")
|
|
217
|
+
|
|
218
|
+
|
|
219
|
+
class Retry(Exception):
|
|
220
|
+
pass
|
|
221
|
+
|
|
222
|
+
|
|
223
|
+
def main():
|
|
224
|
+
ap = argparse.ArgumentParser(description=__doc__)
|
|
225
|
+
ap.add_argument("--remote", help="Override the remote to use (needed when ambiguous).")
|
|
226
|
+
args = ap.parse_args()
|
|
227
|
+
|
|
228
|
+
branch = check_preconditions()
|
|
229
|
+
has_upstream = bool(run(["git", "config", "--get", f"branch.{branch}.merge"], check=False).stdout.strip())
|
|
230
|
+
remote = resolve_remote(branch, args.remote)
|
|
231
|
+
|
|
232
|
+
for attempt_no in range(MAX_PUSH_RETRIES + 1):
|
|
233
|
+
try:
|
|
234
|
+
attempt(branch, remote, has_upstream)
|
|
235
|
+
return
|
|
236
|
+
except Retry as e:
|
|
237
|
+
if attempt_no == MAX_PUSH_RETRIES:
|
|
238
|
+
stop(f"push kept getting rejected (remote changed concurrently) after "
|
|
239
|
+
f"{MAX_PUSH_RETRIES + 1} attempts:\n{e}")
|
|
240
|
+
print(f"Push rejected (remote changed); re-fetching and re-analyzing "
|
|
241
|
+
f"(attempt {attempt_no + 2}/{MAX_PUSH_RETRIES + 1})...", file=sys.stderr)
|
|
242
|
+
|
|
243
|
+
|
|
244
|
+
if __name__ == "__main__":
|
|
245
|
+
try:
|
|
246
|
+
main()
|
|
247
|
+
except RuntimeError as e:
|
|
248
|
+
stop(str(e))
|