aboutsummaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
authorDanilo M. <danix@danix.xyz>2026-06-24 10:19:30 +0200
committerDanilo M. <danix@danix.xyz>2026-06-24 10:19:30 +0200
commit02a5ae2adc2df39fc78fd7e29904ff473e9266a0 (patch)
treebbe270a6302526476147430ce7614e17ac7cc1df
parent345968271dec38b33a32649d8cfb767c2806330d (diff)
downloadgitctl-02a5ae2adc2df39fc78fd7e29904ff473e9266a0.tar.gz
gitctl-02a5ae2adc2df39fc78fd7e29904ff473e9266a0.zip
feat: repo delete - remove repo, move bare repo to trash
gitctl repo delete <name> removes a repo from all three places it lives, the inverse of repo create: - gitolite stanza via remove_stanza (inverse of add_stanza), committed and pushed FIRST so gitolite stops serving the repo before its bytes move. - cgit block via the new delete-repo helper verb (remove_repo_block, inverse of insert_repo_block), backed up and synced. - the bare repo is MOVED to TRASH_DIR (/var/lib/gitolite3/trash, timestamped) with a .trashinfo breadcrumb, not rm, so it is recoverable. A realpath check keeps the move one level under REPO_BASE. Always confirms; the global -y is deliberately not honored (cmd_delete never reads a.yes). Idempotent: a piece already gone is skipped. Partial failure is resumable by re-running, like create. Both inverse functions are pinned by add/insert-then-remove round-trip self-tests plus middle/last/missing cases; trash_repo and do_delete_repo run on temp dirs. Verified live: create then delete round-trips against the real gitolite.conf, bare repo lands in the trash dir intact. Helper changed: redeploy gitctl-helper to the server. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
-rw-r--r--README.md7
-rw-r--r--TODO.md64
-rw-r--r--completions/gitctl.bash2
-rwxr-xr-xgitctl95
-rwxr-xr-xgitctl-helper148
-rw-r--r--skills/gitctl/SKILL.md10
6 files changed, 279 insertions, 47 deletions
diff --git a/README.md b/README.md
index 43f852b..cc25bb5 100644
--- a/README.md
+++ b/README.md
@@ -172,6 +172,7 @@ gitctl repo create publisher --section SlackBuilds --desc "..."
gitctl repo list
gitctl repo desc publisher "a new description"
gitctl repo add-remote publisher --remote-name origin
+gitctl repo delete publisher
gitctl sync
```
@@ -197,6 +198,12 @@ for the SSH key, so it is not fully unattended.
private repos show too. A `VIS` column marks each `PUB` (present in cgitrc) or
`PRIV`; on a terminal public rows are green and private dim. The description
comes from each repo's `description` file, truncated to 60 chars.
+- `repo delete` removes a repo from all three places it lives: the gitolite
+ stanza (committed and pushed first), the cgit block, and the bare repo, which
+ is MOVED to the server's trash dir (`/var/lib/gitolite3/trash`,
+ timestamped) rather than removed, so it is recoverable. It ALWAYS asks for
+ confirmation; the global `-y` does not bypass it. Idempotent: a piece already
+ gone is skipped. Prune the trash dir by hand (or a later tool) when sure.
- `--desc` never writes cgit `repo.desc=` directly. It writes the bare repo's
`description` file and runs sync, the single writer.
- Every operation is idempotent and safe to re-run.
diff --git a/TODO.md b/TODO.md
index c457702..7889779 100644
--- a/TODO.md
+++ b/TODO.md
@@ -4,51 +4,31 @@ Planned features. Each note captures the non-obvious bits so the work does not
re-discover them. Keep the hard rules from CLAUDE.md (stdlib only, single-file
scripts, single-writer repo.desc, idempotent, no em dashes, work on master).
-## 1. delete repo
+## 1. delete repo -- DONE
-`gitctl repo delete <name>` - remove a repo from all three places it lives.
+`gitctl repo delete <name>` is implemented. It removes a repo from all three
+places it lives, the inverse of `repo create`:
+- gitolite stanza -> client `remove_stanza` (inverse of `add_stanza`), commit +
+ push (needs the SSH key / card, like create). PUSHED FIRST so gitolite stops
+ serving the repo before its bytes move.
+- cgit block -> helper verb `delete-repo`, `remove_repo_block` (inverse of
+ `insert_repo_block`); deletes the repo.url/path/owner block + its spacer
+ blanks, leaves the section header. Backed up by write_cgitrc_lines, sync after.
+- bare repo -> `trash_repo` MOVES it to `TRASH_DIR` (`/var/lib/gitolite3/trash`,
+ outside REPO_BASE, timestamped name) instead of rm, with a `.trashinfo`
+ breadcrumb. shutil.move, with a realpath check that the source is one level
+ under REPO_BASE (defense in depth on top of validate_url).
-Touches three writers, so it is the inverse of `repo create`:
-- gitolite.conf stanza -> client edits the admin clone, pushes (needs the SSH
- key / card, like create).
-- cgit block in /etc/cgitrc -> new helper verb, the inverse of
- `insert_repo_block`: find the repo's `repo.url=` line and delete its block up
- to the next `repo.url=`, `section=`, banner, or EOF. Back up first
- (write_cgitrc_lines already does). Run sync after.
-- the bare repo on disk -> gitolite does NOT delete it when the stanza goes
- away. The helper does NOT rm it. Instead it MOVES the repo to a trash dir
- (see below), so deletion is reversible and you review before anything is
- really gone.
+The helper exposes ONE verb (`delete-repo`) doing cgit-block removal + move +
+.trashinfo. Always confirms (the global `-y` is deliberately NOT read by
+cmd_delete). Idempotent: a piece already gone is skipped, not an error. Partial
+failure (push ok, helper fails, or commit ok but push fails) is resumable by
+re-running, same as create. Self-tests: remove_repo_block and remove_stanza both
+pinned by an insert/add-then-remove round-trip plus middle/last/missing cases;
+trash_repo and do_delete_repo exercised on temp dirs.
-Trash dir (the chosen design, not rm -rf):
-- New constant `TRASH_DIR`, OUTSIDE `REPO_BASE` so gitolite and cgit never see
- it (e.g. `/var/lib/gitolite3/trash`). git-writable.
-- Delete = `mv REPO_BASE/<name>.git TRASH_DIR/<name>.git.<timestamp>`. Atomic
- within one filesystem; if TRASH_DIR is on a different mount, fall back to
- copy+remove or just require same-fs.
-- validate_url first; double-check the resolved source path is under REPO_BASE
- before moving (defense in depth, even though validate_url blocks `..`/`/`).
-- Leave a note for the user: print where it went and that it needs manual
- review/removal. Optionally drop a short TRASH_DIR/README or a per-entry
- `.trashinfo` (origin path, timestamp, who) so a later cleanup tool has
- context.
-- FUTURE: a helper verb like `gc-trash --older-than <days>` that prunes
- TRASH_DIR entries past a retention window, runnable from cron. The move-based
- design makes this trivial later; do not build it now (YAGNI), just keep
- TRASH_DIR layout cron-friendly (timestamp in the name).
-
-Design notes:
-- DESTRUCTIVE (even as a move - it disappears from the live tree). Always
- confirm, show what will be removed and where it goes. The `-y` flag from
- item 3 must NOT bypass the delete confirmation (or gate it behind a separate,
- louder opt-in). Deleting is not the same as auto-confirming an additive diff.
-- Idempotent-ish: if the repo is already gone from a given place, skip that
- step and report, do not error.
-- Recoverable: the bare repo sits in TRASH_DIR until you remove it; the cgitrc
- backup covers the cgit block. Nothing is irreversibly destroyed by gitctl.
-- Order: remove cgit block + move bare repo via helper first, then the gitolite
- stanza push? Or stanza first? Decide and document - partial failure (push
- fails after the move) should leave a clear, resumable state like create does.
+FUTURE (not built, YAGNI): a `gc-trash --older-than <days>` prune verb for cron.
+The timestamped TRASH_DIR layout already supports it.
## 2. list repos -- DONE
diff --git a/completions/gitctl.bash b/completions/gitctl.bash
index 3d072fe..929346d 100644
--- a/completions/gitctl.bash
+++ b/completions/gitctl.bash
@@ -36,7 +36,7 @@ _gitctl() {
2)
case $group in
sections) COMPREPLY=($(compgen -W "list add" -- "$cur")) ;;
- repo) COMPREPLY=($(compgen -W "create list desc add-remote" -- "$cur")) ;;
+ repo) COMPREPLY=($(compgen -W "create list desc add-remote delete" -- "$cur")) ;;
esac
return ;;
esac
diff --git a/gitctl b/gitctl
index df0bb16..45f54bb 100755
--- a/gitctl
+++ b/gitctl
@@ -84,6 +84,31 @@ def add_stanza(conf, name, rw_user):
return conf + sep + f"\nrepo {name}\n RW+ = {rw_user}\n"
+def remove_stanza(conf, name):
+ """Inverse of add_stanza: drop the `repo <name>` block (its repo line plus
+ the indented rule lines under it) and the blank line that preceded it.
+ Idempotent: if the stanza is absent, return conf unchanged."""
+ lines = conf.splitlines(keepends=True)
+ start = None
+ for i, line in enumerate(lines):
+ if re.match(rf"^repo[ \t]+{re.escape(name)}[ \t]*$", line.rstrip("\n")):
+ start = i
+ break
+ if start is None:
+ return conf
+ # block body = the repo line + the indented rule lines under it, ending at
+ # the first blank or non-indented line (the next stanza's leading blank, the
+ # next `repo`, or EOF). Blank lines are NOT consumed, so the separator before
+ # the following stanza stays put.
+ end = start + 1
+ while end < len(lines) and lines[end][:1] in (" ", "\t"):
+ end += 1
+ # drop the single blank line add_stanza put before this stanza.
+ if start > 0 and lines[start - 1].strip() == "":
+ start -= 1
+ return "".join(lines[:start] + lines[end:])
+
+
def _self_test():
cfg = load_config("/nonexistent")
assert cfg["rw_user"] == "youruser"
@@ -105,6 +130,19 @@ def _self_test():
# conf without trailing newline: separator inserted so stanza lands clean
assert add_stanza("repo a\n RW+ = youruser", "x", "youruser") == \
"repo a\n RW+ = youruser\n\nrepo x\n RW+ = youruser\n"
+ # remove_stanza is the inverse of add_stanza: add then remove == original.
+ base = "repo gitolite-admin\n RW+ = youruser\n"
+ assert remove_stanza(add_stanza(base, "publisher", "youruser"), "publisher") == base
+ # remove the MIDDLE of three stanzas: neighbours and their separators survive.
+ three = ("repo a\n RW+ = u\n\n"
+ "repo b\n RW+ = u\n\n"
+ "repo c\n RW+ = u\n")
+ assert remove_stanza(three, "b") == ("repo a\n RW+ = u\n\n"
+ "repo c\n RW+ = u\n")
+ assert stanza_exists(remove_stanza(three, "b"), "b") is False
+ # missing stanza: unchanged, idempotent
+ assert remove_stanza(three, "ghost") == three
+ assert remove_stanza(remove_stanza(three, "b"), "b") == remove_stanza(three, "b")
# repo table: columns padded to the widest cell (header included), desc not
# padded, private trailing-blank cells stripped. Color off => no escapes.
t = format_repo_table([
@@ -125,6 +163,9 @@ def _self_test():
assert pr.parse_args(["repo", "create", "r", "--section", "S"]).yes is False
assert pr.parse_args(["-y", "repo", "create", "r", "--section", "S"]).yes is True
assert pr.parse_args(["--yes", "sections", "list"]).yes is True
+ # repo delete takes a name; -y parses but cmd_delete ignores it (always confirms)
+ da = pr.parse_args(["-y", "repo", "delete", "doomed"])
+ assert da.action == "delete" and da.name == "doomed" and da.yes is True
print("self-test OK")
@@ -225,6 +266,45 @@ def cmd_create(cfg, a):
return 0
+def cmd_delete(cfg, a):
+ conf_path = os.path.join(cfg["admin_clone_path"], "conf", "gitolite.conf")
+
+ # ALWAYS confirm. Delete is destructive (the repo leaves the live tree), so
+ # -y is deliberately NOT honored here; the move to the trash is the only
+ # safety net. This block never reads a.yes.
+ print(f"About to DELETE repo {a.name!r}:")
+ print(f" - remove its gitolite stanza (commit + push to gitolite-admin)")
+ print(f" - remove its cgit block")
+ print(f" - MOVE its bare repo to the server trash dir (reversible, not rm)")
+ if not _confirm(f"Delete {a.name}? This cannot be undone from the client."):
+ sys.exit("aborted: nothing changed")
+
+ # PHASE 1: gitolite stanza (inverse of create) -- pushed FIRST so gitolite
+ # stops serving the repo before its bytes move.
+ try:
+ with open(conf_path) as f:
+ conf = f.read()
+ except OSError as e:
+ sys.exit(f"error: cannot read {conf_path}: {e.strerror}")
+ new = remove_stanza(conf, a.name)
+ if new == conf:
+ print(f"no stanza for {a.name}, skipping conf edit")
+ else:
+ with open(conf_path, "w") as f:
+ f.write(new)
+ print(git(cfg, "diff", "--", "conf/gitolite.conf", capture=True).stdout)
+ git(cfg, "add", "conf/gitolite.conf")
+ git(cfg, "commit", "-m", f"gitctl: delete repo {a.name}")
+ git(cfg, "push", "origin", cfg["admin_branch"])
+
+ # PHASE 2: server-side via helper -- cgit block + bare-repo move, idempotent.
+ # If phase 1 pushed but this fails, re-running finishes (stanza step is then
+ # a no-op), the same resumable shape as create.
+ call_helper(cfg, ["delete-repo", "--url", a.name])
+ print(f"done: {a.name} deleted (bare repo moved to the server trash dir)")
+ return 0
+
+
def build_parser():
p = argparse.ArgumentParser(
prog="gitctl",
@@ -236,8 +316,8 @@ def build_parser():
# Global -y. Skips additive y/N confirmations (today: the repo-create
# gitolite.conf push). It does NOT make creation unattended: the phase-1
# push still needs the SSH key password / smartcard touch, which -y cannot
- # supply. NEVER honored by a future `repo delete` (a destructive move must
- # always confirm on its own); see TODO item 1.
+ # supply. NEVER honored by `repo delete`: a destructive move always confirms
+ # on its own (cmd_delete does not read a.yes).
p.add_argument("-y", "--yes", action="store_true",
help="skip y/N confirmation of additive changes (still prints the diff; "
"does NOT bypass SSH key auth, and is never honored by repo delete)")
@@ -292,6 +372,15 @@ def build_parser():
"cgit-exposed ones). A VIS column marks each public (in cgit) or "
"private; on a terminal public rows are green, private dim.")
+ rdel = rep.add_parser(
+ "delete", help="delete a repo (gitolite + cgit + move bare repo to trash)",
+ description="Remove a repo from all three places it lives: its gitolite stanza "
+ "(committed and pushed), its cgit block, and the bare repo itself, "
+ "which is MOVED to the server's trash dir (reversible, not rm). "
+ "ALWAYS asks for confirmation; the global -y does NOT bypass this. "
+ "Idempotent: a piece already gone is skipped.")
+ rdel.add_argument("name", help="repo name to delete")
+
rr = rep.add_parser(
"add-remote", help="add a git remote for the repo in the current directory",
description="Add a git remote pointing at the repo on the server, in the "
@@ -321,6 +410,8 @@ def main():
return cmd_add_remote(cfg, a.name, a.remote_name)
if a.group == "repo" and a.action == "create":
return cmd_create(cfg, a)
+ if a.group == "repo" and a.action == "delete":
+ return cmd_delete(cfg, a)
return 1
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
diff --git a/skills/gitctl/SKILL.md b/skills/gitctl/SKILL.md
index 8660669..a37c1f7 100644
--- a/skills/gitctl/SKILL.md
+++ b/skills/gitctl/SKILL.md
@@ -17,6 +17,7 @@ setup" and "Client install") rather than guessing.
## When to use
- User wants a NEW repo on the server -> `gitctl repo create`
+- DELETE a repo from the server -> `gitctl repo delete` (destructive, always confirms)
- List every repo (public AND private) -> `gitctl repo list`
- List/add cgit sections (categories) -> `gitctl sections list|add`
- Set a repo's cgit description -> `gitctl repo desc`
@@ -60,8 +61,13 @@ git push -u origin master
`gitctl repo create` command. It is idempotent: it detects the existing
stanza, skips phase 1, and finishes phase 2. Do not start over.
- **Section names with spaces must be quoted**: `--section "My Sites"`.
-- **No delete verb.** Removing a repo/section is deliberately manual (server
- side). Do not script deletion through gitctl.
+- **`repo delete <name>` is destructive and ALWAYS confirms** (the global `-y`
+ does not bypass it, by design). It removes the gitolite stanza (commit + push,
+ so it needs the SSH key like create), removes the cgit block, and MOVES the
+ bare repo to the server trash dir (`/var/lib/gitolite3/trash`, timestamped) -
+ it does not `rm`, so it is recoverable there. Idempotent: a piece already gone
+ is skipped. There is still no delete verb for SECTIONS; removing a section is
+ manual.
- **`--desc` is the only way to set a cgit description.** It writes the bare
repo's `description` file and runs the sync script (the single writer of
`repo.desc=`). Never write `repo.desc=` directly.