Correctness and security fixes found by a review of the initial commit: - init decided whether a server repository existed from the *local* remote.origin.url, so an unlinked project directory skipped the confirmation and rm -rf'd the remote history. It now asks the server, and aborts when the server cannot be reached. - Project names and paths were interpolated unquoted into the remote shell command strings: a space split one path into two arguments and a backtick executed on the git server. Everything now goes through shq(), and chained remote commands use && so a failed cd cannot let the next command run in the login directory. - Bare `cd` panicked with an index-out-of-range and took down the shell; it now deselects the project. - Command-line mode set PRJ to the whole path below BASE, so `mgsh push` from a subdirectory staged only that subtree and addressed a bogus server path. It now truncates at the first path element. - `list` hardcoded owner and group "git git" in its regex and silently printed nothing on any server where the repositories are owned by someone else. - The config parser kept inline "#" comments in values although the README and the example file document them, so `mirror = true # ...` silently disabled mirroring. - ~/.mgshrc holds an API token but was created world-readable. - The mirror token was passed on git's command line, visible in the process table; it now goes through GIT_CONFIG_*. - tag, count and dist ran without a repository and operated on BASE. - checkout dropped its git options, because the dispatcher strips -x flags from the word list. - REPO was read with a plain `git config`, inheriting a foreign origin from an enclosing repository; it is now local-only and, being dead state otherwise, no longer recomputed on every prompt. - getkey consumed a single byte, leaving the rest of a typed answer in the tty queue where readline ran it as a command. - The REPL spun on any readline error that was neither EOF nor interrupt. - Tab completion cached an empty repository list after one failed ssh. - Startup did a blocking DNS lookup and three `git config --global` writes on every invocation. New: - A project may carry its own .mgshrc, overriding the global settings while it is active. Resolution order is ~/.mgshrc -> <project>/.mgshrc -> MGSH_*; base and the git identity keys stay global. It is read when the project changes, and `rescan` reloads it. - pushremote mirrors to any number of servers, configured as remote.<name>.url/key/type/visibility blocks. `pushremote` pushes to all of them, `pushremote @name ...` to a selection, and `remotes = ...` restricts and orders the set. Each target owns a git remote of the same name; a failing target no longer stops the others. - `config` shows the resolved configuration, its sources and the mirror targets with masked tokens; `config -k` lists the setting names. - gitkey was parsed and documented but never used. It is now the ssh identity for the git server, for mgsh's own ssh calls and, via GIT_SSH_COMMAND, for the git commands mgsh runs. - config, count, login and cloneall work from the command line too. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
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 (default $HOME/src, or /db/src on Linux). Go port of the original
Perl mgsh (mgsh.perl).
Build
./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.
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, filesystem paths for dist), and colored list/log/error
output.
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 |
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 |
alias [name [cmd]] |
list, show or define a command alias |
unalias <name> |
remove a command alias |
rescan |
refresh the cached server repository list |
!<command> |
run <command> in the shell |
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
a public hosting server (Gitea, GitHub or GitLab) over its REST API. It reads
two settings from ~/.mgshrc:
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
pushremote authenticates with the token, creates the repository (named after
the current project) if it does not exist yet, adds a credential-free remote
named public, and pushes all branches and tags. New repositories are
private unless remotevisibility = public; any words after the command
(pushremote <description>) are set as the repository description on creation.
The token is sent as a one-shot HTTP auth header, never written into the repo's
git config. 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.
Configuration
mgsh has no built-in defaults. Configuration comes entirely from ~/.mgshrc
(overlaid with 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.
It uses simple key = value (or key: value) lines (# comments allowed);
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
editor = code # fallback opener for `open`
alias co 'checkout $1'
Environment overrides: MGSH_BASE, MGSH_GITHOST, MGSH_GITPORT,
MGSH_GITUSER, MGSH_GITPATH, MGSH_GITKEY, MGSH_GITNAME, MGSH_GITEMAIL,
MGSH_PUSHDEFAULT, MGSH_EDITOR, MGSH_REMOTEURL, MGSH_REMOTEKEY,
MGSH_REMOTETYPE, MGSH_REMOTEVISIBILITY, MGSH_MIRROR.
See mgshrc.example for an annotated template.