Commit Graph
22 Commits
Author SHA1 Message Date
mike 39f5b48d94 [mike@mwxm4] 2026-08-11 11:40:32 +02:00
mike 113527f220 [mike@mwxm4] 2026-07-28 15:25:54 +02:00
mike 1e3ae9a4c7 [mike@maginot] 2026-07-28 06:55:46 +02:00
mikeandClaude Opus 5 b4797b056e Fix y/n questions being skipped after the first one
Every y/n prompt after the first in a session answered itself with its
default and left the keypress queued for the next command line.

MIN and TIME are not part of the canonical/non-canonical switch: they
live in their own slots of the control-character array and survive
`stty icanon`. drainTTY left them at "min 0 time 0" — return whatever is
buffered, do not wait — and the restore named only icanon and echo, so
the next read returned zero bytes without ever waiting for a key.

Set MIN and TIME explicitly on the way in, and restore the terminal from
the state captured with `stty -g` instead of naming the flags we changed;
mgsh now hands the terminal back exactly as it found it, where before
"min 0" outlived mgsh itself and broke the next program's single-key
reads too.

getkey also reports whether it got a key at all. When it did not, the
answer is no whatever the default says: a question nobody saw must not
be taken as consent. All five call sites default to no, so the bug never
destroyed anything — it only made agreeing impossible.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-27 21:38:32 +02:00
mike 9bc17513ed [mike@mwxm4] 2026-07-27 16:05:01 +02:00
mikeandClaude Opus 5 8c4d8da4e9 Attach ./bin and ./assets to a release
`release` now uploads every file in the project's ./bin and ./assets when
those directories exist. Nothing to configure, and nothing happens for a
project that has neither.

This is the part deliberately left out when `release` was written,
because it is where the three providers stop resembling each other:

  Gitea   multipart POST to .../releases/<id>/assets?name=<name>
  GitHub  raw POST to the separate upload host the release object names
          in upload_url, whose RFC 6570 template suffix has to go first
  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

So findRelease and createRelease now return a releaseRef carrying the id
and, for GitHub, that upload host -- the id alone cannot address an
upload. Uploads stream from disk rather than buffering: these are whole
binaries, and the Gitea multipart body is assembled through a pipe.

Only regular files directly in those directories are taken. Symlinks are
skipped, which matters here: build.sh leaves bin/mgsh pointing at one of
its siblings, and uploading the same 9M twice under two names helps
nobody. A name present in both directories is used from bin and reported
for assets. Re-releasing a tag replaces same-named assets rather than
failing on them, since rebuilding and publishing again is the normal
reason to do it, and a file that fails does not stop the rest.

Each provider's request shape is pinned down against the recording
stand-in, and the whole chain was run once end to end -- real repository,
real binaries, a fake Gitea that also serves git-http-backend so the tag
push is real too.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-26 20:13:09 +02:00
mikeandClaude Opus 5 f3d8cbe281 Cross-compile into ./bin for the usual platforms
build.sh produced one binary for the machine it ran on. It now builds
darwin/arm64, darwin/amd64, linux/amd64 and linux/arm64 into ./bin, all
from the single version that run bumped, so the four never disagree about
what they are. PLATFORMS="linux/amd64" ./build.sh narrows it.

bin/mgsh is a symlink to the host's build, so there is still one stable
path to "the binary for this machine". The old ./mgsh in the repo root is
no longer written, and the script deletes it once if it finds it -- a
stale binary on a path people have in their fingers is worse than a
missing one.

CGO_ENABLED=0 throughout: the cross builds then need no toolchain per
target and the binaries are static. That is only safe because os/user
still resolves the current user without cgo, which mgsh needs for the
"[user@host]" stamp on every commit -- checked on darwin before relying
on it.

Windows is left out on purpose: mgsh shells out to stty and /bin/sh, so
it would compile there and then not work.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-26 19:53:29 +02:00
mikeandClaude Opus 5 cd5ab1a2bd Remove the view command and the editor setting with it
`view` was the last user of `editor`, so leaving the setting behind would
have made it exactly what `gitkey` was until recently: documented,
parsed, and doing nothing. It is gone from the struct, the template, the
environment, `config -k` and the settings table.

