overview showed dirty and ahead/behind per project, which git can do on its own. mgsh is the only thing that sees both the local base directory and the ssh server, and joining those answers the questions git cannot: which projects were never pushed to the server (candidates for `init`), and which exist there but not on this machine (candidates for `clone`). Both lists are printed after the summary. An unreachable server is reported as such, rather than as "everything is missing". Each row also names the machine that made the last commit and how long ago. That costs nothing: `push` has always stamped "[user@host]" into the commit message, and nothing ever read it back. On a setup spanning several machines it is usually the piece one actually wanted. Rows also show which mirror targets the repository has a remote for, which is local git config and therefore free. The walk is now concurrent and cheaper per project: `git status --porcelain=v2 --branch` yields branch, upstream, ahead/behind and dirty in one subprocess where three were used before. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
441 lines
20 KiB
Markdown
441 lines
20 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)
|
|
- [Overview](#overview) · [Credential check](#credential-check)
|
|
- [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` | inventory of all projects, local and on the server |
|
|
| `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.
|
|
|
|
### Overview
|
|
|
|
`overview` (or `status -a`) is the one view that needs mgsh: it is the only
|
|
thing that sees the local base directory *and* the git server at once.
|
|
|
|
```
|
|
< src > overview
|
|
mgsh * ↑2 · desktop 3h · → hub,gitea
|
|
notes ✓ · laptop 2d
|
|
website ✓ (no upstream) · laptop 20d
|
|
3 projects · 1 dirty · 1 in sync
|
|
not on the git server: scratch, experiments (init)
|
|
not cloned here: oldproject (clone)
|
|
```
|
|
|
|
Per project: dirty marker, commits ahead/behind the upstream, the branch when it
|
|
is not `master`/`main`, and the mirror targets the repository has a remote for.
|
|
|
|
The machine and age come from the commit itself — `push` writes `[user@host]`
|
|
into every message, so `overview` can say where a project was last worked on
|
|
without storing anything. On a setup spanning a laptop and a workstation that is
|
|
usually the piece of information you actually wanted.
|
|
|
|
The two lists at the end are the join no git command can do: local projects the
|
|
server has never seen (`init` them) and server repositories missing on this
|
|
machine (`clone` them). If the server cannot be reached, mgsh says so instead of
|
|
claiming everything is missing.
|
|
|
|
### Credential check
|
|
|
|
`push` runs `git add --all .`, so anything lying in the project gets committed —
|
|
and with `mirror = true` it reaches a public server in the same breath. That is
|
|
the only action in mgsh that cannot be undone: a deleted server repository comes
|
|
back from an archive, a published credential does not.
|
|
|
|
So before anything is committed, the staged diff is checked for private keys,
|
|
GitHub/GitLab/Slack/AWS/PyPI tokens and credential-shaped assignments:
|
|
|
|
```
|
|
< src/notes > push new notes
|
|
2 possible credential(s) in what is about to be committed:
|
|
.env:3 credential assignment
|
|
API_KEY="<the offending line is shown in full here>"
|
|
deploy_key:1 private key
|
|
<the BEGIN … PRIVATE KEY header is shown here>
|
|
(set 'secretscan = off' to skip this check)
|
|
push anyway? y/N ?
|
|
```
|
|
|
|
Declining stops the push with nothing committed; the changes stay staged, so
|
|
`git restore --staged <file>` and a `.gitignore` entry are all it takes.
|
|
|
|
For a line that only *looks* like a credential and is meant to stay, put
|
|
`mgsh:allow` in it — a comment on that line is enough. That is better than
|
|
turning the whole check off for one false positive.
|
|
|
|
This is not a complete secret scanner and does not try to be one. It aims for a
|
|
high hit rate on what actually leaks, with few enough false alarms that the
|
|
prompt still means something: values that are plainly environment references,
|
|
constants, template slots (`<token>`, `${VAR}`) or masked stand-ins are ignored —
|
|
a test checks that mgsh's own README and `mgshrc.example`, both full of
|
|
credential-shaped text, stay quiet. Switch it off with `secretscan = off`.
|
|
|
|
### 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 |
|
|
| `secretscan` | project | `off` disables the credential check `push` runs before committing (on by default; only an explicit `off` disables it) |
|
|
|
|
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.
|