diff options
| author | Danilo M. <danix@danix.xyz> | 2026-06-29 18:42:35 +0200 |
|---|---|---|
| committer | Danilo M. <danix@danix.xyz> | 2026-06-29 18:42:35 +0200 |
| commit | 8fe39342512aca391ea9534e1cec8bc4b805f100 (patch) | |
| tree | 9c0f5678bb9a84c45fb059f4a2e35d9aa62e0305 | |
| parent | 815b655a659a2160a812291ad72676a0e41de1db (diff) | |
| download | gitctl-8fe39342512aca391ea9534e1cec8bc4b805f100.tar.gz gitctl-8fe39342512aca391ea9534e1cec8bc4b805f100.zip | |
fix: repo create recovers from a crashed/interrupted run
Phase 1 was driven by text presence: stanza_exists read the working
gitolite.conf, so a run that died at the y/N prompt (EOFError with no
TTY) left the stanza uncommitted, and the next run reported "already
present", skipped the commit+push, then timed out waiting for a bare
repo gitolite was never told to build.
Drive Phase 1 by git state instead: ensure the stanza is in the working
file (idempotent), commit it if the working tree differs from HEAD, and
push if HEAD is ahead of upstream. This recovers both a leftover
uncommitted edit and a committed-but-unpushed stanza, and is a no-op
when already in sync. Confirm now happens before the commit, and abort
reverts to HEAD. Self-test pins the three git states with a throwaway
repo and bare upstream.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
| -rwxr-xr-x | gitctl | 57 |
1 files changed, 50 insertions, 7 deletions
@@ -181,6 +181,26 @@ def _self_test(): assert pr.parse_args(["repo", "create", "r", "--section", "S"]).private is False pa = pr.parse_args(["repo", "create", "r", "--private"]) assert pa.private is True and pa.section is None + # Phase 1 recovery: the create-state detection is driven by git state, not by + # text presence, so a stanza left uncommitted by a crashed run is detected as + # work-to-do (not "already present"), and a committed-but-unpushed stanza + # still triggers a push. Pin both with a real throwaway repo + bare upstream. + import tempfile, subprocess as sp + def _g(d, *a): return sp.run(["git", "-C", d, *a], capture_output=True, text=True) + with tempfile.TemporaryDirectory() as up, tempfile.TemporaryDirectory() as wt: + sp.run(["git", "init", "--bare", up], capture_output=True) + _g(wt, "init"); _g(wt, "config", "user.email", "t@t"); _g(wt, "config", "user.name", "t") + _g(wt, "config", "commit.gpgsign", "false") + open(os.path.join(wt, "f"), "w").write("base\n") + _g(wt, "add", "f"); _g(wt, "commit", "-m", "base") + _g(wt, "remote", "add", "origin", up); _g(wt, "push", "-u", "origin", "HEAD") + clean = lambda: _g(wt, "diff", "--quiet", "HEAD", "--", "f").returncode != 0 + ahead = lambda: _g(wt, "rev-list", "--count", "@{u}..HEAD").stdout.strip() + assert clean() is False and ahead() == "0" # in sync: skip + open(os.path.join(wt, "f"), "w").write("base\nedit\n") # crashed: uncommitted edit + assert clean() is True # detected as work-to-do + _g(wt, "add", "f"); _g(wt, "commit", "-m", "edit") # committed, not pushed + assert clean() is False and ahead() == "1" # still needs push print("self-test OK") @@ -241,27 +261,50 @@ def cmd_create(cfg, a): owner = a.owner or cfg["default_owner"] conf_path = os.path.join(cfg["admin_clone_path"], "conf", "gitolite.conf") - # PHASE 1: local gitolite-admin edit + # PHASE 1: local gitolite-admin edit. Driven by git STATE, not text presence, + # so a run that died mid-flow (e.g. EOFError at the prompt left the stanza in + # the working tree but uncommitted) recovers on re-run instead of skipping + # the commit/push and then timing out waiting for a bare repo gitolite was + # never told to build. try: with open(conf_path) as f: conf = f.read() except OSError as e: sys.exit(f"error: cannot read {conf_path}: {e.strerror}") - if stanza_exists(conf, a.name): - print(f"stanza for {a.name} already present, skipping conf edit") - else: + + # Ensure the stanza is in the working file (idempotent). + new_conf = add_stanza(conf, a.name, cfg["rw_user"]) + if new_conf != conf: with open(conf_path, "w") as f: - f.write(add_stanza(conf, a.name, cfg["rw_user"])) - diff = git(cfg, "diff", "--", "conf/gitolite.conf", capture=True).stdout + f.write(new_conf) + + # Does the working tree differ from HEAD for this file? (Covers both a fresh + # edit and a leftover uncommitted one.) + uncommitted = git(cfg, "diff", "--quiet", "HEAD", "--", "conf/gitolite.conf", + check=False).returncode != 0 + + if uncommitted: + diff = git(cfg, "diff", "HEAD", "--", "conf/gitolite.conf", capture=True).stdout print(diff) # always shown, even with -y, for visibility if a.yes: print("-y: auto-confirmed (the push may still prompt for the SSH key)") elif not _confirm("Commit and push this gitolite.conf change?"): - git(cfg, "checkout", "--", "conf/gitolite.conf") + # ponytail: reverts the whole file to HEAD, not just our stanza. Fine + # because this clone is gitctl-managed only; revisit if it ever holds + # hand edits. + git(cfg, "checkout", "HEAD", "--", "conf/gitolite.conf") sys.exit("aborted: reverted conf/gitolite.conf, no changes pushed") git(cfg, "add", "conf/gitolite.conf") git(cfg, "commit", "-m", f"gitctl: add repo {a.name}") + + # Push if HEAD is ahead of upstream (covers committed-but-unpushed from a + # prior crash too). No-op when already in sync. + unpushed = git(cfg, "rev-list", "--count", f"@{{u}}..HEAD", + capture=True, check=False) + if unpushed.returncode != 0 or unpushed.stdout.strip() != "0": git(cfg, "push", "origin", cfg["admin_branch"]) + elif not uncommitted: + print(f"stanza for {a.name} already present and pushed, skipping conf edit") # wait for gitolite to build the bare repo print(f"waiting for bare repo {a.name}.git to appear ...") |
