aboutsummaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
authorDanilo M. <danix@danix.xyz>2026-06-23 11:18:05 +0200
committerDanilo M. <danix@danix.xyz>2026-06-23 11:18:05 +0200
commit9b2328bcb46b1b5fd074fdbc2c4bea8855220276 (patch)
treef3936da4c051796dbf51983bbfa309b095bd241c
downloadgitctl-9b2328bcb46b1b5fd074fdbc2c4bea8855220276.tar.gz
gitctl-9b2328bcb46b1b5fd074fdbc2c4bea8855220276.zip
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 <noreply@anthropic.com>
-rw-r--r--.gitignore6
-rw-r--r--CLAUDE.md91
-rw-r--r--COPYING338
-rw-r--r--README.md196
-rw-r--r--authorized_keys.example6
-rw-r--r--completions/gitctl.bash48
-rw-r--r--config.toml.example8
-rwxr-xr-xgitctl259
-rwxr-xr-xgitctl-helper422
-rw-r--r--skills/gitctl/SKILL.md68
10 files changed, 1442 insertions, 0 deletions
diff --git a/.gitignore b/.gitignore
new file mode 100644
index 0000000..e0bfd85
--- /dev/null
+++ b/.gitignore
@@ -0,0 +1,6 @@
+__pycache__/
+*.pyc
+
+# local dev artifacts (contain personal server details, not for publishing)
+gitctl_prompt.txt
+docs/
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.
diff --git a/COPYING b/COPYING
new file mode 100644
index 0000000..9efa6fb
--- /dev/null
+++ b/COPYING
@@ -0,0 +1,338 @@
+ GNU GENERAL PUBLIC LICENSE
+ Version 2, June 1991
+
+ Copyright (C) 1989, 1991 Free Software Foundation, Inc.,
+ <https://fsf.org/>
+ Everyone is permitted to copy and distribute verbatim copies
+ of this license document, but changing it is not allowed.
+
+ Preamble
+
+ The licenses for most software are designed to take away your
+freedom to share and change it. By contrast, the GNU General Public
+License is intended to guarantee your freedom to share and change free
+software--to make sure the software is free for all its users. This
+General Public License applies to most of the Free Software
+Foundation's software and to any other program whose authors commit to
+using it. (Some other Free Software Foundation software is covered by
+the GNU Lesser General Public License instead.) You can apply it to
+your programs, too.
+
+ When we speak of free software, we are referring to freedom, not
+price. Our General Public Licenses are designed to make sure that you
+have the freedom to distribute copies of free software (and charge for
+this service if you wish), that you receive source code or can get it
+if you want it, that you can change the software or use pieces of it
+in new free programs; and that you know you can do these things.
+
+ To protect your rights, we need to make restrictions that forbid
+anyone to deny you these rights or to ask you to surrender the rights.
+These restrictions translate to certain responsibilities for you if you
+distribute copies of the software, or if you modify it.
+
+ For example, if you distribute copies of such a program, whether
+gratis or for a fee, you must give the recipients all the rights that
+you have. You must make sure that they, too, receive or can get the
+source code. And you must show them these terms so they know their
+rights.
+
+ We protect your rights with two steps: (1) copyright the software, and
+(2) offer you this license which gives you legal permission to copy,
+distribute and/or modify the software.
+
+ Also, for each author's protection and ours, we want to make certain
+that everyone understands that there is no warranty for this free
+software. If the software is modified by someone else and passed on, we
+want its recipients to know that what they have is not the original, so
+that any problems introduced by others will not reflect on the original
+authors' reputations.
+
+ Finally, any free program is threatened constantly by software
+patents. We wish to avoid the danger that redistributors of a free
+program will individually obtain patent licenses, in effect making the
+program proprietary. To prevent this, we have made it clear that any
+patent must be licensed for everyone's free use or not licensed at all.
+
+ The precise terms and conditions for copying, distribution and
+modification follow.
+
+ GNU GENERAL PUBLIC LICENSE
+ TERMS AND CONDITIONS FOR COPYING, DISTRIBUTION AND MODIFICATION
+
+ 0. This License applies to any program or other work which contains
+a notice placed by the copyright holder saying it may be distributed
+under the terms of this General Public License. The "Program", below,
+refers to any such program or work, and a "work based on the Program"
+means either the Program or any derivative work under copyright law:
+that is to say, a work containing the Program or a portion of it,
+either verbatim or with modifications and/or translated into another
+language. (Hereinafter, translation is included without limitation in
+the term "modification".) Each licensee is addressed as "you".
+
+Activities other than copying, distribution and modification are not
+covered by this License; they are outside its scope. The act of
+running the Program is not restricted, and the output from the Program
+is covered only if its contents constitute a work based on the
+Program (independent of having been made by running the Program).
+Whether that is true depends on what the Program does.
+
+ 1. You may copy and distribute verbatim copies of the Program's
+source code as you receive it, in any medium, provided that you
+conspicuously and appropriately publish on each copy an appropriate
+copyright notice and disclaimer of warranty; keep intact all the
+notices that refer to this License and to the absence of any warranty;
+and give any other recipients of the Program a copy of this License
+along with the Program.
+
+You may charge a fee for the physical act of transferring a copy, and
+you may at your option offer warranty protection in exchange for a fee.
+
+ 2. You may modify your copy or copies of the Program or any portion
+of it, thus forming a work based on the Program, and copy and
+distribute such modifications or work under the terms of Section 1
+above, provided that you also meet all of these conditions:
+
+ a) You must cause the modified files to carry prominent notices
+ stating that you changed the files and the date of any change.
+
+ b) You must cause any work that you distribute or publish, that in
+ whole or in part contains or is derived from the Program or any
+ part thereof, to be licensed as a whole at no charge to all third
+ parties under the terms of this License.
+
+ c) If the modified program normally reads commands interactively
+ when run, you must cause it, when started running for such
+ interactive use in the most ordinary way, to print or display an
+ announcement including an appropriate copyright notice and a
+ notice that there is no warranty (or else, saying that you provide
+ a warranty) and that users may redistribute the program under
+ these conditions, and telling the user how to view a copy of this
+ License. (Exception: if the Program itself is interactive but
+ does not normally print such an announcement, your work based on
+ the Program is not required to print an announcement.)
+
+These requirements apply to the modified work as a whole. If
+identifiable sections of that work are not derived from the Program,
+and can be reasonably considered independent and separate works in
+themselves, then this License, and its terms, do not apply to those
+sections when you distribute them as separate works. But when you
+distribute the same sections as part of a whole which is a work based
+on the Program, the distribution of the whole must be on the terms of
+this License, whose permissions for other licensees extend to the
+entire whole, and thus to each and every part regardless of who wrote it.
+
+Thus, it is not the intent of this section to claim rights or contest
+your rights to work written entirely by you; rather, the intent is to
+exercise the right to control the distribution of derivative or
+collective works based on the Program.
+
+In addition, mere aggregation of another work not based on the Program
+with the Program (or with a work based on the Program) on a volume of
+a storage or distribution medium does not bring the other work under
+the scope of this License.
+
+ 3. You may copy and distribute the Program (or a work based on it,
+under Section 2) in object code or executable form under the terms of
+Sections 1 and 2 above provided that you also do one of the following:
+
+ a) Accompany it with the complete corresponding machine-readable
+ source code, which must be distributed under the terms of Sections
+ 1 and 2 above on a medium customarily used for software interchange; or,
+
+ b) Accompany it with a written offer, valid for at least three
+ years, to give any third party, for a charge no more than your
+ cost of physically performing source distribution, a complete
+ machine-readable copy of the corresponding source code, to be
+ distributed under the terms of Sections 1 and 2 above on a medium
+ customarily used for software interchange; or,
+
+ c) Accompany it with the information you received as to the offer
+ to distribute corresponding source code. (This alternative is
+ allowed only for noncommercial distribution and only if you
+ received the program in object code or executable form with such
+ an offer, in accord with Subsection b above.)
+
+The source code for a work means the preferred form of the work for
+making modifications to it. For an executable work, complete source
+code means all the source code for all modules it contains, plus any
+associated interface definition files, plus the scripts used to
+control compilation and installation of the executable. However, as a
+special exception, the source code distributed need not include
+anything that is normally distributed (in either source or binary
+form) with the major components (compiler, kernel, and so on) of the
+operating system on which the executable runs, unless that component
+itself accompanies the executable.
+
+If distribution of executable or object code is made by offering
+access to copy from a designated place, then offering equivalent
+access to copy the source code from the same place counts as
+distribution of the source code, even though third parties are not
+compelled to copy the source along with the object code.
+
+ 4. You may not copy, modify, sublicense, or distribute the Program
+except as expressly provided under this License. Any attempt
+otherwise to copy, modify, sublicense or distribute the Program is
+void, and will automatically terminate your rights under this License.
+However, parties who have received copies, or rights, from you under
+this License will not have their licenses terminated so long as such
+parties remain in full compliance.
+
+ 5. You are not required to accept this License, since you have not
+signed it. However, nothing else grants you permission to modify or
+distribute the Program or its derivative works. These actions are
+prohibited by law if you do not accept this License. Therefore, by
+modifying or distributing the Program (or any work based on the
+Program), you indicate your acceptance of this License to do so, and
+all its terms and conditions for copying, distributing or modifying
+the Program or works based on it.
+
+ 6. Each time you redistribute the Program (or any work based on the
+Program), the recipient automatically receives a license from the
+original licensor to copy, distribute or modify the Program subject to
+these terms and conditions. You may not impose any further
+restrictions on the recipients' exercise of the rights granted herein.
+You are not responsible for enforcing compliance by third parties to
+this License.
+
+ 7. If, as a consequence of a court judgment or allegation of patent
+infringement or for any other reason (not limited to patent issues),
+conditions are imposed on you (whether by court order, agreement or
+otherwise) that contradict the conditions of this License, they do not
+excuse you from the conditions of this License. If you cannot
+distribute so as to satisfy simultaneously your obligations under this
+License and any other pertinent obligations, then as a consequence you
+may not distribute the Program at all. For example, if a patent
+license would not permit royalty-free redistribution of the Program by
+all those who receive copies directly or indirectly through you, then
+the only way you could satisfy both it and this License would be to
+refrain entirely from distribution of the Program.
+
+If any portion of this section is held invalid or unenforceable under
+any particular circumstance, the balance of the section is intended to
+apply and the section as a whole is intended to apply in other
+circumstances.
+
+It is not the purpose of this section to induce you to infringe any
+patents or other property right claims or to contest validity of any
+such claims; this section has the sole purpose of protecting the
+integrity of the free software distribution system, which is
+implemented by public license practices. Many people have made
+generous contributions to the wide range of software distributed
+through that system in reliance on consistent application of that
+system; it is up to the author/donor to decide if he or she is willing
+to distribute software through any other system and a licensee cannot
+impose that choice.
+
+This section is intended to make thoroughly clear what is believed to
+be a consequence of the rest of this License.
+
+ 8. If the distribution and/or use of the Program is restricted in
+certain countries either by patents or by copyrighted interfaces, the
+original copyright holder who places the Program under this License
+may add an explicit geographical distribution limitation excluding
+those countries, so that distribution is permitted only in or among
+countries not thus excluded. In such case, this License incorporates
+the limitation as if written in the body of this License.
+
+ 9. The Free Software Foundation may publish revised and/or new versions
+of the General Public License from time to time. Such new versions will
+be similar in spirit to the present version, but may differ in detail to
+address new problems or concerns.
+
+Each version is given a distinguishing version number. If the Program
+specifies a version number of this License which applies to it and "any
+later version", you have the option of following the terms and conditions
+either of that version or of any later version published by the Free
+Software Foundation. If the Program does not specify a version number of
+this License, you may choose any version ever published by the Free Software
+Foundation.
+
+ 10. If you wish to incorporate parts of the Program into other free
+programs whose distribution conditions are different, write to the author
+to ask for permission. For software which is copyrighted by the Free
+Software Foundation, write to the Free Software Foundation; we sometimes
+make exceptions for this. Our decision will be guided by the two goals
+of preserving the free status of all derivatives of our free software and
+of promoting the sharing and reuse of software generally.
+
+ NO WARRANTY
+
+ 11. BECAUSE THE PROGRAM IS LICENSED FREE OF CHARGE, THERE IS NO WARRANTY
+FOR THE PROGRAM, TO THE EXTENT PERMITTED BY APPLICABLE LAW. EXCEPT WHEN
+OTHERWISE STATED IN WRITING THE COPYRIGHT HOLDERS AND/OR OTHER PARTIES
+PROVIDE THE PROGRAM "AS IS" WITHOUT WARRANTY OF ANY KIND, EITHER EXPRESSED
+OR IMPLIED, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF
+MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE. THE ENTIRE RISK AS
+TO THE QUALITY AND PERFORMANCE OF THE PROGRAM IS WITH YOU. SHOULD THE
+PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF ALL NECESSARY SERVICING,
+REPAIR OR CORRECTION.
+
+ 12. IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING
+WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MAY MODIFY AND/OR
+REDISTRIBUTE THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES,
+INCLUDING ANY GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING
+OUT OF THE USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED
+TO LOSS OF DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY
+YOU OR THIRD PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER
+PROGRAMS), EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE
+POSSIBILITY OF SUCH DAMAGES.
+
+ END OF TERMS AND CONDITIONS
+
+ How to Apply These Terms to Your New Programs
+
+ If you develop a new program, and you want it to be of the greatest
+possible use to the public, the best way to achieve this is to make it
+free software which everyone can redistribute and change under these terms.
+
+ To do so, attach the following notices to the program. It is safest
+to attach them to the start of each source file to most effectively
+convey the exclusion of warranty; and each file should have at least
+the "copyright" line and a pointer to where the full notice is found.
+
+ <one line to give the program's name and a brief idea of what it does.>
+ Copyright (C) <year> <name of author>
+
+ This program is free software; you can redistribute it and/or modify
+ it under the terms of the GNU General Public License as published by
+ the Free Software Foundation; either version 2 of the License, or
+ (at your option) any later version.
+
+ This program is distributed in the hope that it will be useful,
+ but WITHOUT ANY WARRANTY; without even the implied warranty of
+ MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ GNU General Public License for more details.
+
+ You should have received a copy of the GNU General Public License along
+ with this program; if not, see <https://www.gnu.org/licenses/>.
+
+Also add information on how to contact you by electronic and paper mail.
+
+If the program is interactive, make it output a short notice like this
+when it starts in an interactive mode:
+
+ Gnomovision version 69, Copyright (C) year name of author
+ Gnomovision comes with ABSOLUTELY NO WARRANTY; for details type `show w'.
+ This is free software, and you are welcome to redistribute it
+ under certain conditions; type `show c' for details.
+
+The hypothetical commands `show w' and `show c' should show the appropriate
+parts of the General Public License. Of course, the commands you use may
+be called something other than `show w' and `show c'; they could even be
+mouse-clicks or menu items--whatever suits your program.
+
+You should also get your employer (if you work as a programmer) or your
+school, if any, to sign a "copyright disclaimer" for the program, if
+necessary. Here is a sample; alter the names:
+
+ Yoyodyne, Inc., hereby disclaims all copyright interest in the program
+ `Gnomovision' (which makes passes at compilers) written by James Hacker.
+
+ <signature of Moe Ghoul>, 1 April 1989
+ Moe Ghoul, President of Vice
+
+This General Public License does not permit incorporating your program into
+proprietary programs. If your program is a subroutine library, you may
+consider it more useful to permit linking proprietary applications with the
+library. If this is what you want to do, use the GNU Lesser General
+Public License instead of this License.
diff --git a/README.md b/README.md
new file mode 100644
index 0000000..a965012
--- /dev/null
+++ b/README.md
@@ -0,0 +1,196 @@
+# 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.
diff --git a/authorized_keys.example b/authorized_keys.example
new file mode 100644
index 0000000..f702b22
--- /dev/null
+++ b/authorized_keys.example
@@ -0,0 +1,6 @@
+# Put this line in a SEPARATE authorized_keys file for the git user, e.g.
+# ~git/.ssh/gitctl_keys, NOT the normal ~git/.ssh/authorized_keys (gitolite
+# owns and regenerates that one). Point sshd at it with a Match User git +
+# AuthorizedKeysFile stanza. See README "SSH setup" step 2.
+# One line, the command= forces gitctl-helper regardless of the client's sent command.
+command="/usr/local/bin/gitctl-helper",no-port-forwarding,no-X11-forwarding,no-agent-forwarding,no-pty ssh-ed25519 AAAA...REPLACE_WITH_HELPER_PUBKEY... gitctl-helper
diff --git a/completions/gitctl.bash b/completions/gitctl.bash
new file mode 100644
index 0000000..0b680dd
--- /dev/null
+++ b/completions/gitctl.bash
@@ -0,0 +1,48 @@
+# bash completion for gitctl
+# install: cp completions/gitctl.bash /etc/bash_completion.d/gitctl
+# or: source completions/gitctl.bash (from ~/.bashrc)
+
+_gitctl() {
+ local cur prev words cword
+ _init_completion 2>/dev/null || {
+ # minimal fallback if bash-completion's _init_completion is absent
+ cur=${COMP_WORDS[COMP_CWORD]}
+ prev=${COMP_WORDS[COMP_CWORD-1]}
+ words=("${COMP_WORDS[@]}")
+ cword=$COMP_CWORD
+ }
+
+ local group=${words[1]} action=${words[2]}
+
+ # complete section names from the live server (one ssh call). Only when the
+ # previous word is --section, so we do not pay it on every Tab.
+ if [[ $prev == "--section" ]]; then
+ local secs
+ secs=$(gitctl sections list 2>/dev/null)
+ local IFS=$'\n'
+ COMPREPLY=($(compgen -W "$secs" -- "$cur"))
+ return
+ fi
+
+ case $cword in
+ 1)
+ COMPREPLY=($(compgen -W "sections sync repo" -- "$cur"))
+ return ;;
+ 2)
+ case $group in
+ sections) COMPREPLY=($(compgen -W "list add" -- "$cur")) ;;
+ repo) COMPREPLY=($(compgen -W "create desc add-remote" -- "$cur")) ;;
+ esac
+ return ;;
+ esac
+
+ # flags, by subcommand
+ if [[ $cur == -* ]]; then
+ case "$group $action" in
+ "repo create") COMPREPLY=($(compgen -W "--section --desc --owner" -- "$cur")) ;;
+ "repo add-remote") COMPREPLY=($(compgen -W "--remote-name" -- "$cur")) ;;
+ esac
+ return
+ fi
+}
+complete -F _gitctl gitctl
diff --git a/config.toml.example b/config.toml.example
new file mode 100644
index 0000000..f7f5787
--- /dev/null
+++ b/config.toml.example
@@ -0,0 +1,8 @@
+# gitctl client config -> ~/.config/gitctl/config.toml
+admin_clone_path = "~/git/gitolite-admin"
+helper_ssh_alias = "git_helper" # restricted command= key, MUST differ from push_ssh_alias
+push_ssh_alias = "git_push" # normal unrestricted git access
+default_owner = "by Your Name"
+admin_branch = "master"
+rw_user = "youruser"
+remote_template = "{push_ssh_alias}:{name}"
diff --git a/gitctl b/gitctl
new file mode 100755
index 0000000..88ba37a
--- /dev/null
+++ b/gitctl
@@ -0,0 +1,259 @@
+#!/usr/bin/env python3
+#
+# gitctl: thin client for managing repos on a gitolite3 + cgit server.
+# Copyright (C) 2026 Danilo M. <danix@danix.xyz>
+#
+# This program is free software; you can redistribute it and/or modify
+# it under the terms of the GNU General Public License as published by
+# the Free Software Foundation; either version 2 of the License, or
+# (at your option) any later version.
+#
+# This program is distributed in the hope that it will be useful,
+# but WITHOUT ANY WARRANTY; without even the implied warranty of
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+# GNU General Public License for more details.
+#
+# You should have received a copy of the GNU General Public License along
+# with this program; if not, write to the Free Software Foundation, Inc.,
+# 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA.
+"""gitctl: thin client for managing repos on a gitolite3 + cgit server."""
+import os, sys, re, shlex, tomllib, argparse, subprocess, time
+
+DEFAULTS = {
+ "admin_clone_path": "~/git/gitolite-admin",
+ "helper_ssh_alias": "git_helper",
+ "push_ssh_alias": "git_push",
+ "default_owner": "by Your Name",
+ "admin_branch": "master",
+ "rw_user": "youruser",
+ "remote_template": "{push_ssh_alias}:{name}",
+}
+CONFIG_PATH = os.path.expanduser("~/.config/gitctl/config.toml")
+
+
+def load_config(path=CONFIG_PATH):
+ cfg = dict(DEFAULTS)
+ if os.path.exists(path):
+ with open(path, "rb") as f:
+ cfg.update(tomllib.load(f))
+ cfg["admin_clone_path"] = os.path.expanduser(cfg["admin_clone_path"])
+ if cfg["helper_ssh_alias"] == cfg["push_ssh_alias"]:
+ sys.exit("error: helper_ssh_alias and push_ssh_alias must differ")
+ return cfg
+
+
+def remote_url(cfg, name):
+ return cfg["remote_template"].format(push_ssh_alias=cfg["push_ssh_alias"], name=name)
+
+
+def helper_command(args):
+ return " ".join(shlex.quote(a) for a in args)
+
+
+def call_helper(cfg, args, check=True):
+ """Run a helper verb. Returns CompletedProcess. ssh sends the verb string;
+ the restricted key forces gitctl-helper, which reads SSH_ORIGINAL_COMMAND."""
+ cmd = ["ssh", cfg["helper_ssh_alias"], helper_command(args)]
+ r = subprocess.run(cmd, text=True, capture_output=True)
+ if r.stdout:
+ sys.stdout.write(r.stdout)
+ if r.stderr:
+ sys.stderr.write(r.stderr)
+ if check and r.returncode != 0:
+ sys.exit(f"error: helper '{args[0]}' failed (exit {r.returncode})")
+ return r
+
+
+def git(cfg, *args, check=True, capture=False):
+ r = subprocess.run(["git", "-C", cfg["admin_clone_path"], *args],
+ text=True, capture_output=capture)
+ if check and r.returncode != 0:
+ sys.exit(f"error: git {' '.join(args)} failed")
+ return r
+
+
+def stanza_exists(conf, name):
+ return re.search(rf"(?m)^repo[ \t]+{re.escape(name)}[ \t]*$", conf) is not None
+
+
+def add_stanza(conf, name, rw_user):
+ if stanza_exists(conf, name):
+ return conf
+ sep = "" if conf.endswith("\n") or conf == "" else "\n"
+ return conf + sep + f"\nrepo {name}\n RW+ = {rw_user}\n"
+
+
+def _self_test():
+ cfg = load_config("/nonexistent")
+ assert cfg["rw_user"] == "youruser"
+ assert remote_url(cfg, "publisher") == "git_push:publisher"
+ # helper_command builds a single shell-quoted string for SSH_ORIGINAL_COMMAND
+ s = helper_command(["add-repo", "--url", "a b", "--section", "X Y"])
+ assert s == "add-repo --url 'a b' --section 'X Y'"
+ conf = "repo gitolite-admin\n RW+ = youruser\n"
+ assert stanza_exists(conf, "publisher") is False
+ new = add_stanza(conf, "publisher", "youruser")
+ assert "repo publisher\n RW+ = youruser\n" in new
+ assert stanza_exists(new, "publisher") is True
+ # idempotent: adding again returns unchanged
+ assert add_stanza(new, "publisher", "youruser") == new
+ # word-boundary: 'pub' must not match 'publisher'
+ assert stanza_exists(new, "pub") is False
+ # empty conf: no leading separator junk, valid stanza
+ assert add_stanza("", "x", "youruser") == "\nrepo x\n RW+ = youruser\n"
+ # conf without trailing newline: separator inserted so stanza lands clean
+ assert add_stanza("repo a\n RW+ = youruser", "x", "youruser") == \
+ "repo a\n RW+ = youruser\n\nrepo x\n RW+ = youruser\n"
+ print("self-test OK")
+
+
+def cmd_add_remote(cfg, name, remote_name):
+ # operates on the CWD's repo
+ url = remote_url(cfg, name)
+ r = subprocess.run(["git", "remote"], text=True, capture_output=True)
+ if r.returncode != 0:
+ sys.exit("error: not a git repository (run this inside the repo's working tree)")
+ if remote_name in r.stdout.split():
+ print(f"remote {remote_name} already exists")
+ return 0
+ if subprocess.run(["git", "remote", "add", remote_name, url]).returncode != 0:
+ sys.exit(f"error: git remote add {remote_name} failed")
+ print(f"added remote {remote_name} -> {url}")
+ return 0
+
+
+def _confirm(prompt):
+ return input(prompt + " [y/N] ").strip().lower() in ("y", "yes")
+
+
+def cmd_create(cfg, a):
+ owner = a.owner or cfg["default_owner"]
+ conf_path = os.path.join(cfg["admin_clone_path"], "conf", "gitolite.conf")
+
+ # PHASE 1: local gitolite-admin edit
+ try:
+ with open(conf_path) as f:
+ conf = f.read()
+ except OSError as e:
+ sys.exit(f"error: cannot read {conf_path}: {e.strerror}")
+ if stanza_exists(conf, a.name):
+ print(f"stanza for {a.name} already present, skipping conf edit")
+ else:
+ with open(conf_path, "w") as f:
+ f.write(add_stanza(conf, a.name, cfg["rw_user"]))
+ diff = git(cfg, "diff", "--", "conf/gitolite.conf", capture=True).stdout
+ print(diff)
+ if not _confirm("Commit and push this gitolite.conf change?"):
+ git(cfg, "checkout", "--", "conf/gitolite.conf")
+ sys.exit("aborted: reverted conf/gitolite.conf, no changes pushed")
+ git(cfg, "add", "conf/gitolite.conf")
+ git(cfg, "commit", "-m", f"gitctl: add repo {a.name}")
+ git(cfg, "push", "origin", cfg["admin_branch"])
+
+ # wait for gitolite to build the bare repo
+ print(f"waiting for bare repo {a.name}.git to appear ...")
+ for _ in range(15):
+ if call_helper(cfg, ["repo-exists", "--url", a.name], check=False).returncode == 0:
+ break
+ time.sleep(1)
+ else:
+ sys.exit(f"error: timed out waiting for {a.name}.git on the server")
+
+ # PHASE 2: server-side via helper
+ sections = call_helper(cfg, ["list-sections"], check=False).stdout.split("\n")
+ if a.section not in [s.strip() for s in sections if s.strip()]:
+ call_helper(cfg, ["add-section", "--name", a.section])
+ call_helper(cfg, ["add-repo", "--url", a.name, "--section", a.section, "--owner", owner])
+ if a.desc:
+ call_helper(cfg, ["set-desc", "--url", a.name, "--desc", a.desc])
+ print(f"done: {a.name} created under {a.section}")
+ return 0
+
+
+def build_parser():
+ p = argparse.ArgumentParser(
+ prog="gitctl",
+ description="Thin client for managing repos on a personal gitolite3 + cgit "
+ "server. Edits the local gitolite-admin clone and pushes it, and "
+ "calls a server helper (run as the git user) over a restricted SSH key for the "
+ "privileged cgit edits. Reads ~/.config/gitctl/config.toml; pass "
+ "--self-test to run the built-in assertions.")
+ sub = p.add_subparsers(dest="group", required=True, help="command group")
+
+ sections = sub.add_parser(
+ "sections", help="list or add cgit sections",
+ description="Manage the cgit sections that repos are grouped under.")
+ sec = sections.add_subparsers(dest="action", required=True, help="sections action")
+ sec.add_parser("list", help="print all cgit section names",
+ description="Print every section name currently in the server's cgitrc.")
+ sa = sec.add_parser("add", help="create a cgit section if it does not exist",
+ description="Create a new cgit section. Idempotent: an existing "
+ "section is left as-is.")
+ sa.add_argument("name", help="section title, e.g. 'Generic Projects' (quote if it has spaces)")
+
+ sub.add_parser("sync", help="re-sync cgit descriptions from the bare repos",
+ description="Run the server's description sync, copying each bare repo's "
+ "description file into cgit's repo.desc=.")
+
+ repo = sub.add_parser(
+ "repo", help="create a repo, set its description, or add a git remote",
+ description="Operations on a single repo.")
+ rep = repo.add_subparsers(dest="action", required=True, help="repo action")
+
+ rc = rep.add_parser(
+ "create", help="create a new repo end to end (gitolite + cgit)",
+ description="Two phase create. Phase 1 adds a stanza to the local "
+ "gitolite-admin clone, shows the diff, asks before committing and "
+ "pushing (declining reverts the edit), then polls until gitolite "
+ "has built the bare repo. Phase 2 ensures the cgit section exists, "
+ "files the repo under it, and optionally sets its description. "
+ "Every step is idempotent and safe to re-run.")
+ rc.add_argument("name", help="repo name; used for the gitolite stanza, the bare repo, and cgit")
+ rc.add_argument("--section", required=True,
+ help="cgit section to file the repo under; created automatically if missing")
+ rc.add_argument("--desc", help="optional initial cgit description for the repo")
+ rc.add_argument("--owner",
+ help="cgit repo.owner string (default: default_owner from config)")
+
+ rd = rep.add_parser(
+ "desc", help="set a repo's cgit description",
+ description="Write the repo's description and re-sync. Goes through the bare "
+ "repo's description file (the single writer of cgit repo.desc=); "
+ "never writes repo.desc= directly.")
+ rd.add_argument("name", help="repo name")
+ rd.add_argument("description", help="new description text (quote if it has spaces)")
+
+ rr = rep.add_parser(
+ "add-remote", help="add a git remote for the repo in the current directory",
+ description="Add a git remote pointing at the repo on the server, in the "
+ "current working directory's clone. Idempotent: an existing remote "
+ "of the same name is reported, not duplicated.")
+ rr.add_argument("name", help="repo name on the server (used to build the remote URL)")
+ rr.add_argument("--remote-name", default="origin",
+ help="local remote name to create (default: %(default)s)")
+ return p
+
+
+def main():
+ cfg = load_config()
+ a = build_parser().parse_args()
+
+ if a.group == "sections" and a.action == "list":
+ call_helper(cfg, ["list-sections"]); return 0
+ if a.group == "sections" and a.action == "add":
+ call_helper(cfg, ["add-section", "--name", a.name]); return 0
+ if a.group == "sync":
+ call_helper(cfg, ["sync"]); return 0
+ if a.group == "repo" and a.action == "desc":
+ call_helper(cfg, ["set-desc", "--url", a.name, "--desc", a.description]); return 0
+ if a.group == "repo" and a.action == "add-remote":
+ return cmd_add_remote(cfg, a.name, a.remote_name)
+ if a.group == "repo" and a.action == "create":
+ return cmd_create(cfg, a)
+ return 1
+
+
+if __name__ == "__main__":
+ if len(sys.argv) > 1 and sys.argv[1] == "--self-test":
+ _self_test(); sys.exit(0)
+ sys.exit(main())
diff --git a/gitctl-helper b/gitctl-helper
new file mode 100755
index 0000000..8cdf460
--- /dev/null
+++ b/gitctl-helper
@@ -0,0 +1,422 @@
+#!/usr/bin/env python3
+#
+# gitctl-helper: server helper for gitctl, run as the git user.
+# Copyright (C) 2026 Danilo M. <danix@danix.xyz>
+#
+# This program is free software; you can redistribute it and/or modify
+# it under the terms of the GNU General Public License as published by
+# the Free Software Foundation; either version 2 of the License, or
+# (at your option) any later version.
+#
+# This program is distributed in the hope that it will be useful,
+# but WITHOUT ANY WARRANTY; without even the implied warranty of
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+# GNU General Public License for more details.
+#
+# You should have received a copy of the GNU General Public License along
+# with this program; if not, write to the Free Software Foundation, Inc.,
+# 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA.
+"""gitctl-helper: server helper for gitctl, run as the git user. Dispatches on SSH_ORIGINAL_COMMAND."""
+import os, re, sys, shlex, shutil, subprocess, argparse, datetime
+
+REPO_BASE = "/var/lib/gitolite3/repositories"
+CGITRC = "/etc/cgitrc"
+SYNC_SCRIPT = "/usr/local/bin/sync-cgit-descs.py"
+# Backups go here, not next to CGITRC: the helper runs as the git user, which
+# owns /etc/cgitrc (the file) but cannot create new files in /etc. Must be a
+# git-writable directory.
+BACKUP_DIR = "/var/lib/gitolite3/cgitrc-backups"
+DEFAULT_OWNER = "by Your Name"
+BANNER = "# ---------- {name} ------------#"
+
+URL_RE = re.compile(r"^[A-Za-z0-9._-]+$")
+
+
+def die(msg, code=1):
+ print(msg, file=sys.stderr)
+ sys.exit(code)
+
+
+def validate_url(u):
+ if not u or ".." in u or not URL_RE.match(u):
+ die(f"error: invalid repo name: {u!r}")
+ return u
+
+
+def validate_section(s):
+ if not s or "\n" in s or "\r" in s:
+ die(f"error: invalid section name: {s!r}")
+ return s
+
+
+SECTION_RE = re.compile(r"^section=(.*)$")
+URL_LINE_RE = re.compile(r"^repo\.url=(.+)$")
+# a section banner comment, e.g. "# ---------- Linux ------------#". It belongs
+# to the section below it, so it bounds the preceding section's span.
+BANNER_RE = re.compile(r"^#\s*-{3,}.*-{3,}#\s*$")
+
+
+def list_sections_from(lines):
+ out = []
+ for line in lines:
+ m = SECTION_RE.match(line.rstrip("\n"))
+ if m:
+ out.append(m.group(1))
+ return out
+
+
+def find_repo_section(lines, url):
+ """Return the section a repo.url= belongs to, or None if absent."""
+ current = None
+ for line in lines:
+ s = SECTION_RE.match(line.rstrip("\n"))
+ if s:
+ current = s.group(1)
+ continue
+ u = URL_LINE_RE.match(line.rstrip("\n"))
+ if u and u.group(1) == url:
+ return current
+ return None
+
+
+def repo_path_for(url):
+ return os.path.join(REPO_BASE, url + ".git")
+
+
+def build_repo_block(url, owner):
+ return [
+ f"repo.url={url}\n",
+ f"repo.path={repo_path_for(url)}\n",
+ f"repo.owner={owner}\n",
+ ]
+
+
+def insert_repo_block(lines, section, block):
+ """Insert block at the end of `section`'s span. Raise KeyError if absent."""
+ start = None
+ for i, line in enumerate(lines):
+ m = SECTION_RE.match(line.rstrip("\n"))
+ if m and m.group(1) == section:
+ start = i
+ break
+ if start is None:
+ raise KeyError(section)
+ # end of span = next section= line OR next banner comment (a banner belongs
+ # to the section it precedes), or EOF
+ end = len(lines)
+ for j in range(start + 1, len(lines)):
+ stripped = lines[j].rstrip("\n")
+ if SECTION_RE.match(stripped) or BANNER_RE.match(stripped):
+ end = j
+ break
+ # trim trailing blank lines inside the span so we control spacing
+ insert_at = end
+ while insert_at > start + 1 and lines[insert_at - 1].strip() == "":
+ insert_at -= 1
+ payload = ["\n"] + block + ["\n"]
+ return lines[:insert_at] + payload + lines[insert_at:]
+
+
+def write_cgitrc_lines(path, lines, backup_dir=None):
+ """Back up then write `path` in place. Not atomic: the helper runs as the
+ git user, which owns /etc/cgitrc but cannot create temp inodes in /etc, so
+ an os.replace rename is impossible. The pre-write backup makes a truncated
+ write (crash mid-write) recoverable. backup_dir defaults to the file's own
+ directory; for the real /etc/cgitrc the caller passes BACKUP_DIR, since git
+ cannot create files in /etc."""
+ if os.path.exists(path):
+ bdir = backup_dir or os.path.dirname(os.path.abspath(path))
+ os.makedirs(bdir, exist_ok=True)
+ ts = datetime.datetime.now().strftime("%Y%m%d-%H%M%S")
+ shutil.copy2(path, os.path.join(bdir, os.path.basename(path) + ".bak." + ts))
+ with open(path, "w") as f: # in place, preserves ownership
+ f.writelines(lines)
+
+
+def ensure_section(cgitrc, name, backup_dir=None):
+ """Append a section banner+header if absent. Return True if added."""
+ with open(cgitrc) as f:
+ lines = f.readlines()
+ if name in list_sections_from(lines):
+ return False
+ addition = ["\n", BANNER.format(name=name) + "\n", f"section={name}\n"]
+ write_cgitrc_lines(cgitrc, lines + addition, backup_dir)
+ return True
+
+
+def do_add_repo(cgitrc, url, section, owner, run_sync, backup_dir=None):
+ with open(cgitrc) as f:
+ lines = f.readlines()
+ existing = find_repo_section(lines, url)
+ if existing is not None:
+ if existing == section:
+ print(f"repo {url} already present under section {section}")
+ return 0
+ print(f"error: repo {url} already exists under section {existing!r}, "
+ f"refusing to move it to {section!r}", file=sys.stderr)
+ return 1
+ if section not in list_sections_from(lines):
+ print(f"error: section {section!r} does not exist; run add-section first",
+ file=sys.stderr)
+ return 1
+ new = insert_repo_block(lines, section, build_repo_block(url, owner))
+ write_cgitrc_lines(cgitrc, new, backup_dir)
+ print(f"added repo {url} under section {section}")
+ run_sync()
+ return 0
+
+
+def do_set_desc(desc_path, desc, run_sync, url=None):
+ if not os.path.exists(desc_path):
+ what = f"repo {url!r} not found" if url else f"description file not found: {desc_path}"
+ print(f"error: {what}", file=sys.stderr)
+ return 1
+ st = os.stat(desc_path)
+ with open(desc_path, "w") as f: # in place, preserves owner
+ f.write(desc.rstrip("\n") + "\n")
+ try:
+ os.chown(desc_path, st.st_uid, st.st_gid) # insurance
+ except PermissionError:
+ pass
+ print(f"wrote description to {desc_path}")
+ run_sync()
+ return 0
+
+
+def _self_test():
+ assert validate_url("publisher") == "publisher"
+ assert validate_url("a.b_c-1") == "a.b_c-1"
+ for bad in ["", "../etc", "a/b", "a b", "a;b", "a$b", "a\nb"]:
+ try:
+ validate_url(bad); assert False, bad
+ except SystemExit:
+ pass
+ assert validate_section("Generic Projects") == "Generic Projects"
+ for bad in ["", "a\nb"]:
+ try:
+ validate_section(bad); assert False, bad
+ except SystemExit:
+ pass
+ fixture = [
+ "section=Generic Projects\n",
+ "repo.url=cad-projects\n",
+ "repo.path=/var/lib/gitolite3/repositories/cad-projects.git\n",
+ "\n",
+ "# ---------- SlackBuilds ------------#\n",
+ "section=SlackBuilds\n",
+ "repo.url=my-slackbuilds\n",
+ "repo.path=/var/lib/gitolite3/repositories/my-slackbuilds.git\n",
+ "\n",
+ ]
+ assert list_sections_from(fixture) == ["Generic Projects", "SlackBuilds"]
+ assert find_repo_section(fixture, "cad-projects") == "Generic Projects"
+ assert find_repo_section(fixture, "my-slackbuilds") == "SlackBuilds"
+ assert find_repo_section(fixture, "nope") is None
+
+ block = build_repo_block("publisher", "by Your Name")
+ assert block == [
+ "repo.url=publisher\n",
+ "repo.path=/var/lib/gitolite3/repositories/publisher.git\n",
+ "repo.owner=by Your Name\n",
+ ]
+
+ # insert under the FIRST section: must land before the SlackBuilds banner,
+ # NOT between the banner and its section= line (the banner belongs to the
+ # section it precedes)
+ new = insert_repo_block(fixture, "Generic Projects", block)
+ text = "".join(new)
+ assert "repo.url=publisher" in text
+ # publisher must precede the SlackBuilds banner (and thus its section header)
+ assert text.index("repo.url=publisher") < text.index("# ---------- SlackBuilds")
+ # original repo still there
+ assert "repo.url=cad-projects" in text
+
+ # insert under the LAST section: lands before EOF
+ new2 = insert_repo_block(fixture, "SlackBuilds", block)
+ assert "repo.url=publisher" in "".join(new2)
+
+ # missing section raises
+ try:
+ insert_repo_block(fixture, "Nope", block); assert False
+ except KeyError:
+ pass
+
+ import tempfile as _tf
+ d = _tf.mkdtemp()
+ p = os.path.join(d, "cgitrc")
+ open(p, "w").write("section=X\nrepo.url=a\n")
+ write_cgitrc_lines(p, ["section=X\n", "repo.url=a\n", "repo.url=b\n"], backup_dir=d)
+ assert open(p).read() == "section=X\nrepo.url=a\nrepo.url=b\n"
+ baks = [f for f in os.listdir(d) if f.startswith("cgitrc.bak.")]
+ assert len(baks) == 1, baks
+
+ p2 = os.path.join(d, "cgitrc2")
+ open(p2, "w").write("section=Existing\nrepo.url=z\n")
+ added = ensure_section(p2, "NewSec")
+ assert added is True
+ txt = open(p2).read()
+ assert "section=NewSec" in txt
+ assert "# ---------- NewSec ------------#" in txt
+ # idempotent: second call is a no-op
+ added2 = ensure_section(p2, "NewSec")
+ assert added2 is False
+ assert open(p2).read().count("section=NewSec") == 1
+
+ p3 = os.path.join(d, "cgitrc3")
+ open(p3, "w").writelines([
+ "# ---------- Generic ------------#\n",
+ "section=Generic\n",
+ "repo.url=cad\n",
+ "repo.path=/var/lib/gitolite3/repositories/cad.git\n",
+ "\n",
+ "# ---------- SlackBuilds ------------#\n",
+ "section=SlackBuilds\n",
+ "repo.url=sps\n",
+ "repo.path=/var/lib/gitolite3/repositories/sps.git\n",
+ "\n",
+ ])
+ calls = []
+ rc = do_add_repo(p3, "publisher", "Generic", "by Your Name",
+ run_sync=lambda: calls.append(1))
+ assert rc == 0
+ t = open(p3).read()
+ assert t.index("repo.url=publisher") < t.index("section=SlackBuilds")
+ assert calls == [1] # sync ran after insert
+
+ # idempotent: re-add same url under same section -> no duplicate, success, no sync
+ calls.clear()
+ rc = do_add_repo(p3, "publisher", "Generic", "x", run_sync=lambda: calls.append(1))
+ assert rc == 0
+ assert open(p3).read().count("repo.url=publisher") == 1
+ assert calls == []
+
+ # exists under a DIFFERENT section -> report, non-zero, no write
+ rc = do_add_repo(p3, "publisher", "SlackBuilds", "x", run_sync=lambda: calls.append(1))
+ assert rc != 0
+
+ # missing section -> non-zero
+ rc = do_add_repo(p3, "newrepo", "Nope", "x", run_sync=lambda: calls.append(1))
+ assert rc != 0
+
+ repo_dir = os.path.join(d, "publisher.git")
+ os.makedirs(repo_dir)
+ desc_path = os.path.join(repo_dir, "description")
+ open(desc_path, "w").write("Unnamed repository; edit this file ...\n")
+ calls.clear()
+ rc = do_set_desc(desc_path, "my real description",
+ run_sync=lambda: calls.append(1))
+ assert rc == 0
+ assert open(desc_path).read() == "my real description\n"
+ assert calls == [1]
+
+ # missing repo -> non-zero, no sync
+ calls.clear()
+ rc = do_set_desc(os.path.join(d, "ghost.git", "description"), "x",
+ run_sync=lambda: calls.append(1), url="ghost")
+ assert rc != 0
+ assert calls == []
+
+ print("self-test OK")
+
+
+def run_sync():
+ r = subprocess.run([sys.executable, SYNC_SCRIPT])
+ if r.returncode != 0:
+ die(f"error: sync exited {r.returncode}", r.returncode)
+
+
+def main():
+ raw = os.environ.get("SSH_ORIGINAL_COMMAND")
+ argv = shlex.split(raw) if raw else sys.argv[1:]
+ if argv and argv[0] == "--self-test":
+ _self_test(); return 0
+
+ p = argparse.ArgumentParser(
+ prog="gitctl-helper",
+ description="Server helper for gitctl, run as the git user. Normally invoked by the "
+ "client over a command=-restricted SSH key, which forces this "
+ "script and passes the verb plus arguments in SSH_ORIGINAL_COMMAND. "
+ "It is the single privileged writer of /etc/cgitrc and of each bare "
+ "repo's description file. Can also be run directly for local "
+ "testing; pass --self-test to run the built-in assertions.")
+ sub = p.add_subparsers(dest="verb", required=True,
+ help="server-side operation to perform")
+
+ sub.add_parser("list-sections",
+ help="print every cgit section name, one per line",
+ description="Read /etc/cgitrc and print each section= name in file order.")
+
+ sp = sub.add_parser("add-section",
+ help="append a new cgit section header if absent",
+ description="Append a banner and section= header to /etc/cgitrc. "
+ "Idempotent: a section that already exists is left "
+ "untouched. Prints 'added' or 'exists'.")
+ sp.add_argument("--name", required=True,
+ help="section title as shown in cgit, e.g. 'Generic Projects'")
+
+ ar = sub.add_parser("add-repo",
+ help="insert a repo block under a section in cgitrc, then sync",
+ description="Insert a repo.url/path/owner block at the end of the "
+ "named section's span in /etc/cgitrc (positionally, "
+ "before the next section or EOF), then run the cgit "
+ "description sync. Idempotent under the same section; "
+ "refuses to move a repo that already exists under a "
+ "different section.")
+ ar.add_argument("--url", required=True,
+ help="repo name as it appears in cgit and on disk (becomes <url>.git)")
+ ar.add_argument("--section", required=True,
+ help="existing section to file the repo under; create it first with add-section")
+ ar.add_argument("--owner", default=DEFAULT_OWNER,
+ help="repo.owner string shown in cgit (default: %(default)r)")
+
+ sd = sub.add_parser("set-desc",
+ help="write a bare repo's description file, then sync",
+ description="Write the bare repo's description file in place "
+ "(preserving its git:gitolite3 ownership) and run the "
+ "sync script. This is the only supported way to change "
+ "a cgit repo.desc=; it is never written directly.")
+ sd.add_argument("--url", required=True, help="repo name (the bare repo is <url>.git)")
+ sd.add_argument("--desc", required=True, help="description text (a single trailing newline is enforced)")
+
+ sub.add_parser("sync",
+ help="run the cgit description sync script",
+ description="Run sync-cgit-descs.py, which copies each bare repo's "
+ "description into cgit's repo.desc=. The single writer of repo.desc=.")
+
+ re_ = sub.add_parser("repo-exists",
+ help="exit 0 if the bare repo exists, 1 otherwise",
+ description="Check whether <url>.git exists under the repository "
+ "base. Used by the client to poll after a gitolite "
+ "push until gitolite has built the bare repo.")
+ re_.add_argument("--url", required=True, help="repo name to test for (as <url>.git)")
+
+ a = p.parse_args(argv)
+
+ if a.verb == "list-sections":
+ with open(CGITRC) as f:
+ for s in list_sections_from(f.readlines()):
+ print(s)
+ return 0
+ if a.verb == "add-section":
+ validate_section(a.name)
+ print("added" if ensure_section(CGITRC, a.name, BACKUP_DIR) else "exists")
+ return 0
+ if a.verb == "add-repo":
+ validate_url(a.url); validate_section(a.section)
+ return do_add_repo(CGITRC, a.url, a.section, a.owner, run_sync, BACKUP_DIR)
+ if a.verb == "set-desc":
+ validate_url(a.url)
+ return do_set_desc(os.path.join(repo_path_for(a.url), "description"),
+ a.desc, run_sync, url=a.url)
+ if a.verb == "sync":
+ run_sync(); return 0
+ if a.verb == "repo-exists":
+ validate_url(a.url)
+ return 0 if os.path.isdir(repo_path_for(a.url)) else 1
+ return 1
+
+
+if __name__ == "__main__":
+ if len(sys.argv) > 1 and sys.argv[1] == "--self-test" and not os.environ.get("SSH_ORIGINAL_COMMAND"):
+ _self_test(); sys.exit(0)
+ sys.exit(main())
diff --git a/skills/gitctl/SKILL.md b/skills/gitctl/SKILL.md
new file mode 100644
index 0000000..b701153
--- /dev/null
+++ b/skills/gitctl/SKILL.md
@@ -0,0 +1,68 @@
+---
+name: gitctl
+description: Use when the user wants to create, list, or describe repos on a personal gitolite3 + cgit server - e.g. "make a repo on my server", "add a cgit section", "set the description for repo X", "add the server remote". gitctl is the CLI that does this; do not edit gitolite.conf or cgitrc by hand.
+---
+
+# gitctl
+
+`gitctl` is a client CLI that manages repos on a personal gitolite3 + cgit
+server through a `command=`-restricted SSH key to a server-side helper. Run
+`gitctl --help` (and `gitctl <group> --help`) for the full flag list; this skill
+is about *when* to use it and the *gotchas*, not re-listing flags.
+
+If `gitctl` is not on PATH or no `~/.config/gitctl/config.toml` exists, the tool
+is not set up on this machine - point the user at the project README ("SSH
+setup" and "Client install") rather than guessing.
+
+## When to use
+
+- User wants a NEW repo on the server -> `gitctl repo create`
+- List/add cgit sections (categories) -> `gitctl sections list|add`
+- Set a repo's cgit description -> `gitctl repo desc`
+- Add the server as a git remote in the CWD repo -> `gitctl repo add-remote`
+- Force a cgit description re-sync -> `gitctl sync`
+
+Do NOT hand-edit `gitolite.conf`, the server's `cgitrc`, or `repo.desc=`.
+gitctl owns those edits and keeps them idempotent and backed up.
+
+## Creating a repo (the two-phase flow)
+
+```
+gitctl repo create <name> --section <Section> [--desc "..."] [--owner "..."]
+```
+
+Phase 1 edits the local gitolite-admin clone, shows the diff, **asks y/N**,
+commits, pushes, then polls until gitolite builds the bare repo. Phase 2 adds
+it to cgit under the section and (if given) sets the description.
+
+Then wire up the new repo for the user:
+```
+gitctl repo add-remote <name> --remote-name origin
+git push -u origin master
+```
+
+## Gotchas
+
+- **`repo create` prompts y/N -> it needs a TTY.** Do not run it through a
+ non-interactive pipe expecting it to just work; let the user confirm, or have
+ them run it. The phase-1 push may also require a hardware-key touch/PIN, which
+ only works interactively.
+- **If the push fails after the local commit** (key refused, auth failure): the
+ gitolite-admin commit is made but unpushed. The user pushes manually
+ (`git -C <admin-clone> push origin <branch>`), then you RE-RUN the same
+ `gitctl repo create` command. It is idempotent: it detects the existing
+ stanza, skips phase 1, and finishes phase 2. Do not start over.
+- **Section names with spaces must be quoted**: `--section "My Sites"`.
+- **No delete verb.** Removing a repo/section is deliberately manual (server
+ side). Do not script deletion through gitctl.
+- **`--desc` is the only way to set a cgit description.** It writes the bare
+ repo's `description` file and runs the sync script (the single writer of
+ `repo.desc=`). Never write `repo.desc=` directly.
+
+## Discovering current state
+
+```
+gitctl sections list # existing cgit sections (categories)
+```
+There is no repo-list verb on the client; a section is required for create, so
+list sections first if unsure which category to use.