Both names are free for aliases now, as `open` already was.

The tests that used `editor` as their example of a project-overridable
setting use `gitkey` instead, which is the same kind of thing and still
exists.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-26 19:11:06 +02:00
mikeandClaude Opus 5 65342bcd7c Remove the open command
`open` and `view` shared one implementation and differed in a single
line: `open` also made the project the active one. Only `view` is left,
with the behaviour it always had.

Dropping it from builtinCmds is the part worth noting: a reserved word
cannot be shadowed by an alias, so `open` is now free for one --
`alias open '!xdg-open $1'` works, which it could not before. That also
made a completion test wrong, since it used `open` as its example of a
name a builtin owns; it uses `status` now.

The `runInDir(d, "open", ...)` calls stay: those are macOS's open(1),
which is how an Xcode workspace gets opened.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-26 18:35:45 +02:00
mikeandClaude Opus 5 0d0d28560e Complete aliases that expand to a shell escape
`alias ll '!ls -la'` makes everything after `ll` a shell argument just as
surely as typing the `!` does, but Tab there still went to the builtin
command tree and found nothing. The dispatch now asks what a line will
turn into rather than how it starts: a '!' escape, or a name that is not a
builtin and resolves to an alias whose body starts with '!'.

Only the arguments complete — the command word is fixed by the alias
body, so `ll vi` offers the file, never the editor. An alias to a builtin
stays with the builtin tree.

Only the alias itself is inspected, not what its expansion might expand
to in turn: an alias chain can rewrite its own arguments, and guessing at
that would offer candidates for a command line other than the one being
built.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-26 18:28:35 +02:00
mikeandClaude Opus 5 2acca170e6 Complete ! shell escapes like a shell
A line starting with '!' was not in the completer tree at all, so Tab did
nothing there -- just where the paths are longest. It now completes the
way a shell does: the command word against the executables on PATH, the
arguments against the filesystem, resolved relative to the active project
because that is where forwardShell runs the line. Directories complete
with their trailing slash, `~/` and absolute paths work, and dot entries
stay hidden until the prefix asks for one.

readline's completer is a tree of fixed words, which cannot express "a
prefix that is not a word", so this is a small AutoCompleter that
dispatches on the '!' and hands everything else to the existing tree. Its
contract is easy to get subtly wrong -- candidates are the suffixes still
missing, and the length is counted in runes, not bytes -- so the
conversion has its own test, as does completing only the basename inside
a directory, which is what keeps the candidate list readable.

Only the basename is offered inside a directory, PATH is scanned once per
session, and a bare '!' offers nothing rather than every executable on
the machine.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-26 18:18:01 +02:00
mikeandClaude Opus 5 2a622046f2 Define mirror targets one way: remote.<name>.<field>
There were two spellings for the same thing -- a flat
remoteurl/remotekey/remotetype/remotevisibility set for a single server,
and remote.<name>.* blocks for several. The flat one is gone; every
target, including a lone one, is now a named block with the fields url,
key, type and visibility.

An existing ~/.mgshrc is converted on the next start. Only the key is
rewritten, so values, comments, alignment, commented-out lines and the
file's 0600 mode survive untouched, and mgsh prints each rename rather
than doing it quietly. The target is named "public", which is what the
old settings called the git remote they created, so a converted setup
keeps pushing to the same place under the same remote name. A file that
carries both spellings keeps what the new one says.

The environment follows the same shape: MGSH_REMOTEURL and friends are
replaced by MGSH_REMOTE_<NAME>_<FIELD>, so MGSH_REMOTE_GITLAB_KEY sets
remote.gitlab.key. The field is read from the end of the variable name,
which leaves target names free to contain underscores.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-26 18:00:26 +02:00
mikeandClaude Opus 5 61a7059f61 Fold the unpublished projects into the overview table
They were a list underneath the table, which meant reading the same
project names in two different shapes. They are rows now, with the action
in an "init" column that only appears when some row needs it, and they
sort to the bottom as their own group: an un-inited directory is a
different kind of task and should not push the daily ones down.

