- Python 95%
- Shell 5%
- README: completion note now covers repo-name completion for desc/delete/add-remote, not just --section; new Notes entry on the server: prefix for helper/sync passthrough. - SKILL.md: gotcha explaining that server:-prefixed lines are remote echo (not errors) and that repo list output is not prefixed. Docs only. (The global ~/.claude copy of the skill got the same gotcha, outside the repo.) Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> |
||
|---|---|---|
| completions | ||
| skills/gitctl | ||
| .gitignore | ||
| authorized_keys.example | ||
| CLAUDE.md | ||
| config.toml.example | ||
| COPYING | ||
| gitctl | ||
| gitctl-helper | ||
| README.md | ||
| TODO.md | ||
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,TRASH_DIR,DEFAULT_OWNER. The git user must be able to createBACKUP_DIR(default/var/lib/gitolite3/cgitrc-backups) andTRASH_DIR(default/var/lib/gitolite3/trash, whererepo deletemoves bare repos); both are 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:
Or source it fromsudo cp completions/gitctl.bash /etc/bash_completion.d/gitctl~/.bashrc. It completes subcommands and flags, and pulls live values from the server (one ssh call each, only at the relevant position): section names after--section, and repo names for thedesc,delete, andadd-remotearguments. - (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 list
gitctl repo desc publisher "a new description"
gitctl repo add-remote publisher --remote-name origin
gitctl repo delete publisher
gitctl sync
End-to-end agent flow:
gitctl -y repo create publisher --section SlackBuilds --desc "..."
gitctl repo add-remote publisher --remote-name origin
git push -u origin master
-y skips the y/N so an agent can drive create; the push itself still prompts
for the SSH key, so it is not fully unattended.
Notes
repo createshows the gitolite.conf diff and asks before committing. On decline it reverts the conf edit, leaving a clean tree.-y/--yes(a global flag, so it goes BEFORE the subcommand:gitctl -y repo create ...) skips that y/N confirmation. The diff is still printed. It does NOT make creation unattended: the phase-1 push still needs the SSH key password or smartcard touch, which-ycannot supply. It is never honored by a destructive delete.repo listlists every bare repo on disk, not just the cgit-exposed ones, so private repos show too. AVIScolumn marks eachPUB(present in cgitrc) orPRIV; on a terminal public rows are green and private dim. The description comes from each repo'sdescriptionfile, truncated to 60 chars.repo deleteremoves 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-ydoes not bypass it. Idempotent: a piece already gone is skipped. Prune the trash dir by hand (or a later tool) when sure.- Lines from the server helper (and the cgit sync script it runs, e.g.
No changes.) are echoed with aserver:prefix, so it is clear which output is local and which is remote. --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.