aboutsummaryrefslogtreecommitdiffstats
path: root/gitctl-helper
diff options
context:
space:
mode:
Diffstat (limited to 'gitctl-helper')
-rwxr-xr-xgitctl-helper148
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