diff options
| author | Danilo M. <danix@danix.xyz> | 2026-06-24 10:19:30 +0200 |
|---|---|---|
| committer | Danilo M. <danix@danix.xyz> | 2026-06-24 10:19:30 +0200 |
| commit | 02a5ae2adc2df39fc78fd7e29904ff473e9266a0 (patch) | |
| tree | bbe270a6302526476147430ce7614e17ac7cc1df | |
| parent | 345968271dec38b33a32649d8cfb767c2806330d (diff) | |
| download | gitctl-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.md | 7 | ||||
| -rw-r--r-- | TODO.md | 64 | ||||
| -rw-r--r-- | completions/gitctl.bash | 2 | ||||
| -rwxr-xr-x | gitctl | 95 | ||||
| -rwxr-xr-x | gitctl-helper | 148 | ||||
| -rw-r--r-- | skills/gitctl/SKILL.md | 10 |
6 files changed, 279 insertions, 47 deletions
@@ -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. @@ -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 @@ -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. |
