Files
mgsh/README.md
T
mikeandClaude Opus 5 915ef1783a Add release: publish tagged releases on the mirror servers
`release [@name ...] <tag> [notes]` does the whole chain in one step —
create the annotated tag, push it to the internal server, then push it to
each selected mirror and turn it into a release object there. Target
selection reuses pushremote's @name mechanism, so the two behave alike.

Notes are generated when none are given: the tag's own annotation when it
carries more than the default, otherwise the commit subjects since the
previous tag, capped at 50 lines. `tag add v1.0 "why this exists"` now
takes a message, which is what that fallback reads; before, the
annotation was always just the tag name.

Tags ending in -rc/-alpha/-beta/-pre are marked as pre-releases on Gitea
and GitHub. Releasing the same tag twice updates the existing release;
a tag that already points at a different commit stops the command, since
moving a published tag makes one version mean different things per
server. A repository that is not on the mirror yet is reported instead of
being created as a side effect.

Binary assets are deliberately out of scope: Gitea attaches them to the
release, GitHub uses a separate upload host, and GitLab does not host
them at all but wants a link into its package registry.

The providers differ in path shape and field names -- GitLab addresses
projects by URL-encoded path, calls the notes "description" and has no
pre-release flag -- so this comes with a recording httptest stand-in that
asserts the exact requests for all three. That harness also covers
authUser, repoExists and the auth header forms, which had no test at all.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-26 15:47:05 +02:00

376 lines
16 KiB
Markdown

