Thin CLI plus server helper to manage repos on a gitolite3 + cgit server
  • Python 95%
  • Shell 5%
Find a file
Danilo M. ffe0864406
docs: document repo-name completion and the server: output prefix
- 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>
2026-06-24 10:35:21 +02:00
completions feat: repo-name completion + label helper passthrough output 2026-06-24 10:33:08 +02:00
skills/gitctl docs: document repo-name completion and the server: output prefix 2026-06-24 10:35:21 +02:00
.gitignore Initial commit: gitctl 2026-06-23 11:18:05 +02:00
authorized_keys.example Initial commit: gitctl 2026-06-23 11:18:05 +02:00
CLAUDE.md docs: bring CLAUDE.md, README, TODO current after the three features 2026-06-24 10:28:04 +02:00
config.toml.example Initial commit: gitctl 2026-06-23 11:18:05 +02:00
COPYING Initial commit: gitctl 2026-06-23 11:18:05 +02:00
gitctl feat: repo-name completion + label helper passthrough output 2026-06-24 10:33:08 +02:00
gitctl-helper feat: repo delete - remove repo, move bare repo to trash 2026-06-24 10:19:30 +02:00
README.md docs: document repo-name completion and the server: output prefix 2026-06-24 10:35:21 +02:00
TODO.md feat: repo-name completion + label helper passthrough output 2026-06-24 10:33:08 +02:00

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

  • gitctl client CLI (your laptop)
  • gitctl-helper server helper (runs as the git user, behind a restricted key)
  • cgit repo.desc= has exactly one writer: the existing sync-cgit-descs.py, driven from each bare repo's description file.

SSH setup

gitctl uses TWO separate SSH paths. They MUST stay distinct: the config aborts if helper_ssh_alias equals push_ssh_alias.

  • git_push everyday, unrestricted git access (gitolite pushes), user git
  • git_helper restricted helper key, locked to gitctl-helper by command=, also the git user

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 with Too many authentication failures before the right key is tried. IdentitiesOnly yes offers only the named IdentityFile.

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

  1. 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
    
  2. Confirm the constants at the top of gitctl-helper match your server: REPO_BASE, CGITRC, SYNC_SCRIPT, BACKUP_DIR, TRASH_DIR, DEFAULT_OWNER. The git user must be able to create BACKUP_DIR (default /var/lib/gitolite3/cgitrc-backups) and TRASH_DIR (default /var/lib/gitolite3/trash, where repo delete moves bare repos); both are made on first write.
  3. Authorize the helper key (see SSH setup, step 2).
  4. Self-test the helper:
    python3 /usr/local/bin/gitctl-helper --self-test
    

Client install

  1. Copy the client:
    install -m 755 gitctl ~/.local/bin/gitctl
    
  2. Create ~/.config/gitctl/config.toml from config.toml.example.
  3. Set up the two ssh-config entries (see SSH setup, step 3).
  4. Self-test the client:
    gitctl --self-test
    
  5. (Optional) Install bash completion:
    sudo cp completions/gitctl.bash /etc/bash_completion.d/gitctl
    
    Or source it from ~/.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 the desc, delete, and add-remote arguments.
  6. (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 create shows 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 -y cannot supply. It is never honored by a destructive delete.
  • repo list lists every bare repo on disk, not just the cgit-exposed ones, so 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.
  • Lines from the server helper (and the cgit sync script it runs, e.g. No changes.) are echoed with a server: prefix, so it is clear which output is local and which is remote.
  • --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.
  • The helper runs as the git user, not root. It writes /etc/cgitrc in 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 to BACKUP_DIR before 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.