# 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 `/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 ` 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] ` | clone a repository (or archive) from the server | | `list [-a] [pattern]` | list repositories on the server | | `show ` | 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] [notes]` | tag and publish a release on the mirrors | | `alias [name [cmd]]` | list, show or define a command alias | | `unalias ` | remove a command alias | | `config [-k]` | show the effective configuration and its sources | | `rescan` | reload the config, refresh the cached repo list | | `!` | run `` 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="" deploy_key:1 private key (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 ` 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 (``, `${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 ''` 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 ` shows one, and `unalias ` 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 = # 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..*` block each: ```ini remote.gitea.url = https://git.example.com remote.gitea.key = remote.hub.url = https://github.com remote.hub.key = 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 ...] [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 (`- ` 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 -> /.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_` (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..url` | project | base URL of the named mirror target | | `remote..key` | project | API token for that target | | `remote..type` | project | provider override for that target | | `remote..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/_DDMMYY.HHMM[_comment].git.tar.gz`; `clone -a ` unpacks one and clones it back. `list -a` lists them.