The name is what the eye looks for, but it came last, behind a ragged
date column, so nothing lined up. Worse, colorRepoLine rebuilt the line
with strings.Fields and single spaces, which destroyed the alignment ls
had produced -- the output was aligned only when colour was off.
The listing line is now parsed properly instead of being split at the
size field: name, date and size come out as fields, the date is re-padded
to a fixed twelve columns so "Sep 28 2016" and "Jan 3 14:32" agree, and
the name leads in a column sized to the longest entry. Colour decorates
that layout without changing it, which a test now checks by stripping the
escapes and comparing. `list -a` shows archive sizes, which were parsed
and thrown away before.
An empty result says so instead of printing nothing, which was
indistinguishable from a failure, and the count line matches the rest of
mgsh. The pattern now filters on the repository name rather than the
whole listing line: matching the owner or the date was never intended and
`list 2016` quietly did it.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
overview showed dirty and ahead/behind per project, which git can do on
its own. mgsh is the only thing that sees both the local base directory
and the ssh server, and joining those answers the questions git cannot:
which projects were never pushed to the server (candidates for `init`),
and which exist there but not on this machine (candidates for `clone`).
Both lists are printed after the summary. An unreachable server is
reported as such, rather than as "everything is missing".
Each row also names the machine that made the last commit and how long
ago. That costs nothing: `push` has always stamped "[user@host]" into
the commit message, and nothing ever read it back. On a setup spanning
several machines it is usually the piece one actually wanted. Rows also
show which mirror targets the repository has a remote for, which is
local git config and therefore free.
The walk is now concurrent and cheaper per project: `git status
--porcelain=v2 --branch` yields branch, upstream, ahead/behind and dirty
in one subprocess where three were used before.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
`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 README had no complete list of settings, so gitkey being inert and
`remotes` being undocumented were invisible. It now carries a reference
table of every setting with its meaning and whether a project .mgshrc may
override it, kept honest by a test that checks each listed key really has
an MGSH_* override.
It also documents what mgsh expects of the git server, which was assumed
but never written down: the login directory of gituser *is* gitpath —
every remote command runs there without a cd — and archive/ has to exist
before `archive` and `clone -a` can work. Plus the per-project config,
the multi-target pushremote forms, `config`, and how the project is
derived from the working directory in command-line mode.
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>