`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. It is the one action in mgsh that cannot be undone: a
deleted server repository comes back from an archive, a published
credential does not.
The staged diff is now scanned before the commit is made -- private keys,
GitHub/GitLab/Slack/AWS/PyPI tokens, and credential-shaped assignments --
and a hit is shown with file and line before asking whether to continue.
Declining leaves the changes staged but uncommitted, so removing the file
and adding a .gitignore entry is all it takes.
The hard part is not detection but silence. A scanner that cries wolf
gets answered with a reflexive "y" and stops being a safety net, so
values that are plainly environment references, dotted identifiers,
constant names, template slots or masked stand-ins are filtered out. A
test scans mgsh's own README and mgshrc.example -- both full of
credential-shaped text -- and fails if either would trip the check. It
caught the documentation for this very feature, which is why the README
describes the sample output instead of reproducing it.
For a line that legitimately looks like a credential there is
`mgsh:allow`, which suppresses that one line; `secretscan = off` turns
the check off entirely. Only an explicit "off" does that -- a typo in the
setting leaves the safety net in place, which is what the new falsy()
is for.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
`release [@name ...] <tag> [notes]` does the whole chain in one step —
create the annotated tag, push it to the internal server, then push it to
each selected mirror and turn it into a release object there. Target
selection reuses pushremote's @name mechanism, so the two behave alike.
Notes are generated when none are given: the tag's own annotation when it
carries more than the default, otherwise the commit subjects since the
previous tag, capped at 50 lines. `tag add v1.0 "why this exists"` now
takes a message, which is what that fallback reads; before, the
annotation was always just the tag name.
Tags ending in -rc/-alpha/-beta/-pre are marked as pre-releases on Gitea
and GitHub. Releasing the same tag twice updates the existing release;
a tag that already points at a different commit stops the command, since
moving a published tag makes one version mean different things per
server. A repository that is not on the mirror yet is reported instead of
being created as a side effect.
Binary assets are deliberately out of scope: Gitea attaches them to the
release, GitHub uses a separate upload host, and GitLab does not host
them at all but wants a link into its package registry.
The providers differ in path shape and field names -- GitLab addresses
projects by URL-encoded path, calls the notes "description" and has no
pre-release flag -- so this comes with a recording httptest stand-in that
asserts the exact requests for all three. That harness also covers
authUser, repoExists and the auth header forms, which had no test at all.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The remote commands are the only code in mgsh that can destroy data, and
they were the least verifiable: each one is a single string handed to a
login shell, so a missing quote silently changes which paths it touches.
Every one of them now goes through the sshExec variable, and yesno is a
variable too, so a test can record what would have been sent and answer
the confirmations without a terminal.
The tests pin down what the previous commit fixed by reasoning alone:
that a declined or unreachable `init` sends no rm -rf, that a project
named "my 'weird' project" reaches the server fully quoted, and that
archive's cp/tar/rm sequence is named, ordered and quoted correctly.
push is driven end to end against a real local bare repository.
archive also gained the server-side existence check that init, clone and
show already had, so a project that was never pushed reports that instead
of failing inside cp -r.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
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>