diff options
Diffstat (limited to 'gitctl-helper')
| -rwxr-xr-x | gitctl-helper | 148 |
1 files changed, 148 insertions, 0 deletions
diff --git a/gitctl-helper b/gitctl-helper index 6c6dbc3..f71763e 100755 --- a/gitctl-helper +++ b/gitctl-helper @@ -26,6 +26,10 @@ SYNC_SCRIPT = "/usr/local/bin/sync-cgit-descs.py" # owns /etc/cgitrc (the file) but cannot create new files in /etc. Must be a # git-writable directory. BACKUP_DIR = "/var/lib/gitolite3/cgitrc-backups" +# Deleted bare repos are MOVED here, not removed, so delete is reversible. Must +# be OUTSIDE REPO_BASE (so gitolite and cgit never see it) and on the same +# filesystem as REPO_BASE for the move to be atomic. git-writable. +TRASH_DIR = "/var/lib/gitolite3/trash" DEFAULT_OWNER = "by Your Name" BANNER = "# ---------- {name} ------------#" @@ -158,6 +162,38 @@ def insert_repo_block(lines, section, block): return lines[:insert_at] + payload + lines[insert_at:] +REPO_KV_RE = re.compile(r"^repo\.[a-z]+=") + + +def remove_repo_block(lines, url): + """Inverse of insert_repo_block: drop the repo.url=<url> block (its url/path/ + owner lines) plus the one blank line insert_repo_block put before it, so the + section's spacing is left as if the repo was never added. Idempotent: if the + url is absent, return lines unchanged. Section banners and headers are never + touched, so an emptied section keeps its header.""" + start = None + for i, line in enumerate(lines): + m = URL_LINE_RE.match(line.rstrip("\n")) + if m and m.group(1) == url: + start = i + break + if start is None: + return list(lines) + # block body = this repo.url= and the contiguous repo.*= lines under it. + end = start + 1 + while end < len(lines) and REPO_KV_RE.match(lines[end].rstrip("\n")) \ + and not URL_LINE_RE.match(lines[end].rstrip("\n")): + end += 1 + # also swallow one trailing blank line (the spacer insert added after the + # block) and one leading blank line (the spacer before it), but never more, + # so unrelated spacing is preserved. + if end < len(lines) and lines[end].strip() == "": + end += 1 + if start > 0 and lines[start - 1].strip() == "": + start -= 1 + return lines[:start] + lines[end:] + + def write_cgitrc_lines(path, lines, backup_dir=None): """Back up then write `path` in place. Not atomic: the helper runs as the git user, which owns /etc/cgitrc but cannot create temp inodes in /etc, so @@ -224,6 +260,49 @@ def do_set_desc(desc_path, desc, run_sync, url=None): return 0 +def trash_repo(url, repo_base, trash_dir): + """Move the bare repo out of repo_base into trash_dir under a timestamped + name, instead of deleting it, so the operation is reversible. Returns the + destination path, or None if the repo was already gone (idempotent). Refuses + to move anything whose resolved path is not directly under repo_base.""" + src = os.path.join(repo_base, url + ".git") + # defense in depth: validate_url already blocks .. and /, but re-check the + # resolved path really sits one level under repo_base before moving. + if os.path.dirname(os.path.realpath(src)) != os.path.realpath(repo_base): + die(f"error: refusing to trash {url!r}: resolved path escapes repo base") + if not os.path.isdir(src): + return None + os.makedirs(trash_dir, exist_ok=True) + ts = datetime.datetime.now().strftime("%Y%m%d-%H%M%S") + dst = os.path.join(trash_dir, f"{url}.git.{ts}") + shutil.move(src, dst) + # leave a breadcrumb for review / a future prune tool. + with open(os.path.join(dst, ".trashinfo"), "w") as f: + f.write(f"origin={src}\ndeleted={ts}\nby=gitctl\n") + return dst + + +def do_delete_repo(cgitrc, url, repo_base, trash_dir, run_sync, backup_dir=None): + """Remove a repo from cgit (block) and move its bare repo to the trash. Each + step is idempotent and reports what it did. The gitolite stanza is NOT + touched here: the client removes and pushes that separately (phase 1).""" + with open(cgitrc) as f: + lines = f.readlines() + new = remove_repo_block(lines, url) + if new != lines: + write_cgitrc_lines(cgitrc, new, backup_dir) + print(f"removed cgit block for {url}") + run_sync() + else: + print(f"no cgit block for {url}, skipping") + dst = trash_repo(url, repo_base, trash_dir) + if dst: + print(f"moved bare repo to {dst}") + else: + print(f"no bare repo {url}.git on disk, skipping") + return 0 + + def _self_test(): assert validate_url("publisher") == "publisher" assert validate_url("a.b_c-1") == "a.b_c-1" @@ -313,6 +392,62 @@ def _self_test(): except KeyError: pass + # remove_repo_block is the inverse of insert: insert then remove == original, + # for the first section and the last. This is the strongest guard. + assert remove_repo_block(insert_repo_block(fixture, "Generic Projects", block), + "publisher") == fixture + assert remove_repo_block(insert_repo_block(fixture, "SlackBuilds", block), + "publisher") == fixture + # remove an EXISTING repo from the fixture (middle of the file): the block + # goes, the SlackBuilds banner/header and the other repo stay. + minus = remove_repo_block(fixture, "cad-projects") + mtext = "".join(minus) + assert "repo.url=cad-projects" not in mtext + assert "section=Generic Projects" in mtext # emptied section keeps header + assert "repo.url=my-slackbuilds" in mtext # sibling untouched + assert "# ---------- SlackBuilds" in mtext + # last repo in the file + assert "repo.url=my-slackbuilds" not in "".join(remove_repo_block(fixture, "my-slackbuilds")) + # missing url: unchanged, idempotent + assert remove_repo_block(fixture, "ghost") == fixture + assert remove_repo_block(remove_repo_block(fixture, "cad-projects"), + "cad-projects") == remove_repo_block(fixture, "cad-projects") + + # trash_repo: moves the bare repo into a trash dir, leaves a .trashinfo, and + # is idempotent (second call finds nothing). + import tempfile + base = tempfile.mkdtemp(); trash = tempfile.mkdtemp() + os.makedirs(os.path.join(base, "doomed.git")) + dst = trash_repo("doomed", base, trash) + assert dst and os.path.isdir(dst) and not os.path.exists(os.path.join(base, "doomed.git")) + assert os.path.exists(os.path.join(dst, ".trashinfo")) + assert trash_repo("doomed", base, trash) is None # already gone + + # do_delete_repo: removes the cgit block, moves the repo, runs sync once. + dd = tempfile.mkdtemp() + cg = os.path.join(dd, "cgitrc") + open(cg, "w").writelines([ + "# ---------- Generic ------------#\n", + "section=Generic\n", + "repo.url=gone\n", + "repo.path=/x/gone.git\n", + "repo.owner=me\n", + "\n", + ]) + rbase = tempfile.mkdtemp(); rtrash = tempfile.mkdtemp() + os.makedirs(os.path.join(rbase, "gone.git")) + dcalls = [] + rc = do_delete_repo(cg, "gone", rbase, rtrash, run_sync=lambda: dcalls.append(1), backup_dir=dd) + assert rc == 0 + assert "repo.url=gone" not in open(cg).read() + assert "section=Generic" in open(cg).read() # header survives + assert not os.path.exists(os.path.join(rbase, "gone.git")) + assert dcalls == [1] + # idempotent: second delete is all skips, no sync, still rc 0 + dcalls.clear() + rc = do_delete_repo(cg, "gone", rbase, rtrash, run_sync=lambda: dcalls.append(1), backup_dir=dd) + assert rc == 0 and dcalls == [] + import tempfile as _tf d = _tf.mkdtemp() p = os.path.join(d, "cgitrc") @@ -462,6 +597,16 @@ def main(): "push until gitolite has built the bare repo.") re_.add_argument("--url", required=True, help="repo name to test for (as <url>.git)") + dr = sub.add_parser("delete-repo", + help="remove a repo from cgit and move its bare repo to the trash", + description="Inverse of add-repo plus the bare repo: remove the " + "repo's cgit block (and sync), then MOVE its bare repo " + "out of the repository base into the trash dir under a " + "timestamped name (not rm, so it is reversible). The " + "gitolite stanza is removed by the client, not here. " + "Idempotent: a piece already gone is skipped, not an error.") + dr.add_argument("--url", required=True, help="repo name to delete (the bare repo is <url>.git)") + sub.add_parser("list-repos", help="list every bare repo, tagged public/private", description="Scan the repository base for all bare repos (the full " @@ -498,6 +643,9 @@ def main(): for name, pub, section, desc in list_repos(REPO_BASE, cgit): print(f"{name}\t{'PUB' if pub else 'PRIV'}\t{section}\t{desc}") return 0 + if a.verb == "delete-repo": + validate_url(a.url) + return do_delete_repo(CGITRC, a.url, REPO_BASE, TRASH_DIR, run_sync, BACKUP_DIR) return 1 |
