gitctl
Thin CLI to manage repos on a personal gitolite3 + cgit server. The client on
your laptop is a dumb caller. All privileged server edits live in one helper
(run as the git user) reached over SSH through a command=-restricted key.
Layout
gitctlclient CLI (your laptop)gitctl-helperserver helper (runs as the git user, behind a restricted key)- cgit
repo.desc=has exactly one writer: the existingsync-cgit-descs.py, driven from each bare repo'sdescriptionfile.
SSH setup
gitctl uses TWO separate SSH paths. They MUST stay distinct: the config
aborts if helper_ssh_alias equals push_ssh_alias.
git_pusheveryday, unrestricted git access (gitolite pushes), usergitgit_helperrestricted helper key, locked togitctl-helperbycommand=, also thegituser
Both paths use the git user. The helper does NOT need root: the git user
already owns the bare repos' description files, and you make it own
/etc/cgitrc (step 2). This keeps PermitRootLogin no intact.
1. Generate a dedicated helper keypair (on the laptop)
ssh-keygen -t ed25519 -f ~/.ssh/gitctl_helper -C gitctl-helper
Keep this separate from your everyday git key.
2. Let the git user write cgitrc, and authorize the helper key (on the server)
/etc/cgitrc is usually root:root. The helper writes it in place (no temp
file, so it does not need write access to /etc itself), but the git user
must own the file. cgit only reads it, so 644 keeps that working:
chown git:gitolite3 /etc/cgitrc
chmod 644 /etc/cgitrc
Now authorize the helper key. Do NOT add it to the git user's normal
~/.ssh/authorized_keys — gitolite owns and regenerates that file from its
keydir, so a hand-added line is wiped and, worse, an existing gitolite key may
match first and run gitolite-shell instead of the helper (you get
FATAL: unknown git/gitolite command).
Instead give the git user a SECOND authorized_keys file that gitolite does not
manage. Put the restricted helper line (from authorized_keys.example, your
real pubkey) in it:
# on the server, as root or git
printf '%s\n' 'command="/usr/local/bin/gitctl-helper",no-port-forwarding,no-X11-forwarding,no-agent-forwarding,no-pty ssh-ed25519 AAAA...your gitctl_helper.pub... gitctl-helper' \
> ~git/.ssh/gitctl_keys
chown git:gitolite3 ~git/.ssh/gitctl_keys
chmod 600 ~git/.ssh/gitctl_keys
Then tell sshd to read it for the git user. Append at the END of
/etc/ssh/sshd_config (Match blocks must come after the global section):
Match User git
AuthorizedKeysFile .ssh/authorized_keys .ssh/gitctl_keys
Validate and reload (keep your current session open in case of a typo):
sshd -t && systemctl reload ssh # or sshd / ssh, per distro
The command= forces gitctl-helper regardless of what the client sends; the
no-* options strip forwarding and pty. PermitRootLogin no stays intact.
3. Add two ssh-config entries (on the laptop)
In ~/.ssh/config, adjust HostName, Port, and IdentityFile to your
server. Both use User git:
Host git_push # everyday unrestricted git access
HostName git.example.com
User git
Port 22
IdentityFile ~/.ssh/git_id # your gitolite key (or a card's .pub, agent signs)
IdentitiesOnly yes
Host git_helper # restricted helper key
HostName git.example.com
User git
Port 22
IdentityFile ~/.ssh/gitctl_helper
IdentitiesOnly yes
IdentitiesOnly yes on BOTH hosts matters, for two reasons:
- If your ssh-agent (or a hardware key) holds a key that gitolite also knows,
ssh may offer it first and the server runs gitolite-shell instead of the
helper:
FATAL: unknown git/gitolite command. Pinning avoids it. - A loaded agent can offer many keys; the server's
MaxAuthTries(often 5) cuts you off withToo many authentication failuresbefore the right key is tried.IdentitiesOnly yesoffers only the namedIdentityFile.
If your gitolite key lives on a smartcard, point IdentityFile at the card's
public key file (export with ssh-add -L | grep cardno > ~/.ssh/gitcard.pub);
the agent still does the signing.
Do not collapse the two: git_push stays unrestricted for normal pushes;
git_helper is locked to the helper by command=. The alias names here must
match push_ssh_alias and helper_ssh_alias in your config.toml.
4. Verify both paths
ssh git_helper list-sections # prints your cgit sections via the helper
ssh git_push info # gitolite access check
Server install
- Copy the helper, world-readable and executable (it runs as the git user):
sudo install -m 755 -o root -g root gitctl-helper /usr/local/bin/gitctl-helper - Confirm the constants at the top of
gitctl-helpermatch your server:REPO_BASE,CGITRC,SYNC_SCRIPT,BACKUP_DIR,DEFAULT_OWNER. The git user must be able to createBACKUP_DIR(default/var/lib/gitolite3/cgitrc-backups); it is made on first write. - Authorize the helper key (see SSH setup, step 2).
- Self-test the helper:
python3 /usr/local/bin/gitctl-helper --self-test
Client install
- Copy the client:
install -m 755 gitctl ~/.local/bin/gitctl - Create
~/.config/gitctl/config.tomlfromconfig.toml.example. - Set up the two ssh-config entries (see SSH setup, step 3).
- Self-test the client:
gitctl --self-test - (Optional) Install bash completion:
sudo cp completions/gitctl.bash /etc/bash_completion.d/gitctlOr source it from~/.bashrc. It completes subcommands and flags, and pulls--sectionvalues live from the server (one ssh call, only after--section). - (Optional) If you drive gitctl through a Claude Code agent, install the
bundled skill so the agent knows when and how to use it:
cp -r skills/gitctl ~/.claude/skills/gitctl
Usage
gitctl sections list
gitctl sections add "Generic Projects"
gitctl repo create publisher --section SlackBuilds --desc "..."
gitctl repo desc publisher "a new description"
gitctl repo add-remote publisher --remote-name origin
gitctl sync
End-to-end agent flow:
gitctl repo create publisher --section SlackBuilds --desc "..."
gitctl repo add-remote publisher --remote-name origin
git push -u origin master
Notes
repo createshows the gitolite.conf diff and asks before committing. On decline it reverts the conf edit, leaving a clean tree.--descnever writes cgitrepo.desc=directly. It writes the bare repo'sdescriptionfile and runs sync, the single writer.- Every operation is idempotent and safe to re-run.
- The helper runs as the
gituser, not root. It writes/etc/cgitrcin place (the git user owns the file but cannot create temp files in/etc, so the write is not atomic). A timestamped backup (cgitrc.bak.YYYYMMDD-HHMMSS) is written toBACKUP_DIRbefore every edit, so a crash mid-write is recoverable. Backups are never pruned; delete old ones by hand if they pile up. - The sync script (
sync-cgit-descs.py) must be runnable as the git user.