Every directory under the base gets a row, not just the repositories --
`init` is exactly what turns a plain directory into a project, so leaving
those out would have hidden the ones the column is for. Such a row has no
git state to show and costs no subprocesses either, since projectStatus
now checks for .git before running any.

The count line gained "N to init"; the projects count still counts
repositories, so the two numbers stay meaningful side by side. An
unreachable server marks nothing at all, as before.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-26 17:49:59 +02:00
mikeandClaude Opus 5 8ca05d6ad2 Drop the "not cloned here" line from overview
It answered a question `list` already answers, and it did so on every
run: the point of the overview is the state of the projects you have,
not a second listing of the server. reportInventory became
reportUnpublished and now reports one thing -- the local projects the
server has never seen, which are the ones `init` is for.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-26 17:45:02 +02:00
mikeandClaude Opus 5 cb1ff98c3d Lay overview out as a table
The status field was not a column: "*", "↑2", "✓" and "✓ (no upstream)"
are four different widths, so everything after them started somewhere
else on every line and the eye had to hunt along each row instead of
going down one.

Each field now has its own measured column: name (with the branch
appended when it is not master/main), a one-character dirty marker, the
sync state, host and age, then the mirrors. "(no upstream)" was fifteen
columns wide for something that is not even a problem, and is now "–".
Colour weights the row rather than decorating it -- a project that is
clean and in sync goes grey, the arrows and the dirty marker keep their
colour -- and the rows needing action sort to the top, alphabetically
within each group so positions stay predictable.

padRight counted bytes, which was fine while everything it padded was
ASCII; the arrows and check marks are three bytes and one column, so it
counts runes now.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-26 17:42:37 +02:00
mikeandClaude Opus 5 86fb898df4 Fix list breaking when the server's du fails
Two mistakes in the size support, reported from a real server.

The remote command used "2>/dev/null" to silence du. That is sh syntax,
and the git user's login shell need not be sh: in csh it parses as an
argument "2" followed by a redirection of stdout, so du was handed a
file named "2", complained, and exited non-zero. The redirection is
gone -- without it there is no bogus argument to trip over, and the
command now uses nothing that differs between sh and csh.

Worse, the exit status of the chain is the *last* command's, so that
failing du made sshOut return an error and `list` threw away a listing
that had arrived perfectly intact. It now reports a failure only when
nothing usable came back at all; a listing that parsed is shown whatever
the exit status, simply without the size column.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-26 17:35:50 +02:00
mikeandClaude Opus 5 3a420093d1 Show repository sizes in list
`list -a` had sizes because archives are files; repositories are
directories, and a long listing reports the inode size for those -- 4096
for every single one. Taking that number would have filled the column
with the same meaningless value, so the real disk usage is asked of `du`
instead, appended to the same remote command so it still costs one round
trip.

The column is dropped entirely when no usable sizes come back, rather
than showing a column of zeroes, so a server without a working `du`
degrades to the previous output. The summary line carries the total.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-26 17:30:12 +02:00
mikeandClaude Opus 5 8c68b28dc2 Format list as an aligned table
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>
2026-07-26 16:53:54 +02:00
mikeandClaude Opus 5 cad7a4ec2c Turn overview into an inventory across local and server
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>
2026-07-26 16:17:40 +02:00
mikeandClaude Opus 5 915ef1783a Add release: publish tagged releases on the mirror servers
`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>
2026-07-26 15:47:05 +02:00
mikeandClaude Opus 5 fa44a4056a Document the settings, the server layout and the new commands
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>
2026-07-26 11:38:31 +02:00
mike 5562055695 initial commit [141.14.129.234,mike] 2026-07-26 06:38:01 +02:00