From 9b2328bcb46b1b5fd074fdbc2c4bea8855220276 Mon Sep 17 00:00:00 2001 From: "Danilo M." Date: Tue, 23 Jun 2026 11:18:05 +0200 Subject: Initial commit: gitctl Thin CLI plus a server helper (run as the git user) to manage repos on a personal gitolite3 + cgit server over a command=-restricted SSH key. Includes the client, the helper, bash completion, a Claude Code skill, and docs. Co-Authored-By: Claude Opus 4.8 --- CLAUDE.md | 91 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 91 insertions(+) create mode 100644 CLAUDE.md (limited to 'CLAUDE.md') diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..267bac1 --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,91 @@ +# gitctl + +Thin-client CLI plus a server helper (runs as the git user) to manage repos on +a personal gitolite3 + cgit server without raw ssh commands. Packaged later as +a SlackBuild. + +## Architecture + +Two single-file scripts, no shared module: + +- `gitctl` — client, runs on the laptop. Dumb caller: loads TOML config, edits + the local gitolite-admin clone and pushes, shells out to the helper over SSH. +- `gitctl-helper` — server helper, runs as the git user over a `command=`-restricted + SSH key. Holds all parsing-heavy logic. Dispatches on `SSH_ORIGINAL_COMMAND`. + +The restricted key forces `gitctl-helper` regardless of the command the client +sends; the verb + args arrive in `SSH_ORIGINAL_COMMAND`, are `shlex.split` then +fed to argparse. **No shell string interpolation, ever.** The client mirrors this +with `shlex.quote`; the round-trip is pinned in both self-tests. + +Two distinct SSH aliases, enforced different at config load: +- `push_ssh_alias` (e.g. `git_push`) — normal unrestricted git pushes +- `helper_ssh_alias` (e.g. `git_helper`) — restricted helper key, also `User git` + +The helper runs as the **git** user, not root (the target server has +`PermitRootLogin no`). git owns the bare repos' `description` files already; for +`/etc/cgitrc` you `chown git:gitolite3 /etc/cgitrc` once during setup. Because +git cannot create temp inodes in `/etc`, cgitrc is written **in place** (not via +atomic rename), with a pre-write backup to `BACKUP_DIR` +(`/var/lib/gitolite3/cgitrc-backups`). + +The helper key does NOT go in git's normal `~/.ssh/authorized_keys` — gitolite +owns and regenerates that file, and an existing gitolite key would match first +and run `gitolite-shell` (`FATAL: unknown git/gitolite command`). It lives in a +separate `AuthorizedKeysFile` (`~git/.ssh/gitctl_keys`) wired via a +`Match User git` stanza in sshd_config. Client-side, both ssh hosts use +`IdentitiesOnly yes` so a multi-key agent does not trip the server's +`MaxAuthTries` or offer a gitolite key first. + +## Hard rules (do not break) + +- **Python 3.11+ stdlib only.** No third-party deps (SlackBuild packaging). + Both scripts must stay single-file. +- **cgit `repo.desc=` has exactly one writer: the existing `sync-cgit-descs.py`.** + Never write `repo.desc=` directly. Descriptions flow: + `set-desc` -> bare repo's `description` file -> run sync. +- **cgitrc section blocks are flush-left and positional.** `section=` is positional, + so a new repo block must be inserted at the end of the section's span: + before the next `section=` line OR the next banner comment + (`# ---------- Name ------------#`), whichever comes first — a banner belongs + to the section it precedes (`insert_repo_block`, `BANNER_RE`). Ending the span + only at `section=` drops the block under the following section's banner. +- **`description` file is owned `git:gitolite3`** (system user git, group gitolite3, + no gitolite3 user). `set-desc` writes in place (truncate-write) to preserve owner, + with `os.chown` back as insurance. +- Every operation is idempotent and safe to re-run. +- No em dashes in user-facing text. +- Work on `master`, no feature branches. + +## Testing + +No framework. Each script has an assert-based `--self-test`: + +``` +python3 gitctl --self-test +python3 gitctl-helper --self-test +``` + +The helper self-test prints expected `error:` lines to stderr (it exercises +rejection paths) then `self-test OK`. Run both after any change. + +## Server constants (top of gitctl-helper) + +`REPO_BASE`, `CGITRC`, `SYNC_SCRIPT`, `BACKUP_DIR`, `DEFAULT_OWNER` — must match +the actual server. Confirmed against the real `/etc/cgitrc` and +`sync-cgit-descs.py`. Verified end to end on the live server (read, section add, +two-phase repo create, desc, sync, idempotency, backups). + +## Docs / design + +- User/install docs: `README.md` +- Design doc and implementation plan live under `docs/` locally but are + gitignored (they hold personal server details); not part of the published repo. + +## Before the first push to a public remote + +Untracking the personal artifacts only scrubbed future commits. The early +history (commits before the genericize commit) still contains real server +details and the original prompt. **Squash to a single clean commit before +pushing anywhere public** (e.g. `git checkout --orphan` a fresh root from the +current tree, or `git filter-repo`). Do not push the existing history. -- cgit v1.2.3