# mgsh — git shell
A small interactive shell / command-line wrapper around a self-hosted bare git
server reachable over ssh. It manages a flat set of projects living under a base
directory. Go port of the original Perl `mgsh` (`mgsh.perl`).
## Contents
- [Build](#build) · [Usage](#usage) · [Commands](#commands) · [Aliases](#aliases)
- [Public mirror (`pushremote`)](#public-mirror-pushremote) ·
[Releases](#releases)
- [Configuration](#configuration) · [Settings reference](#settings-reference) ·
[Per-project configuration](#per-project-configuration)
- [Git server layout](#git-server-layout)
## Build
```sh
./build.sh # builds ./mgsh and bumps the patch version by 0.0.1
go build -o mgsh . # plain build, keeps the default version
```
`build.sh` reads `version.txt`, increments the patch component, injects it via
`-ldflags -X main.VERSION`, and writes it back — so `version.txt` always holds
the version of the binary just built. Dependencies are fetched via Go modules
(`go.mod` / `go.sum`) on first build.
Run the tests with `go test ./...`.
## Usage
Launch `mgsh` for the interactive shell, or run a single command directly from a
project directory, e.g. `mgsh push "message"`, `mgsh status`, `mgsh log`.
In command-line mode the project is taken from the working directory: anywhere
below `<base>/myproject` — however deep — the active project is `myproject`, and
the command applies to the whole project, not the subdirectory you happen to
stand in. Outside `base` no project is selected. `mgsh <project>` starts the
interactive shell with that project preselected.
The commands available directly from the shell are `clone`, `init`, `log`,
`push`, `pushremote`, `release`, `list`, `tag`, `archive`, `show`, `open`,
`pull`, `fetch`, `status`, `diff`, `overview`, `config`, `count`, `login` and
`cloneall`; every other command is interactive-only.
The interactive prompt is colored (Catppuccin Mocha) and shows the active
project, its git branch and a `*` dirty marker:
```
< src/myproject (master*) >
```
Features: command history (`~/.mgsh_history`), Tab completion (commands, local
projects for `cd`/`open`, server repos for `clone`/`show`, branches/tags for
`checkout`/`tag`, mirror targets for `pushremote`, filesystem paths for `dist`),
and colored `list`/`log`/error output.
The server repository list is fetched once per session on the first Tab that
needs it; `rescan` refreshes it (and reloads the configuration).
Exit with `quit`, `exit`, `Ctrl-D`, or `Ctrl-C` on an empty line.
### Shell escape
Unknown commands are **not** forwarded to a shell. To run a shell command,
prefix it with `!`:
```
< src/myproject > !ls -la
```
### Commands
Run `help` for the full list. Highlights:
| command | description |
|---------------------------|------------------------------------------------|
| `cd [project]` | change project (no argument: back to the base) |
| `push [comment]` | commit everything and push to the server |
| `pushremote [desc]` | mirror the repo to a public server (gitea/github/gitlab) |
| `pull` / `fetch` | pull / fetch from the server |
| `status [-a]` / `diff` | short git status (`-a`: overview of all projects) |
| `overview` | dirty / ahead-behind summary of all projects |
| `log` | show the project log |
| `edit [n]` | interactive rebase of the last n commits |
| `clone [-a] <repo>` | clone a repository (or archive) from the server |
| `list [-a] [pattern]` | list repositories on the server |
| `show <repo>` | show a repository log directly on the server |
| `archive [comment]` | snapshot the server-side repo into `./archive` |
| `init` | make a new repository from the current directory |
| `tag [add/checkout/delete]` | manage tags (`tag add v1.0 "why"` annotates) |
| `release [@name] <tag> [notes]` | tag and publish a release on the mirrors |
| `alias [name [cmd]]` | list, show or define a command alias |
| `unalias <name>` | remove a command alias |
| `config [-k]` | show the effective configuration and its sources |
| `rescan` | reload the config, refresh the cached repo list |
| `!<command>` | run `<command>` in the shell |
Commands that touch a repository (`push`, `pull`, `log`, `diff`, `tag`, `dist`,
`checkout`, `edit`, …) require an active project with a `.git`; they refuse to
run rather than silently operating on the base directory. `checkout` and `diff`
forward their arguments to git unchanged, so `checkout -b topic` works.
`config` is the quickest way to see what mgsh actually resolved — which server,
which mirror targets, and which of them came from the project's own `.mgshrc`:
```
< src/myproject > config
global /Users/me/.mgshrc
project /Users/me/src/myproject/.mgshrc
base /Users/me/src
githost git.example.com
gitport 22
gituser git
gitpath /home/git
editor code (.mgshrc)
remotes hub (.mgshrc)
clone url ssh://git@git.example.com:22/home/git
pushremote targets (in push order):
@hub https://github.com github (detected), public, key gh***************xk
```
Tokens are masked, so the output is safe to paste into a bug report.
`config -k` prints just the setting names, one per line.
### Aliases
`alias <name> '<command>'` defines a reusable shortcut, persisted to
`~/.mgshrc` and reloaded on every start. The expansion is itself a mgsh
command line and may reference the alias arguments:
| placeholder | meaning |
|-------------|----------------------------------|
| `$1``$N` | the Nth argument (empty if unset)|
| `$*` / `$@` | all arguments, space-joined |
When the expansion contains no placeholder, the arguments are appended (classic
shell-alias behaviour). Because unknown commands are **not** forwarded to a
shell, a shell command inside an alias needs the `!` prefix:
```
alias co 'checkout $1' # co v2 -> checkout v2 (builtin)
alias p 'push $*' # p fixed bug -> push fixed bug (builtin)
alias ec '!echo $1' # ec hello -> echo hello (shell)
```
`alias` with no arguments lists all aliases, `alias <name>` shows one, and
`unalias <name>` removes it. Aliases cannot shadow builtin commands.
### Public mirror (`pushremote`)
Besides the internal ssh git server, `pushremote` mirrors the active project to
one or more public hosting servers (Gitea, GitHub or GitLab) over their REST
API. A single server is configured flat:
```ini
remoteurl = https://git.example.com # base URL of the server
remotekey = <personal-access-token> # API token
# remotetype = gitea # optional; auto-detected from remoteurl
# remotevisibility = private # visibility of created repos (default private)
# mirror = true # `push` also mirrors via pushremote
```
Several servers get one `remote.<name>.*` block each:
```ini
remote.gitea.url = https://git.example.com
remote.gitea.key = <personal-access-token>
remote.hub.url = https://github.com
remote.hub.key = <personal-access-token>
remote.hub.visibility = public
# remotes = gitea, hub # optional: restrict and order the set
```
| command | pushes to |
|------------------------|-----------------------------------------------|
| `pushremote` | every configured target, in order |
| `pushremote @hub` | only `hub` |
| `pushremote @hub @gitea` | those two |
| `pushremote a fix` | every target, description "a fix" |
Each target owns a git remote of the same name in the repository (the flat form
uses `public`, as before), so `git push hub` keeps working outside mgsh. A
target that fails does not stop the others; with more than one target
`pushremote` prints an `n/m remotes updated` summary. `remotes = …` restricts
and orders the set, which is mostly useful in a project `.mgshrc` — see below.
`pushremote` authenticates with the token, creates the repository (named after
the current project) if it does not exist yet, adds the credential-free remote,
and pushes all branches and tags. New repositories are **private** unless
`visibility = public`; any words after the command (and after any `@name`) are
set as the repository description on creation.
The token is sent as a one-shot HTTP auth header: it is never written into the
repo's git config, and it reaches git through the environment rather than the
command line, so it does not show up in the process table. Because `~/.mgshrc`
then holds a credential, mgsh creates it mode `600` and warns at startup if an
existing file is readable by others. The provider is auto-detected from `remoteurl` (`github.com`
GitHub, `gitlab*` → GitLab, otherwise Gitea) and can be forced with
`remotetype`. Set `mirror = true` to have every `push` mirror automatically.
### Releases
`release` turns a commit into a published release on the mirror servers, in one
step — creating the tag, getting it onto the internal server, and then onto each
mirror as a release object:
```
release [@name ...] <tag> [notes]
```
```
< src/mgsh > release v4.1.0 first public build
remote hub released https://github.com/mike/mgsh.git
```
Without `@name` it releases to every configured mirror target, exactly like
`pushremote`. Everything after the tag becomes the release notes *and* the tag's
annotation.
**Notes are generated when you do not write any**: the tag's own annotation if
it has a real one, otherwise the commit subjects since the previous tag
(`- <subject>` per line, at most 50). So a plain `release v4.1.0` already
produces a usable changelog.
A tag ending in `-rc`, `-alpha`, `-beta` or `-pre` (optionally with digits) is
marked as a **pre-release** on Gitea and GitHub; GitLab has no such flag.
Releasing the same tag twice updates the existing release rather than failing.
But a tag that already exists **on a different commit** stops the command —
moving a published tag is how one version quietly comes to mean different things
on different servers. `release` also refuses when the repository is not on the
mirror yet and tells you to run `pushremote` first, rather than creating it as a
side effect.
**No binary assets.** The three providers handle uploads in three incompatible
ways — Gitea attaches them to the release, GitHub uses a separate upload host,
and GitLab does not host them at all but expects a link into its package
registry. mgsh publishes source releases with notes; if you need binaries,
upload them with the provider's own tooling.
## Configuration
mgsh has **no built-in defaults**. Settings are resolved in three steps, each
overriding the one before:
```
~/.mgshrc -> <project>/.mgshrc -> MGSH_* environment variables
```
On first run mgsh writes a blank, annotated `~/.mgshrc` template (migrating any
aliases from a pre-4.x `~/.mgsh_aliases`) and then exits with an error until the
required settings — `base`, `githost`, `gitport`, `gituser`, `gitpath` — are
filled in. The global file must be complete on its own: mgsh has to work outside
any project, so a project file can only refine it, never complete it.
It uses simple `key = value` (or `key: value`) lines; a `#` preceded by
whitespace starts a comment, so a value may still contain a literal `#` (quote
the value to keep a trailing comment-like part). Alias definitions live in the
same file:
```ini
# --- required ---
base = /Users/me/src
githost = git.example.com
gitport = 22
gituser = git
gitpath = /home/git
# --- optional ---
gitname = Your Name
gitemail = you@example.com
pushdefault = matching
editor = code # fallback opener for `open`
alias co 'checkout $1'
```
`config` shows the resolved result at any time.
### Settings reference
Every setting can also be given as an environment variable named `MGSH_<KEY>`
(e.g. `MGSH_GITHOST`), which wins over both files. "Scope" says whether a
project `.mgshrc` may override the setting.
| setting | scope | meaning |
|---|---|---|
| `base` | global | directory holding the projects — **required** |
| `githost` | project | git server host — **required** |
| `gitport` | project | ssh port — **required** |
| `gituser` | project | ssh user on the git server — **required** |
| `gitpath` | project | path of the bare repositories on the server — **required** |
| `gitkey` | project | ssh identity for the git server: a bare name is looked up in `~/.ssh`, a path is used as given. Applies to mgsh's own ssh calls and to git's ssh transport. Unset = normal ssh/`~/.ssh/config` behaviour |
| `gitname` | global | `user.name` written to the **global** git config at startup |
| `gitemail` | global | `user.email` written to the global git config |
| `pushdefault` | global | `push.default` written to the global git config |
| `editor` | project | opener used by `open`/`view` when the project has no Xcode workspace (default `coda`) |
| `remoteurl` | project | base URL of a single mirror server (target name `public`) |
| `remotekey` | project | API token for `remoteurl` |
| `remotetype` | project | `gitea`\|`github`\|`gitlab`; auto-detected from the URL when unset |
| `remotevisibility` | project | `private` (default) or `public` for repositories created by `pushremote` |
| `remote.<name>.url` | project | base URL of the named mirror target |
| `remote.<name>.key` | project | API token for that target |
| `remote.<name>.type` | project | provider override for that target |
| `remote.<name>.visibility` | project | visibility for that target |
| `remotes` | project | comma- or space-separated list restricting and ordering the mirror targets |
| `mirror` | project | truthy (`1`/`true`/`yes`/`on`) → every `push` also mirrors |
The three settings written to the global git config are applied at startup, and
only when they actually differ, so a plain `mgsh status` does not rewrite
`~/.gitconfig`.
### Per-project configuration
A project may carry its own `.mgshrc`, which overrides the global settings while
that project is active — a project on a different git server, with a different
editor, or mirrored to a different place:
```ini
# ~/src/myproject/.mgshrc
githost = other.example.com
remotes = hub # mirror this project to github only
mirror = true
```
It is read when you `cd` into the project and applies to every command that
follows. `base`, `gitname`, `gitemail` and `pushdefault` stay global — `base`
defines where projects live, and the identity keys are written to your *global*
git config, which should not change as you move between projects. mgsh says so
when a project file sets one of them. Aliases also remain global; `alias` always
writes to `~/.mgshrc`.
`rescan` re-reads both files, so an edit takes effect without restarting. A
reload that would leave the global config incomplete is refused, and a changed
`base` needs a restart — mgsh says so rather than half-applying it.
A project `.mgshrc` is a normal file in the working tree, and `push` commits
everything: **keep API tokens out of it** unless it is git-ignored. mgsh warns
when a project config holds a token that git is not ignoring — including before
`init`, since that first commit would publish it too.
See `mgshrc.example` for an annotated template.
## Git server layout
mgsh expects nothing more than a user with ssh access whose **login directory is
`gitpath`** — every remote command runs there without a `cd`. Bare repositories
live directly in it, snapshots in an `archive/` subdirectory:
```
/home/git/ <- gitpath, and the login directory of gituser
├── myproject.git <- created by `init`, cloned by `clone`
├── notes.git
└── archive/ <- created by hand; needed by `archive` and `clone -a`
└── myproject_170126.1432_before_rewrite.git.tar.gz
```
Setting this up once:
```sh
ssh git@git.example.com 'mkdir -p /home/git/archive'
```
Ownership does not matter — `list` reads whatever `ls -ltr` reports, whichever
user or group owns the repositories. Everything mgsh runs there is plain
`ls`/`mkdir`/`cp`/`tar`/`rm` plus `git --bare init`, with all names shell-quoted,
so a restricted shell that allows those commands is enough.
`archive` copies the *server-side* repository (not the working tree) to
`archive/<project>_DDMMYY.HHMM[_comment].git.tar.gz`; `clone -a <name>` unpacks
one and clones it back. `list -a` lists them.