Michael Wesemann 34be410a97 [mike@mwxm4]
2026-08-11 15:12:28 +02:00
2026-08-11 15:07:21 +02:00
2026-07-28 15:25:54 +02:00
2026-07-28 15:25:54 +02:00
2026-08-11 15:07:21 +02:00
2026-08-11 15:07:21 +02:00
2026-08-11 11:40:32 +02:00
2026-08-11 11:40:32 +02:00
2026-08-11 11:40:32 +02:00
2026-07-26 06:38:01 +02:00
2026-07-26 06:38:01 +02:00
2026-08-11 15:07:21 +02:00
2026-08-11 11:40:32 +02:00
2026-08-11 11:40:32 +02:00
2026-07-28 15:25:54 +02:00
2026-07-28 15:25:54 +02:00
2026-08-11 15:07:21 +02:00
2026-08-11 11:40:32 +02:00
2026-08-11 11:40:32 +02:00
2026-07-28 15:25:54 +02:00
2026-08-11 15:07:21 +02:00
2026-08-11 15:07:21 +02:00
2026-07-26 18:35:45 +02:00
2026-08-11 11:40:32 +02:00
2026-08-11 15:12:28 +02:00

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.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, deleteremote, 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-flavored) 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)
deleteremote <@name|host> delete the repo on that public server (asks first)
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
update [-c] update mgsh to the newest release (-c: only look)
!<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
remote.gitlab.active     = false     # only used when named (default true)

# 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 active target, in order
pushremote @gitea only gitea, active or not
pushremote @gitea @gitlab those two
pushremote a fix every active target, description "a fix"

Active and inactive targets

remote.<name>.active = false takes a target out of the default set: it is skipped by a bare pushremote, by release without a @name, and by the automatic mirror of mirror = true. Naming it uses it exactly as before — pushremote @gitea, release @gitea v1.2, or by host, pushremote @git.example.com.

That is the difference to the alternatives: removing the block loses the token, and leaving the target in remotes = … keeps it on the automatic path. An inactive target stays configured, keeps its credentials, is listed by config (a shade dimmer, marked inactive) and completes with Tab — it just no longer goes along by default. Useful for a server that is behind a VPN, rate-limited, or only occasionally interesting.

Unset means active, so a configuration written before this flag existed keeps mirroring where it always did. When every configured target is inactive, a bare pushremote says so and names one instead of claiming nothing is configured.

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.

Deleting a mirror (deleteremote)

deleteremote is the counterpart: it deletes the active project's repository on a public server, through the same API and with the same token.

deleteremote <@name|host> [...]
< src/myproject > deleteremote git.example.com
remote gitea https://git.example.com (as mike)
this deletes https://git.example.com/mike/myproject.git with its issues,
releases and history, and cannot be undone
delete mike/myproject on gitea? y/N ? y
deleted mike/myproject on gitea
removed git remote gitea

A target can be named either way: @gitea (its configured name) or git.example.com (the host of its url) select the same server, and the @ is optional here. Both spellings work for pushremote and release too. active = false changes nothing for this command — it always needs the server named anyway.

The guard rails, because nothing on the other end can be undone from here:

  • the server must be named. Unlike pushremote, a bare deleteremote does not mean "every configured target" — it lists them and stops.
  • the repository is looked up first, so a typo is reported as "nothing to delete" instead of becoming a request nobody meant to send.
  • each deletion is confirmed on its own, naming owner/repo and server, and the default answer is no.
  • the local clone is never touched. Only the git remote of that name is dropped, and only while it still points at the repository that was deleted.

Only the mirror is affected — the repository on the internal ssh git server, and everything list/archive deal with, stays where it is.

Deletion needs more from the token than pushing does: the delete_repo scope on GitHub, write:repository on Gitea, and the api scope plus the Owner role on GitLab. When it is missing the server answers 403, and mgsh names the scope rather than passing the bare status on. GitLab may only schedule the deletion (HTTP 202): depending on the plan the project stays visible until its retention period is over.

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 active 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.

Updating itself

update -c        # only look
update           # fetch and replace

and from outside the shell, mgsh update / mgsh --update — the dashed spelling is the one that also works before mgsh is configured, where every other command exits with "not configured".

mgsh fetches the newest release from git.micw.org/mike/mgsh — the URL sits fixed in the program, there is nothing to configure. What it needs is one release per version, whose tag is the bare number (4.1.0), with the files from ./bin as its assets; the one looked for is the one matching GOOS/GOARCH of this machine. That is exactly what ./build.sh produces and release publishes, so ./build.sh && release 4.1.0 is the whole publishing side.

What gets replaced is the running file itself. If the mgsh that was called is a symlink — say ~/bin/mgsh pointing at ~/src/mgsh/bin/mgsh-darwin-arm64 — the target behind it is renewed, not the link. Before the swap, what was freshly fetched is called once with --version; if it does not report the expected number, everything stays as it was. The swap itself is a rename within the same directory, hence atomic: either the old file or the new one, never half of one. If the binary lies somewhere you may not write to (/usr/local/bin), update says so and does nothing — then sudo.

Once a day, by itself

Without being asked, mgsh looks once a day and says so on stderr — at the start of an interactive session, and after the output of a one-shot command:

mgsh 4.1.0 is available, run 'mgsh --update'

The run in the foreground never touches the network for this. It only reads a note — ~/Library/Caches/mgsh/update.json, on Linux ~/.cache/mgsh/update.json — and when that one is older than a day, it starts mgsh --update-refresh on the side: the same binary once more, detached, without output, only to ask. Nobody waits for its answer; it will be in the note at the next call. mgsh thereby stays exactly as fast as before, even when the server happens to be silent.

The timestamp moves on before the asking. Two simultaneous runs therefore start one query, not two, and a server that does not answer is asked again tomorrow rather than on every call. If the note cannot be written, the question is dropped entirely — otherwise a write-protected cache directory would mean one process per call.

Asking and speaking happen only when stderr hangs on a terminal. In a pipe, in a script and under cron there is quiet, and MGSH_NO_UPDATE_CHECK=1 turns it off altogether.

selfupdate.go is a copy from dx and hangs on nothing in the rest of mgsh: standard library only, every name it brings starts with selfUpdate or update, and the block at the top of the file is all there is to adjust when it moves on to the next program.

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
remote.<name>.active project false keeps the target out of the default set; it is then only used when named (pushremote @<name>). Default true
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.

S
Description
No description provided
Readme 255 KiB
4.0.70 Latest
2026-08-11 16:19:23 +02:00
Languages
Go 99.4%
Shell 0.6%