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 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))