`release` now uploads every file in the project's ./bin and ./assets when
those directories exist. Nothing to configure, and nothing happens for a
project that has neither.
This is the part deliberately left out when `release` was written,
because it is where the three providers stop resembling each other:
Gitea multipart POST to .../releases/<id>/assets?name=<name>
GitHub raw POST to the separate upload host the release object names
in upload_url, whose RFC 6570 template suffix has to go first
GitLab a release stores links, not files: the file goes into the
project's generic package registry and the release gets a
package link pointing at it
So findRelease and createRelease now return a releaseRef carrying the id
and, for GitHub, that upload host -- the id alone cannot address an
upload. Uploads stream from disk rather than buffering: these are whole
binaries, and the Gitea multipart body is assembled through a pipe.
Only regular files directly in those directories are taken. Symlinks are
skipped, which matters here: build.sh leaves bin/mgsh pointing at one of
its siblings, and uploading the same 9M twice under two names helps
nobody. A name present in both directories is used from bin and reported
for assets. Re-releasing a tag replaces same-named assets rather than
failing on them, since rebuilding and publishing again is the normal
reason to do it, and a file that fails does not stop the rest.
Each provider's request shape is pinned down against the recording
stand-in, and the whole chain was run once end to end -- real repository,
real binaries, a fake Gitea that also serves git-http-backend so the tag
push is real too.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
24 KiB
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 · Usage · Commands · Aliases
- Overview · Credential check
- Public mirror (
pushremote) · Releases - Configuration · Settings reference · Per-project configuration
- Git server layout
Build
./build.sh # all platforms into ./bin, bumps the patch version
PLATFORMS="linux/amd64" ./build.sh # just one
go build -o mgsh . # plain build, keeps the default version
build.sh cross-compiles for darwin/arm64, darwin/amd64, linux/amd64 and
linux/arm64 into ./bin:
bin/mgsh -> mgsh-darwin-arm64 (this machine)
bin/mgsh-darwin-amd64
bin/mgsh-darwin-arm64
bin/mgsh-linux-amd64
bin/mgsh-linux-arm64
bin/mgsh is a symlink to the build for the host, so there is one stable path
to "the binary for this machine". bin/ is git-ignored. Windows is deliberately
absent: mgsh shells out to stty and /bin/sh, so it would compile there and
then not work.
Everything is built with CGO_ENABLED=0, which makes the cross builds need no
toolchain per target and the binaries static; os/user resolves the current
user without cgo on both darwin and linux.
It reads version.txt, increments the patch component, injects it via
-ldflags -X main.VERSION into all platforms of that run, and writes it
back — so version.txt always holds the version of the binaries just built, and
they all carry the same one. 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,
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, server repos for clone/show, branches/tags for
checkout/tag, mirror targets for pushremote/release, filesystem paths for
dist, and shell-style completion after ! and for aliases that expand to
one), 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
It runs in the active project's directory. Tab completion works there the way it
does in a shell: the word after the ! completes against the executables on
PATH, everything after it against the filesystem — relative to the project,
with ~/ and absolute paths understood, and directories completing with their
trailing slash so the next Tab walks into them. Dot entries stay out of the way
until the prefix asks for one.
< src/myproject > !vi ma<Tab> -> !vi main
< src/myproject > !vi <Tab> -> Makefile main.go main_test.go src/
< src/myproject > !gre<Tab> -> grep gresource
An alias that expands to a shell escape completes the same way, because its arguments end up as shell arguments:
alias ll '!ls -la'
< src/myproject > ll ma<Tab> -> ll main
Only the alias's arguments complete, never its first word — the command is
fixed by the alias body. An alias to a builtin (alias co 'checkout $1') is not
a shell line and is left alone.
Word splitting for completion is by whitespace only; quotes and backslash escapes are left to the shell that runs the line.
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 (-a: archives, with sizes) |
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
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.
Listing the server
list shows what is on the git server, name first and aligned, ordered by
modification time — push touches the bare repository, so the most recently
worked-on project sits closest to the prompt:
< src > list
Betaflight3.0.0 Sep 28 2016 181M
website Mar 3 2024 2.1M
notes Jan 3 14:32 876K
3 repositories · 184M
The size is the repository's real disk usage on the server, asked of du in
the same round trip as the listing — a long listing reports the inode size for
a directory, which is the same number for every repository and says nothing. If
the server produces no usable sizes the column is left out rather than filled
with zeroes.
list -a lists the archives instead, whose sizes come from the listing itself;
a pattern filters by name (list note).
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
notes * ↑2 laptop 3h
website * ✓ desktop 2d → hub
Betaflight3.0.0 ✓ workstation 20d → gitea hub
experiments init
sandbox (wip) – laptop 1h init
4 projects · 2 dirty · 2 in sync · 2 to init
Every field sits in its own column, so the eye can go down one instead of hunting along each line. The projects that need something done come first, the settled ones next, and the directories the git server does not have yet come last — those are a different kind of task. Within each group the order stays alphabetical, so positions do not jump around.
| column | meaning |
|---|---|
| name | the project, with its branch appended when it is not master/main |
* |
uncommitted changes |
↑n ↓n |
commits ahead of / behind the upstream (↑2↓1 when both) |
✓ |
in sync with the upstream |
– |
the branch tracks nothing — never pushed |
init |
the git server does not have this one; run init |
| host, age | who last committed and when, from the [user@host] stamp |
→ |
mirror targets this repository has a remote for |
Every directory under the base gets a row, including those that are not
repositories at all — init is exactly what turns one into a project, so it
belongs in the table rather than in a list underneath it. Such a row simply has
no git state to show.
The host 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 init column is the join no git command can do. If the server cannot be
reached, no row is marked and mgsh says so — not knowing is not the same as
knowing they are missing. The other direction, repositories on the server that
are not here, is what list shows.
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. Each server is one remote.<name>.<field> block:
remote.gitea.url = https://git.example.com
remote.gitea.key = <personal-access-token>
remote.gitea.type = gitea # optional; auto-detected from the url
remote.gitea.visibility = private # or public (default private)
remote.gitlab.url = https://gitlab.example.com
remote.gitlab.key = <personal-access-token>
remote.gitlab.type = gitlab
remote.gitlab.visibility = public
# remotes = gitea, gitlab # optional: restrict and order the set
# mirror = true # `push` also mirrors via pushremote
<name> is yours to pick; there is no other spelling. Older versions had a flat
remoteurl/remotekey pair for a single server — mgsh converts those to
remote.public.* in place on the next start and says so, keeping the git remote
name those versions used.
| command | pushes to |
|---|---|
pushremote |
every configured target, in order |
pushremote @gitea |
only gitea |
pushremote @gitea @gitlab |
those two |
pushremote a fix |
every target, description "a fix" |
Each target owns a git remote of the same name in the repository, so
git push gitlab 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 the url
(github.com → GitHub, gitlab* → GitLab, otherwise Gitea) and can be forced
with remote.<name>.type. 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.
Binaries and assets
If the project has a ./bin or ./assets directory, every file in it is
attached to the release — nothing to configure, and nothing happens for a
project that has neither:
< src/mgsh > release v4.1.0
attaching 5 assets, 38M from ./bin and ./assets
remote gitea released https://git.example.com/mike/mgsh.git
uploading logo.png 2.0K
uploading mgsh-darwin-amd64 9.8M
uploading mgsh-darwin-arm64 9.2M
uploading mgsh-linux-amd64 9.7M
uploading mgsh-linux-arm64 8.9M
Only regular files directly in those directories are taken: subdirectories are
not descended into, and symlinks are skipped — bin/mgsh points at one of its
own siblings, and uploading the same binary twice under two names helps nobody.
A name present in both directories is used from bin and reported for
assets, since one asset name can only mean one file.
Re-releasing the same tag replaces same-named assets instead of failing or piling up duplicates, because rebuilding and publishing again is the normal reason to do it. A file that fails to upload does not stop the rest.
This is where the providers stop resembling each other, and mgsh papers over it:
| how the bytes get there | |
|---|---|
| Gitea | multipart POST to …/releases/<id>/assets?name=<name> |
| GitHub | raw POST to the separate upload host named by the release's upload_url |
| GitLab | a release stores links, not files: the file goes into the project's generic package registry and the release gets a package link pointing at it |
The GitLab route needs the package registry enabled on the project — it is on by default, but a self-hosted instance can turn it off.
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:
# --- 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
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; a mirror field is
MGSH_REMOTE_<NAME>_<FIELD>, so MGSH_REMOTE_GITLAB_KEY sets
remote.gitlab.key. "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 |
remote.<name>.url |
project | base URL of the mirror target <name> |
remote.<name>.key |
project | API token for that target |
remote.<name>.type |
project | gitea|github|gitlab; auto-detected from the url when unset |
remote.<name>.visibility |
project | private (default) or public for repositories pushremote creates |
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
ssh identity, or mirrored to a different place:
# ~/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:
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.