aboutsummaryrefslogtreecommitdiffstats

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, DEFAULT_OWNER. The git user must be able to create BACKUP_DIR (default /var/lib/gitolite3/cgitrc-backups); it is 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 --section values live from the server (one ssh call, only after --section).
  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 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 create shows the gitolite.conf diff and asks before committing. On decline it reverts the conf edit, leaving a clean tree.
  • --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.