aboutsummaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
authorDanilo M. <danix@danix.xyz>2026-06-29 18:42:35 +0200
committerDanilo M. <danix@danix.xyz>2026-06-29 18:42:35 +0200
commit8fe39342512aca391ea9534e1cec8bc4b805f100 (patch)
tree9c0f5678bb9a84c45fb059f4a2e35d9aa62e0305
parent815b655a659a2160a812291ad72676a0e41de1db (diff)
downloadgitctl-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-xgitctl57
1 files changed, 50 insertions, 7 deletions
diff --git a/gitctl b/gitctl
index fe04dae..7293cf1 100755
--- a/gitctl
+++ b/gitctl
@@ -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 ...")