13 Commits
Author SHA1 Message Date
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 59da8f376c Check the staged diff for credentials before push commits
`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>
2026-07-26 16:17:30 +02:00
21 changed files with 2331 additions and 308 deletions
+175 -44
View File
@@ -7,6 +7,7 @@ directory. Go port of the original Perl `mgsh` (`mgsh.perl`).
## Contents ## Contents
- [Build](#build) · [Usage](#usage) · [Commands](#commands) · [Aliases](#aliases) - [Build](#build) · [Usage](#usage) · [Commands](#commands) · [Aliases](#aliases)
- [Overview](#overview) · [Credential check](#credential-check)
- [Public mirror (`pushremote`)](#public-mirror-pushremote) · - [Public mirror (`pushremote`)](#public-mirror-pushremote) ·
[Releases](#releases) [Releases](#releases)
- [Configuration](#configuration) · [Settings reference](#settings-reference) · - [Configuration](#configuration) · [Settings reference](#settings-reference) ·
@@ -39,7 +40,7 @@ stand in. Outside `base` no project is selected. `mgsh <project>` starts the
interactive shell with that project preselected. interactive shell with that project preselected.
The commands available directly from the shell are `clone`, `init`, `log`, The commands available directly from the shell are `clone`, `init`, `log`,
`push`, `pushremote`, `release`, `list`, `tag`, `archive`, `show`, `open`, `push`, `pushremote`, `release`, `list`, `tag`, `archive`, `show`,
`pull`, `fetch`, `status`, `diff`, `overview`, `config`, `count`, `login` and `pull`, `fetch`, `status`, `diff`, `overview`, `config`, `count`, `login` and
`cloneall`; every other command is interactive-only. `cloneall`; every other command is interactive-only.
@@ -51,9 +52,10 @@ project, its git branch and a `*` dirty marker:
``` ```
Features: command history (`~/.mgsh_history`), Tab completion (commands, local Features: command history (`~/.mgsh_history`), Tab completion (commands, local
projects for `cd`/`open`, server repos for `clone`/`show`, branches/tags for projects for `cd`, server repos for `clone`/`show`, branches/tags for
`checkout`/`tag`, mirror targets for `pushremote`, filesystem paths for `dist`), `checkout`/`tag`, mirror targets for `pushremote`/`release`, filesystem paths for
and colored `list`/`log`/error output. `dist`, and shell-style completion after `!` and for aliases that expand to
one), and colored `list`/`log`/error output.
The server repository list is fetched once per session on the first Tab that The server repository list is fetched once per session on the first Tab that
needs it; `rescan` refreshes it (and reloads the configuration). needs it; `rescan` refreshes it (and reloads the configuration).
@@ -69,6 +71,34 @@ prefix it with `!`:
< src/myproject > !ls -la < src/myproject > !ls -la
``` ```
It runs in the active project's directory. Tab completion works there the way it
does in a shell: the word after the `!` completes against the executables on
`PATH`, everything after it against the filesystem — relative to the project,
with `~/` and absolute paths understood, and directories completing with their
trailing slash so the next Tab walks into them. Dot entries stay out of the way
until the prefix asks for one.
```
< src/myproject > !vi ma<Tab> -> !vi main
< src/myproject > !vi <Tab> -> Makefile main.go main_test.go src/
< src/myproject > !gre<Tab> -> grep gresource
```
An alias that expands to a shell escape completes the same way, because its
arguments end up as shell arguments:
```
alias ll '!ls -la'
< src/myproject > ll ma<Tab> -> ll main
```
Only the alias's arguments complete, never its first word — the command is
fixed by the alias body. An alias to a builtin (`alias co 'checkout $1'`) is not
a shell line and is left alone.
Word splitting for completion is by whitespace only; quotes and backslash
escapes are left to the shell that runs the line.
### Commands ### Commands
Run `help` for the full list. Highlights: Run `help` for the full list. Highlights:
@@ -80,11 +110,11 @@ Run `help` for the full list. Highlights:
| `pushremote [desc]` | mirror the repo to a public server (gitea/github/gitlab) | | `pushremote [desc]` | mirror the repo to a public server (gitea/github/gitlab) |
| `pull` / `fetch` | pull / fetch from the server | | `pull` / `fetch` | pull / fetch from the server |
| `status [-a]` / `diff` | short git status (`-a`: overview of all projects) | | `status [-a]` / `diff` | short git status (`-a`: overview of all projects) |
| `overview` | dirty / ahead-behind summary of all projects | | `overview` | inventory of all projects, local and on the server |
| `log` | show the project log | | `log` | show the project log |
| `edit [n]` | interactive rebase of the last n commits | | `edit [n]` | interactive rebase of the last n commits |
| `clone [-a] <repo>` | clone a repository (or archive) from the server | | `clone [-a] <repo>` | clone a repository (or archive) from the server |
| `list [-a] [pattern]` | list repositories on the server | | `list [-a] [pattern]` | list repositories on the server (`-a`: archives, with sizes) |
| `show <repo>` | show a repository log directly on the server | | `show <repo>` | show a repository log directly on the server |
| `archive [comment]` | snapshot the server-side repo into `./archive` | | `archive [comment]` | snapshot the server-side repo into `./archive` |
| `init` | make a new repository from the current directory | | `init` | make a new repository from the current directory |
@@ -114,7 +144,6 @@ project /Users/me/src/myproject/.mgshrc
gitport 22 gitport 22
gituser git gituser git
gitpath /home/git gitpath /home/git
editor code (.mgshrc)
remotes hub (.mgshrc) remotes hub (.mgshrc)
clone url ssh://git@git.example.com:22/home/git clone url ssh://git@git.example.com:22/home/git
@@ -125,6 +154,111 @@ pushremote targets (in push order):
Tokens are masked, so the output is safe to paste into a bug report. Tokens are masked, so the output is safe to paste into a bug report.
`config -k` prints just the setting names, one per line. `config -k` prints just the setting names, one per line.
### Listing the server
`list` shows what is on the git server, name first and aligned, ordered by
modification time — `push` touches the bare repository, so the most recently
worked-on project sits closest to the prompt:
```
< src > list
Betaflight3.0.0 Sep 28 2016 181M
website Mar 3 2024 2.1M
notes Jan 3 14:32 876K
3 repositories · 184M
```
The size is the repository's real disk usage on the server, asked of `du` in
the same round trip as the listing — a long listing reports the inode size for
a directory, which is the same number for every repository and says nothing. If
the server produces no usable sizes the column is left out rather than filled
with zeroes.
`list -a` lists the archives instead, whose sizes come from the listing itself;
a pattern filters by name (`list note`).
### 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
notes * ↑2 laptop 3h
website * ✓ desktop 2d → hub
Betaflight3.0.0 ✓ workstation 20d → gitea hub
experiments init
sandbox (wip) laptop 1h init
4 projects · 2 dirty · 2 in sync · 2 to init
```
Every field sits in its own column, so the eye can go down one instead of
hunting along each line. The projects that need something done come first, the
settled ones next, and the directories the git server does not have yet come
last — those are a different kind of task. Within each group the order stays
alphabetical, so positions do not jump around.
| column | meaning |
|---|---|
| name | the project, with its branch appended when it is not `master`/`main` |
| `*` | uncommitted changes |
| `↑n` `↓n` | commits ahead of / behind the upstream (`↑2↓1` when both) |
| `✓` | in sync with the upstream |
| `` | the branch tracks nothing — never pushed |
| `init` | the git server does not have this one; run `init` |
| host, age | who last committed and when, from the `[user@host]` stamp |
| `→` | mirror targets this repository has a remote for |
Every directory under the base gets a row, including those that are not
repositories at all — `init` is exactly what turns one into a project, so it
belongs in the table rather than in a list underneath it. Such a row simply has
no git state to show.
The host 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 `init` column is the join no git command can do. If the server cannot be
reached, no row is marked and mgsh says so — not knowing is not the same as
knowing they are missing. The other direction, repositories on the server that
are not here, is what `list` shows.
### 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="<the offending line is shown in full here>"
deploy_key:1 private key
<the BEGIN … PRIVATE KEY header is shown here>
(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 <file>` 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 (`<token>`, `${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 ### Aliases
`alias <name> '<command>'` defines a reusable shortcut, persisted to `alias <name> '<command>'` defines a reusable shortcut, persisted to
@@ -153,38 +287,37 @@ alias ec '!echo $1' # ec hello -> echo hello (shell)
Besides the internal ssh git server, `pushremote` mirrors the active project to 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 one or more public hosting servers (Gitea, GitHub or GitLab) over their REST
API. A single server is configured flat: API. Each server is one `remote.<name>.<field>` block:
```ini ```ini
remoteurl = https://git.example.com # base URL of the server remote.gitea.url = https://git.example.com
remotekey = <personal-access-token> # API token remote.gitea.key = <personal-access-token>
# remotetype = gitea # optional; auto-detected from remoteurl remote.gitea.type = gitea # optional; auto-detected from the url
# remotevisibility = private # visibility of created repos (default private) remote.gitea.visibility = private # or public (default private)
# mirror = true # `push` also mirrors via pushremote
remote.gitlab.url = https://gitlab.example.com
remote.gitlab.key = <personal-access-token>
remote.gitlab.type = gitlab
remote.gitlab.visibility = public
# remotes = gitea, gitlab # optional: restrict and order the set
# mirror = true # `push` also mirrors via pushremote
``` ```
Several servers get one `remote.<name>.*` block each: `<name>` is yours to pick; there is no other spelling. Older versions had a flat
`remoteurl`/`remotekey` pair for a single server — mgsh converts those to
```ini `remote.public.*` in place on the next start and says so, keeping the git remote
remote.gitea.url = https://git.example.com name those versions used.
remote.gitea.key = <personal-access-token>
remote.hub.url = https://github.com
remote.hub.key = <personal-access-token>
remote.hub.visibility = public
# remotes = gitea, hub # optional: restrict and order the set
```
| command | pushes to | | command | pushes to |
|------------------------|-----------------------------------------------| |------------------------|-----------------------------------------------|
| `pushremote` | every configured target, in order | | `pushremote` | every configured target, in order |
| `pushremote @hub` | only `hub` | | `pushremote @gitea` | only `gitea` |
| `pushremote @hub @gitea` | those two | | `pushremote @gitea @gitlab` | those two |
| `pushremote a fix` | every target, description "a fix" | | `pushremote a fix` | every target, description "a fix" |
Each target owns a git remote of the same name in the repository (the flat form Each target owns a git remote of the same name in the repository, so
uses `public`, as before), so `git push hub` keeps working outside mgsh. A `git push gitlab` keeps working outside mgsh. A
target that fails does not stop the others; with more than one target target that fails does not stop the others; with more than one target
`pushremote` prints an `n/m remotes updated` summary. `remotes = …` restricts `pushremote` prints an `n/m remotes updated` summary. `remotes = …` restricts
and orders the set, which is mostly useful in a project `.mgshrc` — see below. and orders the set, which is mostly useful in a project `.mgshrc` — see below.
@@ -198,9 +331,10 @@ 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 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` 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 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` existing file is readable by others. The provider is auto-detected from the url
GitHub, `gitlab*` → GitLab, otherwise Gitea) and can be forced with (`github.com`GitHub, `gitlab*` → GitLab, otherwise Gitea) and can be forced
`remotetype`. Set `mirror = true` to have every `push` mirror automatically. with `remote.<name>.type`. Set `mirror = true` to have every `push` mirror
automatically.
### Releases ### Releases
@@ -274,7 +408,6 @@ gitpath = /home/git
gitname = Your Name gitname = Your Name
gitemail = you@example.com gitemail = you@example.com
pushdefault = matching pushdefault = matching
editor = code # fallback opener for `open`
alias co 'checkout $1' alias co 'checkout $1'
``` ```
@@ -284,8 +417,10 @@ alias co 'checkout $1'
### Settings reference ### Settings reference
Every setting can also be given as an environment variable named `MGSH_<KEY>` Every setting can also be given as an environment variable named `MGSH_<KEY>`
(e.g. `MGSH_GITHOST`), which wins over both files. "Scope" says whether a (e.g. `MGSH_GITHOST`), which wins over both files; a mirror field is
project `.mgshrc` may override the setting. `MGSH_REMOTE_<NAME>_<FIELD>`, so `MGSH_REMOTE_GITLAB_KEY` sets
`remote.gitlab.key`. "Scope" says whether a project `.mgshrc` may override the
setting.
| setting | scope | meaning | | setting | scope | meaning |
|---|---|---| |---|---|---|
@@ -298,17 +433,13 @@ project `.mgshrc` may override the setting.
| `gitname` | global | `user.name` written to the **global** git config at startup | | `gitname` | global | `user.name` written to the **global** git config at startup |
| `gitemail` | global | `user.email` written to the global git config | | `gitemail` | global | `user.email` written to the global git config |
| `pushdefault` | global | `push.default` 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`) | | `remote.<name>.url` | project | base URL of the mirror target `<name>` |
| `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.<name>.url` | project | base URL of the named mirror target |
| `remote.<name>.key` | project | API token for that target | | `remote.<name>.key` | project | API token for that target |
| `remote.<name>.type` | project | provider override for that target | | `remote.<name>.type` | project | `gitea`\|`github`\|`gitlab`; auto-detected from the url when unset |
| `remote.<name>.visibility` | project | visibility for that target | | `remote.<name>.visibility` | project | `private` (default) or `public` for repositories `pushremote` creates |
| `remotes` | project | comma- or space-separated list restricting and ordering the mirror targets | | `remotes` | project | comma- or space-separated list restricting and ordering the mirror targets |
| `mirror` | project | truthy (`1`/`true`/`yes`/`on`) → every `push` also mirrors | | `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 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 only when they actually differ, so a plain `mgsh status` does not rewrite
@@ -318,7 +449,7 @@ only when they actually differ, so a plain `mgsh status` does not rewrite
A project may carry its own `.mgshrc`, which overrides the global settings while 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 that project is active — a project on a different git server, with a different
editor, or mirrored to a different place: ssh identity, or mirrored to a different place:
```ini ```ini
# ~/src/myproject/.mgshrc # ~/src/myproject/.mgshrc
+1 -1
View File
@@ -46,7 +46,7 @@ var builtinCmds = map[string]bool{
"diff": true, "pull": true, "fetch": true, "push": true, "edit": true, "diff": true, "pull": true, "fetch": true, "push": true, "edit": true,
"pushremote": true, "overview": true, "archive": true, "init": true, "pushremote": true, "overview": true, "archive": true, "init": true,
"login": true, "cd": true, "checkout": true, "clone": true, "cloneall": true, "login": true, "cd": true, "checkout": true, "clone": true, "cloneall": true,
"open": true, "view": true, "count": true, "tag": true, "alias": true, "count": true, "tag": true, "alias": true,
"unalias": true, "config": true, "release": true, "unalias": true, "config": true, "release": true,
} }
+48 -14
View File
@@ -2,7 +2,9 @@ package main
import ( import (
"fmt" "fmt"
"strconv"
"strings" "strings"
"unicode/utf8"
) )
// Colors for the prompt, banner and output, using the Catppuccin Mocha palette // Colors for the prompt, banner and output, using the Catppuccin Mocha palette
@@ -35,25 +37,57 @@ func errorln(msg string) {
fmt.Println(col(cRed, msg)) fmt.Println(col(cRed, msg))
} }
// padRight pads an ASCII string with trailing spaces to width n. // padRight pads s with trailing spaces to a width of n columns. It counts
// runes, not bytes: the overview pads fields holding ↑ ↓ ✓, each of which is
// one column wide but three bytes long.
func padRight(s string, n int) string { func padRight(s string, n int) string {
if len(s) < n { if l := utf8.RuneCountInString(s); l < n {
return s + strings.Repeat(" ", n-len(s)) return s + strings.Repeat(" ", n-l)
} }
return s return s
} }
// colorRepoLine colors a `list` entry: the leading `ls -ltr` date (3 fields) in // formatRepoList renders the server listing for `list`: the name first, in a
// yellow and the repository name in green. // column wide enough for the longest one, then the date, and for archives the
func colorRepoLine(s string) string { // size. Names come first because that is what the eye scans for; putting the
if !useColor { // ragged date there instead is what made the old output hard to read.
return s //
// The order is left as it arrives: `ls -ltr` sorts by modification time, and
// `push` touches the bare repository, so the most recently worked-on project
// ends up closest to the prompt.
func formatRepoList(entries []lsEntry, withSize bool) string {
width := 0
for _, e := range entries {
if len(e.name) > width {
width = len(e.name)
}
} }
parts := strings.Fields(s) var b strings.Builder
if len(parts) >= 4 { for _, e := range entries {
date := strings.Join(parts[:3], " ") fmt.Fprintf(&b, " %s %s", col(cGreen, padRight(e.name, width)), col(cYellow, e.date))
name := strings.Join(parts[3:], " ") if withSize {
return col(cYellow, date) + " " + col(cGreen, name) fmt.Fprintf(&b, " %s", col(cGray, fmt.Sprintf("%7s", humanSize(e.size))))
}
b.WriteByte('\n')
} }
return col(cGreen, s) return b.String()
}
// humanSize renders a byte count compactly, the way `ls -h` does: a decimal
// only while it still carries information, so "3.2M" but "512K".
func humanSize(n int64) string {
const unit = 1024
if n < unit {
return strconv.FormatInt(n, 10) + "B"
}
div, exp := int64(unit), 0
for v := n / unit; v >= unit && exp < 4; v /= unit {
div *= unit
exp++
}
v := float64(n) / float64(div)
if v < 10 {
return fmt.Sprintf("%.1f%c", v, "KMGTP"[exp])
}
return fmt.Sprintf("%.0f%c", v, "KMGTP"[exp])
} }
+127 -61
View File
@@ -14,32 +14,85 @@ import (
var ( var (
optRe = regexp.MustCompile(`^-(\w)$`) optRe = regexp.MustCompile(`^-(\w)$`)
numRe = regexp.MustCompile(`^\d+$`) numRe = regexp.MustCompile(`^\d+$`)
// a `ls -ltr` long-listing line: mode, link count, owner, group, size, then // a `ls -ltr` long-listing line: mode, link count, owner, group, size, the
// the date columns and the name. Owner and group are matched as opaque // three date columns, then the name. Owner and group are matched as opaque
// fields — the bare repositories need not belong to a user or group // fields — the bare repositories need not belong to a user or group
// literally named "git". // literally named "git".
lsEntryRe = regexp.MustCompile(`^\S+\s+\d+\s+\S+\s+\S+\s+\d+\s+(.*)$`) lsEntryRe = regexp.MustCompile(`^\S+\s+\d+\s+\S+\s+\S+\s+(\d+)\s+(\S+)\s+(\S+)\s+(\S+)\s+(.*)$`)
gitDirRe = regexp.MustCompile(`^(.*)\.git$`) gitDirRe = regexp.MustCompile(`^(.*)\.git$`)
sanRe = regexp.MustCompile(`[,;:\\/='"|?><-]+`) sanRe = regexp.MustCompile(`[,;:\\/='"|?><-]+`)
wsRe = regexp.MustCompile(`\s+`) wsRe = regexp.MustCompile(`\s+`)
) )
// lsEntry extracts the "<date columns> <name>" tail of a `ls -ltr` line whose // listMarker separates the two sections of the combined listing command, so
// entry name ends in suffix, with the suffix removed. It returns "" for any // `list` gets both the long listing and the disk usage in one round trip.
// other line (the leading "total" line, entries of a different kind). const listMarker = "---mgsh---"
func lsEntry(line, suffix string) string {
// duRe matches one `du -sk` line: kilobytes, then the path.
var duRe = regexp.MustCompile(`^(\d+)\s+(.*)$`)
// splitAtMarker divides the remote output into the part before and after the
// marker line. Everything is in the first section when the marker is absent —
// which is what happens when only a plain listing was asked for.
func splitAtMarker(lines []string, marker string) (before, after []string) {
for i, ln := range lines {
if strings.TrimSpace(ln) == marker {
return lines[:i], lines[i+1:]
}
}
return lines, nil
}
// parseDuSizes turns `du -sk` output into a name -> bytes map. A long listing
// reports the inode size for a directory — the same number for every bare
// repository — so this is the only way to say how large one actually is.
// A symlinked repository reports the size of the link, not of its target.
func parseDuSizes(lines []string) map[string]int64 {
out := map[string]int64{}
for _, ln := range lines {
m := duRe.FindStringSubmatch(strings.TrimRight(ln, "\r"))
if m == nil {
continue
}
kb, err := strconv.ParseInt(m[1], 10, 64)
if err != nil {
continue
}
out[strings.TrimPrefix(strings.TrimSpace(m[2]), "./")] = kb * 1024
}
return out
}
// lsEntry is one parsed entry of the server's listing.
type lsEntry struct {
name string // with the ".git" / ".git.tar.gz" suffix removed
date string // the ls date columns, normalised to a fixed 12 columns
size int64
}
// parseLsEntry reads one `ls -ltr` line whose entry name ends in suffix. It
// returns false for anything else: the leading "total" line, entries of another
// kind, or output that does not look like a long listing at all.
func parseLsEntry(line, suffix string) (lsEntry, bool) {
m := lsEntryRe.FindStringSubmatch(strings.TrimSpace(line)) m := lsEntryRe.FindStringSubmatch(strings.TrimSpace(line))
if m == nil { if m == nil {
return "" return lsEntry{}, false
} }
name := m[1] name := m[5]
if i := strings.Index(name, " -> "); i >= 0 { if i := strings.Index(name, " -> "); i >= 0 {
name = name[:i] // a symlinked bare repo lists as "link.git -> target.git" name = name[:i] // a symlinked bare repo lists as "link.git -> target.git"
} }
if !strings.HasSuffix(name, suffix) { if !strings.HasSuffix(name, suffix) {
return "" return lsEntry{}, false
} }
return strings.TrimSuffix(name, suffix) size, _ := strconv.ParseInt(m[1], 10, 64)
return lsEntry{
name: strings.TrimSuffix(name, suffix),
// ls pads these itself, but only in its own column widths; re-pad so
// "Sep 28 2016" and "Jan 3 14:32" line up at 12 either way
date: fmt.Sprintf("%s %2s %5s", m[2], m[3], m[4]),
size: size,
}, true
} }
// validProject reports whether name is usable as a project name: a single path // validProject reports whether name is usable as a project name: a single path
@@ -184,20 +237,66 @@ func runCommandDepth(line string, depth int) bool {
if opt["a"] { if opt["a"] {
path, suffix = "./archive", ".git.tar.gz" path, suffix = "./archive", ".git.tar.gz"
} }
pat := word(words, 1) one, many := "repository", "repositories"
lines, err := sshOut("/bin/ls -ltr " + shq(path)) if opt["a"] {
if err != nil { one, many = "archive", "archives"
errorln("could not list repositories on the git server")
break
} }
for _, ln := range lines { pat := strings.ToLower(word(words, 1))
if pat != "" && !strings.Contains(strings.ToLower(ln), strings.ToLower(pat)) { remote := "/bin/ls -ltr " + shq(path)
if !opt["a"] {
// archives are files and carry a real size; repositories are
// directories, whose listed size is the inode's, so ask du in the
// same round trip. Nothing shell-specific here on purpose: the
// login shell may be csh, where "2>/dev/null" is not a redirection
// but an argument followed by one.
remote += "; echo " + shq(listMarker) + "; du -sk *.git"
}
lines, err := sshOut(remote)
lsLines, duLines := splitAtMarker(lines, listMarker)
sizes := parseDuSizes(duLines)
var entries []lsEntry
var total int64
for _, ln := range lsLines {
e, ok := parseLsEntry(ln, suffix)
// the pattern filters the name, not the whole listing line — an
// accidental match on the date or the owner helps nobody
if !ok || (pat != "" && !strings.Contains(strings.ToLower(e.name), pat)) {
continue continue
} }
if name := lsEntry(ln, suffix); name != "" { if !opt["a"] {
fmt.Println(colorRepoLine(name)) e.size = sizes[e.name+suffix] // 0 when du said nothing
} }
total += e.size
entries = append(entries, e)
} }
// The exit status belongs to the last command in the chain, so a `du`
// that fails must not discard a listing that arrived intact. Only
// complain when nothing usable came back at all.
if len(entries) == 0 {
if err != nil {
errorln("could not list " + many + " on the git server")
break
}
what := "no " + many + " on the git server"
if pat != "" {
what = "no " + many + " matching '" + word(words, 1) + "'"
}
fmt.Println(col(cGray, what))
break
}
// no size column when the server gave no usable sizes, rather than a
// column of zeroes
fmt.Print(formatRepoList(entries, total > 0))
label := many
if len(entries) == 1 {
label = one
}
summary := fmt.Sprintf("%d %s", len(entries), label)
if total > 0 {
summary += " · " + humanSize(total)
}
fmt.Println(col(cGray, summary))
case "show": // show a repository's log directly on the server case "show": // show a repository's log directly on the server
prj := PRJ prj := PRJ
@@ -272,6 +371,13 @@ func runCommandDepth(line string, depth int) bool {
} }
comment := strings.Join(fields[1:], " ") comment := strings.Join(fields[1:], " ")
git(DIR, "add", "--all", ".") git(DIR, "add", "--all", ".")
// last look before anything is committed: `add --all` sweeps up whatever
// is lying around, and with mirroring on it goes straight to a public
// server. Nothing has been committed yet, so declining costs nothing.
if !secretsApproved(DIR) {
errorln("push cancelled — your changes are staged but not committed")
break
}
msg := strings.TrimSpace(fmt.Sprintf("[%s@%s] %s", USER, HOST, comment)) msg := strings.TrimSpace(fmt.Sprintf("[%s@%s] %s", USER, HOST, comment))
git(DIR, "commit", "-m", msg) // may be "nothing to commit"; continue anyway git(DIR, "commit", "-m", msg) // may be "nothing to commit"; continue anyway
if !gitOK(DIR, "push") { if !gitOK(DIR, "push") {
@@ -467,45 +573,6 @@ func runCommandDepth(line string, depth int) bool {
} }
} }
case "open", "view": // open project in Xcode / editor
prj := PRJ
if w := word(words, 1); w != "" {
prj = w
}
d := BASE + "/" + prj
if !validProject(prj) || !isDir(d) {
errorln("not found")
break
}
xws, xprj := "", ""
if entries, err := os.ReadDir(d); err == nil {
for _, e := range entries {
if strings.HasSuffix(e.Name(), ".xcworkspace") {
xws = e.Name()
}
if strings.HasSuffix(e.Name(), ".xcodeproj") {
xprj = e.Name()
}
}
}
// prefer the workspace over the project; fall back to the editor unless
// one of them is really openable (a name match on a plain file is not).
switch {
case xws != "" && isDir(d+"/"+xws):
runInDir(d, "open", xws)
case xprj != "" && isDir(d+"/"+xprj):
runInDir(d, "open", xprj)
default:
editor := cfg.Editor
if editor == "" {
editor = "coda"
}
runInDir(d, editor, d)
}
if words[0] == "open" {
PRJ = prj
}
case "count": // count source lines in the project case "count": // count source lines in the project
if !requireProject() { if !requireProject() {
break break
@@ -703,7 +770,6 @@ const gitignore = `.DS_Store
var helpItems = []struct{ cmd, desc string }{ var helpItems = []struct{ cmd, desc string }{
{"cd [project]", "change project (no argument: back to the base)"}, {"cd [project]", "change project (no argument: back to the base)"},
{"open [project]", "open project"},
{"init", "make new repository from current directory"}, {"init", "make new repository from current directory"},
{"push [comment]", "push changes to git server"}, {"push [comment]", "push changes to git server"},
{"pushremote [@name] [desc]", "mirror repo to the public server(s) (gitea/github/gitlab)"}, {"pushremote [@name] [desc]", "mirror repo to the public server(s) (gitea/github/gitlab)"},
@@ -711,7 +777,7 @@ var helpItems = []struct{ cmd, desc string }{
{"pull", "pull changes from git server"}, {"pull", "pull changes from git server"},
{"fetch", "fetch changes from git server"}, {"fetch", "fetch changes from git server"},
{"status [-a]", "short git status (-a: overview of all projects)"}, {"status [-a]", "short git status (-a: overview of all projects)"},
{"overview", "status of all projects (dirty, ahead/behind)"}, {"overview", "inventory of all projects, local and on the server"},
{"diff [args]", "show git diff"}, {"diff [args]", "show git diff"},
{"edit [number]", "edit last [number] commits (default is 10)"}, {"edit [number]", "edit last [number] commits (default is 10)"},
{"clone [-a] <repository>", "clone repository from git server (-a for archive)"}, {"clone [-a] <repository>", "clone repository from git server (-a for archive)"},
+42 -13
View File
@@ -4,19 +4,54 @@ import (
"os" "os"
"path/filepath" "path/filepath"
"strings" "strings"
"unicode/utf8"
"github.com/chzyer/readline" "github.com/chzyer/readline"
) )
// completer wires up Tab completion. Command names complete at the start of the // completer wires up Tab completion. A line headed for a shell — a '!' escape,
// line; cd/open/view complete local project names; clone/show complete // or an alias that expands to one — is completed the way a shell would:
// executables for the command, paths for its arguments. Everything else goes to
// the builtin command tree.
func completer() readline.AutoCompleter {
return &mgshCompleter{builtin: builtinCompleter()}
}
// mgshCompleter dispatches between the two completion worlds.
type mgshCompleter struct{ builtin *readline.PrefixCompleter }
func (c *mgshCompleter) Do(line []rune, pos int) ([][]rune, int) {
if pos > len(line) {
pos = len(line)
}
if cands, prefix, ok := completeShellLine(string(line[:pos])); ok {
return runeSuffixes(cands, prefix)
}
return c.builtin.Do(line, pos)
}
// runeSuffixes converts full candidate words into what readline wants: the part
// still missing after the prefix already typed, plus that prefix's length.
func runeSuffixes(cands []string, prefix string) ([][]rune, int) {
n := utf8.RuneCountInString(prefix)
out := make([][]rune, 0, len(cands))
for _, c := range cands {
r := []rune(c)
if len(r) >= n {
out = append(out, r[n:])
}
}
return out, n
}
// builtinCompleter is the command tree. Command names complete at the start of
// the line; cd completes local project names; clone/show complete
// repository names cached from the git server; checkout/tag complete branch and // repository names cached from the git server; checkout/tag complete branch and
// tag names; dist completes filesystem paths. // tag names; pushremote/release complete mirror targets; dist completes
func completer() *readline.PrefixCompleter { // filesystem paths.
func builtinCompleter() *readline.PrefixCompleter {
return readline.NewPrefixCompleter( return readline.NewPrefixCompleter(
readline.PcItem("cd", readline.PcItemDynamic(dynLocalProjects)), readline.PcItem("cd", readline.PcItemDynamic(dynLocalProjects)),
readline.PcItem("open", readline.PcItemDynamic(dynLocalProjects)),
readline.PcItem("view", readline.PcItemDynamic(dynLocalProjects)),
readline.PcItem("clone", readline.PcItem("clone",
readline.PcItem("-a", readline.PcItemDynamic(dynServerArchives)), readline.PcItem("-a", readline.PcItemDynamic(dynServerArchives)),
readline.PcItemDynamic(dynServerRepos), readline.PcItemDynamic(dynServerRepos),
@@ -82,18 +117,12 @@ func fetchServerRepos() {
if serverFetched { if serverFetched {
return return
} }
lines, err := sshOut("/bin/ls .") repos, err := serverRepoNames()
if err != nil { if err != nil {
// a transient failure (server down, no network) must not cache an // a transient failure (server down, no network) must not cache an
// empty list for the rest of the session — the next Tab tries again // empty list for the rest of the session — the next Tab tries again
return return
} }
var repos []string
for _, ln := range lines {
if m := gitDirRe.FindStringSubmatch(strings.TrimSpace(ln)); m != nil {
repos = append(repos, m[1])
}
}
// a missing ./archive is a permanent, unremarkable state: still cache // a missing ./archive is a permanent, unremarkable state: still cache
var archives []string var archives []string
if lines, err := sshOut("/bin/ls archive"); err == nil { if lines, err := sshOut("/bin/ls archive"); err == nil {
+145 -34
View File
@@ -6,6 +6,7 @@ import (
"os" "os"
"path/filepath" "path/filepath"
"regexp" "regexp"
"slices"
"sort" "sort"
"strings" "strings"
) )
@@ -32,12 +33,8 @@ type Config struct {
GitName string // git user.name to set globally ("" = leave alone) GitName string // git user.name to set globally ("" = leave alone)
GitEmail string // git user.email to set globally ("" = leave alone) GitEmail string // git user.email to set globally ("" = leave alone)
PushDefault string // git push.default to set globally ("" = leave alone) PushDefault string // git push.default to set globally ("" = leave alone)
Editor string // editor/opener used as fallback by `open` ("" = coda)
RemoteURL string // public mirror server base URL (Gitea/GitHub/GitLab)
RemoteKey string // API token for the mirror server (used by `pushremote`)
RemoteType string // "gitea"|"github"|"gitlab" (auto-detected when empty)
RemoteVis string // visibility of created repos: "private" (default)|"public"
Mirror string // truthy -> `push` also mirrors via `pushremote` Mirror string // truthy -> `push` also mirrors via `pushremote`
SecretScan string // falsy -> `push` skips the credential scan
Remotes []RemoteTarget Remotes []RemoteTarget
RemoteNames string // "remotes": explicit, ordered subset of targets to use RemoteNames string // "remotes": explicit, ordered subset of targets to use
} }
@@ -53,22 +50,25 @@ type RemoteTarget struct {
Vis string // "private" (default) | "public" Vis string // "private" (default) | "public"
} }
// legacyRemoteName is the target name for the flat remoteurl/remotekey pair, // legacyRemoteName is the target the pre-4.1 flat remoteurl/remotekey settings
// matching the git remote that earlier versions created. // are migrated to. It matches the git remote those versions created, so a
// converted configuration keeps pushing to the same place.
const legacyRemoteName = "public" const legacyRemoteName = "public"
// legacyRemoteKeys maps the old flat spelling onto the named-target form. A
// mirror target is defined one way now, not two.
var legacyRemoteKeys = map[string]string{
"remoteurl": "remote." + legacyRemoteName + ".url",
"remotekey": "remote." + legacyRemoteName + ".key",
"remotetype": "remote." + legacyRemoteName + ".type",
"remotevisibility": "remote." + legacyRemoteName + ".visibility",
}
// mirrorTargets returns the usable mirror targets in configured order, plus the // mirrorTargets returns the usable mirror targets in configured order, plus the
// names of targets that are defined but unusable (missing url or key) so the // names of targets that are defined but unusable (missing url or key) so the
// caller can complain about them instead of silently skipping. // caller can complain about them instead of silently skipping.
func (c Config) mirrorTargets() (usable []RemoteTarget, incomplete []string) { func (c Config) mirrorTargets() (usable []RemoteTarget, incomplete []string) {
var all []RemoteTarget all := c.Remotes
if c.RemoteURL != "" || c.RemoteKey != "" {
all = append(all, RemoteTarget{
Name: legacyRemoteName, URL: c.RemoteURL, Key: c.RemoteKey,
Type: c.RemoteType, Vis: c.RemoteVis,
})
}
all = append(all, c.Remotes...)
// `remotes = a, b` narrows and orders the set — a project .mgshrc uses it // `remotes = a, b` narrows and orders the set — a project .mgshrc uses it
// to mirror to only some of the globally configured servers. // to mirror to only some of the globally configured servers.
@@ -146,11 +146,54 @@ func loadConfig() Config {
m := parseConfig(string(data)) m := parseConfig(string(data))
applyConfig(&c, m) applyConfig(&c, m)
warnConfigPerms(path, m) warnConfigPerms(path, m)
migrateRemoteKeys(path, string(data))
} }
applyEnv(&c) applyEnv(&c)
return c return c
} }
// migrateRemoteKeys converts the pre-4.1 flat remote settings in a config file
// to the remote.<name>.<field> spelling, so a mirror target is defined one way
// and not two. Only the key is rewritten: values, comments, blank lines and the
// file's permissions stay exactly as they are, and commented-out lines are left
// alone. Reports what it changed rather than doing it silently.
func migrateRemoteKeys(path, data string) {
lines := strings.Split(data, "\n")
var renamed []string
for i, ln := range lines {
trimmed := strings.TrimLeft(ln, " \t")
if trimmed == "" || strings.HasPrefix(trimmed, "#") {
continue
}
sep := strings.IndexAny(trimmed, "=:")
if sep < 0 {
continue
}
key := strings.TrimRight(trimmed[:sep], " \t")
dotted, ok := legacyRemoteKeys[strings.ToLower(key)]
if !ok {
continue
}
indent := ln[:len(ln)-len(trimmed)]
gap := trimmed[len(key):sep] // whatever alignment was there
lines[i] = indent + dotted + gap + trimmed[sep:]
renamed = append(renamed, key+" → "+dotted)
}
if len(renamed) == 0 {
return
}
if err := os.WriteFile(path, []byte(strings.Join(lines, "\n")), configMode); err != nil {
errorln("could not update " + path + ": " + err.Error())
return
}
fmt.Println(col(cGray, path+": mirror settings renamed to the remote.<name>.* form"))
for _, r := range renamed {
fmt.Println(col(cGray, " "+r))
}
}
// projectGlobalOnly lists settings a project-level .mgshrc must not change: // projectGlobalOnly lists settings a project-level .mgshrc must not change:
// `base` decides where projects live in the first place, and the git identity // `base` decides where projects live in the first place, and the git identity
// keys are written to the user's *global* git config at startup — applying // keys are written to the user's *global* git config at startup — applying
@@ -276,15 +319,17 @@ func writeConfigTemplate(path string) {
b.WriteString("# gitname = Your Name\n") b.WriteString("# gitname = Your Name\n")
b.WriteString("# gitemail = you@example.com\n") b.WriteString("# gitemail = you@example.com\n")
b.WriteString("# pushdefault = matching\n") b.WriteString("# pushdefault = matching\n")
b.WriteString("# editor = code\n\n") b.WriteString("\n")
b.WriteString("# --- public mirrors for `pushremote` ---\n") b.WriteString("# --- public mirrors for `pushremote` ---\n")
b.WriteString("# One block per server; `pushremote` pushes to all of them,\n") b.WriteString("# One 'remote.<name>.*' block per server. `pushremote` pushes to all\n")
b.WriteString("# `pushremote @hub` to a single one.\n") b.WriteString("# of them, `pushremote @gitlab` to a single one. <name> is also the\n")
b.WriteString("# remote.hub.url = https://github.com\n") b.WriteString("# git remote created in the repository.\n")
b.WriteString("# remote.hub.key = <personal-access-token>\n") b.WriteString("# remote.gitlab.url = https://gitlab.example.com\n")
b.WriteString("# remote.hub.visibility = public\n") b.WriteString("# remote.gitlab.key = <personal-access-token>\n")
b.WriteString("# remotes = hub # optional: restrict/order the set\n") b.WriteString("# remote.gitlab.type = gitlab # optional; detected from the url\n")
b.WriteString("# mirror = true # `push` also mirrors\n\n") b.WriteString("# remote.gitlab.visibility = private # or public (default private)\n")
b.WriteString("# remotes = gitlab # optional: restrict/order the set\n")
b.WriteString("# mirror = true # `push` also mirrors\n\n")
b.WriteString("# A project may override any of these (except base and the git\n") b.WriteString("# A project may override any of these (except base and the git\n")
b.WriteString("# identity) in its own <project>/.mgshrc.\n") b.WriteString("# identity) in its own <project>/.mgshrc.\n")
@@ -363,14 +408,36 @@ func applyConfig(c *Config, m map[string]string) {
set("gitname", &c.GitName) set("gitname", &c.GitName)
set("gitemail", &c.GitEmail) set("gitemail", &c.GitEmail)
set("pushdefault", &c.PushDefault) set("pushdefault", &c.PushDefault)
set("editor", &c.Editor)
set("remoteurl", &c.RemoteURL)
set("remotekey", &c.RemoteKey)
set("remotetype", &c.RemoteType)
set("remotevisibility", &c.RemoteVis)
set("remotes", &c.RemoteNames) set("remotes", &c.RemoteNames)
set("mirror", &c.Mirror) set("mirror", &c.Mirror)
applyRemoteTargets(c, m) set("secretscan", &c.SecretScan)
applyRemoteTargets(c, foldLegacyRemoteKeys(m))
}
// foldLegacyRemoteKeys rewrites the pre-4.1 flat remote settings into the
// named-target form, so a configuration that has not been converted yet still
// works while it is being read. The file itself is converted by
// migrateRemoteKeys; this only makes the current run behave.
func foldLegacyRemoteKeys(m map[string]string) map[string]string {
folded, copied := m, false
for old, dotted := range legacyRemoteKeys {
v, ok := m[old]
if !ok || v == "" {
continue
}
if _, taken := m[dotted]; taken {
continue // an explicit new-style setting always wins
}
if !copied { // copy on first write, never touch the caller's map
folded = make(map[string]string, len(m))
for k, val := range m {
folded[k] = val
}
copied = true
}
folded[dotted] = v
}
return folded
} }
// remoteFieldRe matches a named mirror target setting: remote.<name>.<field>. // remoteFieldRe matches a named mirror target setting: remote.<name>.<field>.
@@ -436,11 +503,55 @@ func applyEnv(c *Config) {
env("MGSH_GITNAME", &c.GitName) env("MGSH_GITNAME", &c.GitName)
env("MGSH_GITEMAIL", &c.GitEmail) env("MGSH_GITEMAIL", &c.GitEmail)
env("MGSH_PUSHDEFAULT", &c.PushDefault) env("MGSH_PUSHDEFAULT", &c.PushDefault)
env("MGSH_EDITOR", &c.Editor)
env("MGSH_REMOTEURL", &c.RemoteURL)
env("MGSH_REMOTEKEY", &c.RemoteKey)
env("MGSH_REMOTETYPE", &c.RemoteType)
env("MGSH_REMOTEVISIBILITY", &c.RemoteVis)
env("MGSH_REMOTES", &c.RemoteNames) env("MGSH_REMOTES", &c.RemoteNames)
env("MGSH_MIRROR", &c.Mirror) env("MGSH_MIRROR", &c.Mirror)
env("MGSH_SECRETSCAN", &c.SecretScan)
applyRemoteEnv(c)
}
// remoteFields are the settings a mirror target is made of.
var remoteFields = []string{"url", "key", "type", "visibility"}
// applyRemoteEnv reads MGSH_REMOTE_<NAME>_<FIELD>, the environment spelling of
// a remote.<name>.<field> setting — MGSH_REMOTE_GITLAB_KEY for
// remote.gitlab.key. The field is taken from the end, so a target name may
// contain underscores itself.
func applyRemoteEnv(c *Config) {
const prefix = "MGSH_REMOTE_"
// sorted, so a target these variables introduce lands in the push order the
// same way on every run
envs := os.Environ()
sort.Strings(envs)
for _, kv := range envs {
eq := strings.IndexByte(kv, '=')
if eq < 0 {
continue
}
name, value := kv[:eq], kv[eq+1:]
if value == "" || !strings.HasPrefix(name, prefix) {
continue
}
rest := name[len(prefix):]
us := strings.LastIndexByte(rest, '_')
if us <= 0 {
continue
}
target, field := strings.ToLower(rest[:us]), strings.ToLower(rest[us+1:])
if !slices.Contains(remoteFields, field) {
continue // MGSH_REMOTES and anything else that merely starts alike
}
t := &c.Remotes[c.remoteIndex(target)]
switch field {
case "url":
t.URL = value
case "key":
t.Key = value
case "type":
t.Type = value
case "visibility":
t.Vis = value
}
}
} }
+16
View File
@@ -152,6 +152,22 @@ func sshOut(remote string) ([]string, error) {
return lines, err return lines, err
} }
// serverRepoNames lists the bare repositories on the git server, without the
// ".git" suffix.
func serverRepoNames() ([]string, error) {
lines, err := sshOut("/bin/ls .")
if err != nil {
return nil, err
}
var out []string
for _, ln := range lines {
if m := gitDirRe.FindStringSubmatch(strings.TrimSpace(ln)); m != nil {
out = append(out, m[1])
}
}
return out, nil
}
// serverEntryExists reports whether entry is present in the remote directory // serverEntryExists reports whether entry is present in the remote directory
// path (relative to the git user's home). The error is returned rather than // path (relative to the git user's home). The error is returned rather than
// folded into the bool so a failed lookup is never mistaken for "not there". // folded into the bool so a failed lookup is never mistaken for "not there".
+1 -1
View File
@@ -145,7 +145,7 @@ func parseArgs() (int, string, bool) {
} }
cls := map[string]int{ cls := map[string]int{
"clone": 2, "init": 2, "log": 2, "clone": 2, "init": 2, "log": 2,
"push": 1, "pushremote": 1, "list": 1, "tag": 1, "archive": 1, "show": 1, "open": 1, "push": 1, "pushremote": 1, "list": 1, "tag": 1, "archive": 1, "show": 1,
"pull": 1, "fetch": 1, "status": 1, "diff": 1, "overview": 1, "pull": 1, "fetch": 1, "status": 1, "diff": 1, "overview": 1,
"config": 1, "count": 1, "login": 1, "cloneall": 1, "release": 1, "config": 1, "count": 1, "login": 1, "cloneall": 1, "release": 1,
} }
+308 -47
View File
@@ -9,6 +9,7 @@ import (
"strings" "strings"
"testing" "testing"
"time" "time"
"unicode/utf8"
) )
func TestSanitizeComment(t *testing.T) { func TestSanitizeComment(t *testing.T) {
@@ -74,19 +75,65 @@ func TestFormatLogRecentCompact(t *testing.T) {
} }
} }
func TestColorRepoLine(t *testing.T) { func TestFormatRepoList(t *testing.T) {
useColor = false useColor = false
in := "Sep 28 2016 Betaflight3.0.0" entries := []lsEntry{
if got := colorRepoLine(in); got != in { {name: "short", date: "Sep 28 2016", size: 4096},
t.Errorf("colorRepoLine with color off changed input: %q", got) {name: "a-much-longer-name", date: "Jan 3 14:32", size: 1536},
} }
useColor = true out := formatRepoList(entries, false)
got := colorRepoLine(in) lines := strings.Split(strings.TrimRight(out, "\n"), "\n")
if !strings.Contains(got, "Betaflight3.0.0") || !strings.Contains(got, cGreen) || !strings.Contains(got, cYellow) { if len(lines) != 2 {
t.Errorf("colorRepoLine did not color parts: %q", got) t.Fatalf("expected 2 lines, got %d: %q", len(lines), out)
} }
// order is preserved: `ls -ltr` already sorted by modification time
if !strings.Contains(lines[0], "short") || !strings.Contains(lines[1], "a-much-longer-name") {
t.Errorf("order not preserved: %q", out)
}
// the date starts at the same column on every line
if strings.Index(lines[0], "Sep") != strings.Index(lines[1], "Jan") {
t.Errorf("date column not aligned:\n%s", out)
}
if strings.Contains(out, "4.0K") {
t.Errorf("size shown for repositories: %q", out)
}
if withSize := formatRepoList(entries, true); !strings.Contains(withSize, "4.0K") ||
!strings.Contains(withSize, "1.5K") {
t.Errorf("archive sizes missing: %q", withSize)
}
// colour must decorate the layout, never change it
useColor = true
colored := formatRepoList(entries, false)
useColor = false useColor = false
strip := func(s string) string {
for _, c := range []string{cReset, cGreen, cYellow, cGray} {
s = strings.ReplaceAll(s, c, "")
}
return s
}
if strip(colored) != out {
t.Errorf("colour changed the layout:\n%q\n%q", strip(colored), out)
}
}
func TestHumanSize(t *testing.T) {
cases := []struct {
n int64
want string
}{
{0, "0B"}, {512, "512B"}, {1024, "1.0K"}, {1536, "1.5K"},
{1024 * 1024, "1.0M"}, {3 * 1024 * 1024 * 1024, "3.0G"},
// past 10 the decimal carries nothing, as with `ls -h`
{512 * 1024, "512K"}, {99 * 1024 * 1024, "99M"},
}
for _, c := range cases {
if got := humanSize(c.n); got != c.want {
t.Errorf("humanSize(%d) = %q, want %q", c.n, got, c.want)
}
}
} }
func TestParseConfig(t *testing.T) { func TestParseConfig(t *testing.T) {
@@ -95,7 +142,7 @@ func TestParseConfig(t *testing.T) {
githost = 10.0.0.1 githost = 10.0.0.1
GitPort: 22 GitPort: 22
gituser = "deploy" gituser = "deploy"
editor = 'code' gitkey = 'mgit_rsa'
ignored line without separator ignored line without separator
base=/tmp/src base=/tmp/src
` `
@@ -104,7 +151,7 @@ base=/tmp/src
"githost": "10.0.0.1", "githost": "10.0.0.1",
"gitport": "22", "gitport": "22",
"gituser": "deploy", "gituser": "deploy",
"editor": "code", "gitkey": "mgit_rsa",
"base": "/tmp/src", "base": "/tmp/src",
} }
for k, want := range checks { for k, want := range checks {
@@ -119,7 +166,7 @@ base=/tmp/src
func TestParseConfigInlineComments(t *testing.T) { func TestParseConfigInlineComments(t *testing.T) {
rc := ` rc := `
editor = code # fallback opener for ` + "`open`" + ` gitkey = mgit_rsa # fallback opener comment
mirror = true # ` + "`push`" + ` also mirrors via pushremote mirror = true # ` + "`push`" + ` also mirrors via pushremote
gitport = 22 # ssh port gitport = 22 # ssh port
remotekey = abc#123 remotekey = abc#123
@@ -129,7 +176,7 @@ gitemail = # value is only a comment
` `
m := parseConfig(rc) m := parseConfig(rc)
checks := map[string]string{ checks := map[string]string{
"editor": "code", "gitkey": "mgit_rsa",
"mirror": "true", "mirror": "true",
"gitport": "22", "gitport": "22",
"remotekey": "abc#123", // '#' not preceded by space stays part of the value "remotekey": "abc#123", // '#' not preceded by space stays part of the value
@@ -147,38 +194,49 @@ gitemail = # value is only a comment
} }
} }
func TestLsEntry(t *testing.T) { func TestParseLsEntry(t *testing.T) {
cases := []struct{ line, suffix, want string }{ cases := []struct {
// ownership is not assumed: any user/group must list line, suffix string
{"drwxr-xr-x 7 git git 4096 Sep 28 2016 myproj.git", ".git", "Sep 28 2016 myproj"}, name, date string
{"drwxr-xr-x 7 deploy deploy 4096 Sep 28 2016 myproj.git", ".git", "Sep 28 2016 myproj"}, size int64
{"drwxr-xr-x 7 mike staff 4096 Sep 28 2016 myproj.git", ".git", "Sep 28 2016 myproj"}, ok bool
{"drwxr-xr-x. 7 git users 4096 Sep 28 2016 myproj.git", ".git", "Sep 28 2016 myproj"}, }{
// archives only match the archive suffix, and vice versa // ownership is not assumed: any user/group must parse
{"-rw-r--r-- 1 git git 512 Sep 28 2016 myproj.git.tar.gz", ".git.tar.gz", "Sep 28 2016 myproj"}, {"drwxr-xr-x 7 git git 4096 Sep 28 2016 myproj.git", ".git", "myproj", "Sep 28 2016", 4096, true},
{"-rw-r--r-- 1 git git 512 Sep 28 2016 myproj.git.tar.gz", ".git", ""}, {"drwxr-xr-x 7 deploy deploy 4096 Sep 28 2016 myproj.git", ".git", "myproj", "Sep 28 2016", 4096, true},
{"drwxr-xr-x 7 git git 4096 Sep 28 2016 myproj.git", ".git.tar.gz", ""}, {"drwxr-xr-x. 7 git users 4096 Sep 28 2016 myproj.git", ".git", "myproj", "Sep 28 2016", 4096, true},
// a recent entry carries a time instead of a year, and still lines up
{"drwxr-xr-x 7 mike staff 224 Jan 3 14:32 myproj.git", ".git", "myproj", "Jan 3 14:32", 224, true},
// a symlinked bare repo lists its target too — only the link name counts
{"lrwxrwxrwx 1 git git 14 Sep 28 2016 myproj.git -> /srv/other.git", ".git", "myproj", "Sep 28 2016", 14, true},
// archives carry a size worth showing
{"-rw-r--r-- 1 git git 524288 Sep 28 2016 myproj.git.tar.gz", ".git.tar.gz", "myproj", "Sep 28 2016", 524288, true},
// suffixes must not cross over
{"-rw-r--r-- 1 git git 512 Sep 28 2016 myproj.git.tar.gz", ".git", "", "", 0, false},
{"drwxr-xr-x 7 git git 4096 Sep 28 2016 myproj.git", ".git.tar.gz", "", "", 0, false},
{"lrwxrwxrwx 1 git git 5 Sep 28 2016 notes -> x.git", ".git", "", "", 0, false},
// non-entries // non-entries
{"total 48", ".git", ""}, {"total 48", ".git", "", "", 0, false},
{"", ".git", ""}, {"", ".git", "", "", 0, false},
{"drwxr-xr-x 7 git git 4096 Sep 28 2016 notes", ".git", ""}, {"drwxr-xr-x 7 git git 4096 Sep 28 2016 notes", ".git", "", "", 0, false},
} }
for _, c := range cases { for _, c := range cases {
if got := lsEntry(c.line, c.suffix); got != c.want { e, ok := parseLsEntry(c.line, c.suffix)
t.Errorf("lsEntry(%q, %q) = %q, want %q", c.line, c.suffix, got, c.want) if ok != c.ok {
t.Errorf("parseLsEntry(%q, %q) ok = %v, want %v", c.line, c.suffix, ok, c.ok)
continue
}
if !ok {
continue
}
if e.name != c.name || e.size != c.size {
t.Errorf("parseLsEntry(%q) = %+v, want name %q size %d", c.line, e, c.name, c.size)
}
// every date renders to the same width, whichever form ls used
if e.date != c.date || len(e.date) != 12 {
t.Errorf("parseLsEntry(%q) date = %q (len %d), want %q at 12",
c.line, e.date, len(e.date), c.date)
} }
}
}
func TestLsEntrySymlink(t *testing.T) {
// a symlinked bare repo lists its target too — only the link name counts
in := "lrwxrwxrwx 1 git git 14 Sep 28 2016 myproj.git -> /srv/other.git"
if got := lsEntry(in, ".git"); got != "Sep 28 2016 myproj" {
t.Errorf("lsEntry(symlink) = %q, want %q", got, "Sep 28 2016 myproj")
}
// and a symlink to something that is not a repo must not match
if got := lsEntry("lrwxrwxrwx 1 git git 5 Sep 28 2016 notes -> x.git", ".git"); got != "" {
t.Errorf("lsEntry(non-repo symlink) = %q, want empty", got)
} }
} }
@@ -410,7 +468,7 @@ remote.broken.url = https://nowhere.example # no key -> unusable
} }
func TestMirrorTargetsLegacyAndSelection(t *testing.T) { func TestMirrorTargetsLegacyAndSelection(t *testing.T) {
// the flat remoteurl/remotekey pair stays supported, as target "public" // the pre-4.1 flat pair still loads, folded onto the target "public"
var c Config var c Config
applyConfig(&c, parseConfig("remoteurl = https://git.example.com\nremotekey = tok\n")) applyConfig(&c, parseConfig("remoteurl = https://git.example.com\nremotekey = tok\n"))
usable, _ := c.mirrorTargets() usable, _ := c.mirrorTargets()
@@ -481,7 +539,7 @@ func TestResolveProjectConfig(t *testing.T) {
dir := t.TempDir() dir := t.TempDir()
global := Config{ global := Config{
Base: "/base", GitHost: "global.example", GitPort: "22", GitUser: "git", Base: "/base", GitHost: "global.example", GitPort: "22", GitUser: "git",
GitPath: "/home/git", GitName: "Global Name", Editor: "vi", GitPath: "/home/git", GitName: "Global Name", GitKey: "global_rsa",
Remotes: []RemoteTarget{{Name: "gitea", URL: "https://gitea.example", Key: "tok"}}, Remotes: []RemoteTarget{{Name: "gitea", URL: "https://gitea.example", Key: "tok"}},
} }
@@ -492,7 +550,7 @@ func TestResolveProjectConfig(t *testing.T) {
rc := ` rc := `
githost = project.example githost = project.example
editor = code gitkey = project_rsa
base = /somewhere/else base = /somewhere/else
gitname = Project Name gitname = Project Name
remote.hub.url = https://github.com remote.hub.url = https://github.com
@@ -504,8 +562,8 @@ remote.gitea.visibility = public
} }
got := resolveConfig(global, dir) got := resolveConfig(global, dir)
if got.GitHost != "project.example" || got.Editor != "code" { if got.GitHost != "project.example" || got.GitKey != "project_rsa" {
t.Errorf("project overrides not applied: host=%q editor=%q", got.GitHost, got.Editor) t.Errorf("project overrides not applied: host=%q gitkey=%q", got.GitHost, got.GitKey)
} }
// base and the git identity stay global // base and the git identity stay global
if got.Base != "/base" { if got.Base != "/base" {
@@ -749,6 +807,7 @@ func TestTruthy(t *testing.T) {
func TestFormatProjStatus(t *testing.T) { func TestFormatProjStatus(t *testing.T) {
useColor = false useColor = false
defer func() { useColor = false }() defer func() { useColor = false }()
w := overviewWidths{label: 12, sync: 5, host: 7}
cases := []struct { cases := []struct {
s projStatus s projStatus
contains []string contains []string
@@ -763,10 +822,16 @@ func TestFormatProjStatus(t *testing.T) {
{projStatus{name: "d", branch: "feature", dirty: true}, {projStatus{name: "d", branch: "feature", dirty: true},
[]string{"d", "*", "(feature)"}, nil}, []string{"d", "*", "(feature)"}, nil},
{projStatus{name: "e", branch: "master"}, // clean, no upstream {projStatus{name: "e", branch: "master"}, // clean, no upstream
[]string{"e", "no upstream"}, []string{"*"}}, []string{"e", ""}, []string{"*", "✓"}},
{projStatus{name: "f", branch: "master", hasUpstream: true, ahead: 1, behind: 2},
[]string{"f", "↑1↓2"}, []string{"✓"}}, // diverged shows both
{projStatus{name: "g", branch: "master", hasUpstream: true, lastHost: "laptop",
mirrors: []string{"hub", "gitea"}},
[]string{"g", "laptop", "→ hub gitea"}, nil},
} }
for _, c := range cases { for _, c := range cases {
got := formatProjStatus(c.s, 8) c.s.isRepo = true // these all describe real repositories
got := formatProjStatus(c.s, w)
for _, sub := range c.contains { for _, sub := range c.contains {
if !strings.Contains(got, sub) { if !strings.Contains(got, sub) {
t.Errorf("formatProjStatus(%+v) = %q, missing %q", c.s, got, sub) t.Errorf("formatProjStatus(%+v) = %q, missing %q", c.s, got, sub)
@@ -780,6 +845,62 @@ func TestFormatProjStatus(t *testing.T) {
} }
} }
// TestOverviewColumnsAlign is the point of the table: every field has to start
// at the same column on every row, whatever the name lengths or the multi-byte
// status glyphs do.
func TestOverviewColumnsAlign(t *testing.T) {
useColor = false
// host names must not occur anywhere else in a row, or the index search
// below would find them inside a project or branch name instead
rows := []projStatus{
{name: "a", branch: "master", hasUpstream: true, ahead: 12, behind: 3, lastHost: "workstation"},
{name: "a-very-long-project-name", branch: "wip", dirty: true, lastHost: "buildbox"},
{name: "mid", branch: "main", hasUpstream: true, lastHost: "laptop"},
}
w := measureOverview(rows)
var widths []int
for _, r := range rows {
line := formatProjStatus(r, w)
// the host column starts right after the padded sync field
idx := strings.Index(line, r.lastHost)
if idx < 0 {
t.Fatalf("host %q missing from %q", r.lastHost, line)
}
widths = append(widths, utf8.RuneCountInString(line[:idx]))
}
for i := 1; i < len(widths); i++ {
if widths[i] != widths[0] {
t.Errorf("host column starts at %d on row %d, %d on row 0:\n%s",
widths[i], i, widths[0], strings.Join([]string{
formatProjStatus(rows[0], w), formatProjStatus(rows[i], w)}, "\n"))
}
}
}
func TestAttentionRank(t *testing.T) {
ranks := []struct {
s projStatus
want int
}{
{projStatus{isRepo: true, dirty: true}, 0},
{projStatus{isRepo: true, ahead: 1}, 0},
{projStatus{isRepo: true, behind: 1}, 0},
{projStatus{isRepo: true, hasUpstream: true}, 1},
{projStatus{isRepo: true}, 1}, // clean, no upstream
// not on the server is a different kind of task and goes last, even
// when the working tree is dirty — it cannot be pushed anyway
{projStatus{isRepo: true, notOnServer: true}, 2},
{projStatus{isRepo: true, dirty: true, notOnServer: true}, 2},
{projStatus{notOnServer: true}, 2},
}
for _, c := range ranks {
if got := attentionRank(c.s); got != c.want {
t.Errorf("attentionRank(%+v) = %d, want %d", c.s, got, c.want)
}
}
}
func TestDetectRemoteKind(t *testing.T) { func TestDetectRemoteKind(t *testing.T) {
cases := []struct { cases := []struct {
url, override string url, override string
@@ -886,3 +1007,143 @@ func TestExpandAlias(t *testing.T) {
} }
} }
} }
func TestSplitAtMarker(t *testing.T) {
lines := []string{"a", "b", "---mgsh---", "c", "d"}
before, after := splitAtMarker(lines, "---mgsh---")
if strings.Join(before, ",") != "a,b" || strings.Join(after, ",") != "c,d" {
t.Errorf("split = %v / %v", before, after)
}
// no marker: everything is the first section, so a server that produced no
// du output simply yields no sizes
before, after = splitAtMarker([]string{"a", "b"}, "---mgsh---")
if strings.Join(before, ",") != "a,b" || after != nil {
t.Errorf("split without marker = %v / %v", before, after)
}
}
func TestParseDuSizes(t *testing.T) {
lines := []string{
"185432\tBetaflight3.0.0.git",
"2144\twebsite.git",
"876 spaced-with-blanks.git", // some du implementations use spaces
"1024\t./with-dot-slash.git",
"1500\tmy project.git", // a name with a space survives
"garbage",
"",
}
got := parseDuSizes(lines)
want := map[string]int64{
"Betaflight3.0.0.git": 185432 * 1024,
"website.git": 2144 * 1024,
"spaced-with-blanks.git": 876 * 1024,
"with-dot-slash.git": 1024 * 1024,
"my project.git": 1500 * 1024,
}
if len(got) != len(want) {
t.Fatalf("parseDuSizes = %v, want %d entries", got, len(want))
}
for k, v := range want {
if got[k] != v {
t.Errorf("parseDuSizes[%q] = %d, want %d", k, got[k], v)
}
}
}
// TestMigrateRemoteKeys rewrites the pre-4.1 flat spelling in place. Only the
// key changes: values, comments and everything else stay byte for byte.
func TestMigrateRemoteKeys(t *testing.T) {
dir := t.TempDir()
path := filepath.Join(dir, ".mgshrc")
before := `# my config
base = /home/me/src
remoteurl = https://git.example.com # the mirror
remotekey = s3cr3t-token
remotevisibility: public
# remotetype = gitea (commented out, must stay put)
alias co 'checkout $1'
`
if err := os.WriteFile(path, []byte(before), 0600); err != nil {
t.Fatal(err)
}
out := captureStdout(t, func() { migrateRemoteKeys(path, before) })
got := readFile(t, path)
for _, want := range []string{
"remote.public.url = https://git.example.com # the mirror",
"remote.public.key = s3cr3t-token",
" remote.public.visibility: public",
"# remotetype = gitea (commented out, must stay put)",
"base = /home/me/src",
"alias co 'checkout $1'",
} {
if !strings.Contains(got, want) {
t.Errorf("migrated file missing %q:\n%s", want, got)
}
}
if strings.Contains(got, "\nremoteurl") || strings.Contains(got, "\nremotekey") {
t.Errorf("old spelling left behind:\n%s", got)
}
if !strings.Contains(out, "remoteurl → remote.public.url") {
t.Errorf("migration was not reported: %q", out)
}
// the file keeps its private mode
if fi, err := os.Stat(path); err != nil {
t.Fatal(err)
} else if fi.Mode().Perm() != 0o600 {
t.Errorf("mode after migration = %04o, want 0600", fi.Mode().Perm())
}
// running again changes nothing and says nothing
second := captureStdout(t, func() { migrateRemoteKeys(path, readFile(t, path)) })
if strings.TrimSpace(second) != "" {
t.Errorf("a converted file was migrated again: %q", second)
}
if readFile(t, path) != got {
t.Error("a second migration changed the file")
}
}
// TestLegacyKeysDoNotOverrideExplicitOnes: a config carrying both spellings must
// keep what the new one says.
func TestLegacyKeysDoNotOverrideExplicitOnes(t *testing.T) {
var c Config
applyConfig(&c, parseConfig(
"remoteurl = https://old.example\nremote.public.url = https://new.example\n"+
"remote.public.key = tok\n"))
targets, _ := c.mirrorTargets()
if len(targets) != 1 || targets[0].URL != "https://new.example" {
t.Errorf("targets = %+v, want the remote.public.url value", targets)
}
}
// TestRemoteEnvOverrides: MGSH_REMOTE_<NAME>_<FIELD> is the environment
// spelling of remote.<name>.<field>.
func TestRemoteEnvOverrides(t *testing.T) {
t.Setenv("MGSH_REMOTE_GITLAB_URL", "https://gitlab.example")
t.Setenv("MGSH_REMOTE_GITLAB_KEY", "env-token")
t.Setenv("MGSH_REMOTE_GITLAB_VISIBILITY", "public")
t.Setenv("MGSH_REMOTE_MY_HUB_URL", "https://hub.example") // name with an underscore
t.Setenv("MGSH_REMOTE_MY_HUB_KEY", "hub-token")
t.Setenv("MGSH_REMOTES", "") // must not be mistaken for a target field
var c Config
applyConfig(&c, parseConfig("remote.gitlab.url = https://from-file.example\nremote.gitlab.key = file-token\n"))
applyEnv(&c)
targets, incomplete := c.mirrorTargets()
if len(incomplete) != 0 {
t.Fatalf("incomplete targets: %v", incomplete)
}
byName := map[string]RemoteTarget{}
for _, tg := range targets {
byName[tg.Name] = tg
}
if g := byName["gitlab"]; g.URL != "https://gitlab.example" || g.Key != "env-token" || g.Vis != "public" {
t.Errorf("env did not override the file: %+v", g)
}
// the field is taken from the end, so the name may contain underscores
if h := byName["my_hub"]; h.URL != "https://hub.example" || h.Key != "hub-token" {
t.Errorf("MGSH_REMOTE_MY_HUB_* = %+v, want target my_hub", h)
}
}
+22 -17
View File
@@ -20,27 +20,32 @@ gitpath = /home/git
# gitname = Your Name # gitname = Your Name
# gitemail = you@example.com # gitemail = you@example.com
# pushdefault = matching # pushdefault = matching
# editor = code
# --- pushremote: mirror to public servers (gitea/github/gitlab) via their API --- # --- pushremote: mirror to public servers (gitea/github/gitlab) via their API ---
# A single server, the flat form (this target is named "public"): # One "remote.<name>.<field>" block per server, with the fields url, key, type
# remoteurl = https://git.example.com # and visibility. <name> is yours to pick and becomes the git remote created in
# remotekey = <personal-access-token> # the repository, so `git push gitlab` keeps working outside mgsh.
# remotetype = gitea # optional; auto-detected from remoteurl
# remotevisibility = private # visibility of created repos (default private)
# #
# Or any number of named servers. `pushremote` pushes to all of them in the # `pushremote` pushes to every configured server in the order given,
# order given, `pushremote @hub` to a single one. Each target gets a git remote # `pushremote @gitlab` to a single one.
# of the same name in the repository.
# remote.gitea.url = https://git.example.com
# remote.gitea.key = <personal-access-token>
# remote.hub.url = https://github.com
# remote.hub.key = <personal-access-token>
# remote.hub.type = github # optional; auto-detected from the url
# remote.hub.visibility = public # default private
# remotes = gitea, hub # optional: restrict and order the set
# #
# mirror = true # `push` also mirrors via pushremote # remote.gitea.url = https://git.example.com
# remote.gitea.key = <personal-access-token>
# remote.gitea.type = gitea # optional; auto-detected from the url
# remote.gitea.visibility = private # or public (default private)
#
# remote.gitlab.url = https://gitlab.example.com
# remote.gitlab.key = <personal-access-token>
# remote.gitlab.type = gitlab
# remote.gitlab.visibility = public
#
# remotes = gitea, gitlab # optional: restrict and order the set
# mirror = true # `push` also mirrors via pushremote
# --- safety ---
# `push` checks the staged diff for private keys and API tokens before it
# commits, and asks before continuing. Only an explicit "off" disables it.
# secretscan = off
# --- per-project overrides --- # --- per-project overrides ---
# A <project>/.mgshrc overrides all of the above for that project only, except # A <project>/.mgshrc overrides all of the above for that project only, except
+317 -55
View File
@@ -1,25 +1,49 @@
package main package main
// overview.go — the `overview` command (also reachable as `status -a`): a // overview.go — the `overview` command (also reachable as `status -a`).
// one-line-per-project summary of every git project under BASE, showing the //
// dirty state and how far each branch is ahead/behind its upstream. // mgsh is the only thing that sees all three places a project can live: the
// local base directory, the internal ssh server, and the public mirrors. Joining
// those answers the questions plain git cannot — which projects were never
// pushed to the server, and which machine last touched each one (every `push`
// stamps "[user@host]" into the commit message, so that comes for free).
import ( import (
"fmt" "fmt"
"os" "os"
"regexp"
"sort"
"strconv"
"strings" "strings"
"sync"
"time"
"unicode/utf8"
) )
// projStatus is the collected state of one project for the overview. // projStatus is the collected state of one project for the overview.
type projStatus struct { type projStatus struct {
name string name string
branch string branch string
isRepo bool // has a .git of its own
dirty bool dirty bool
ahead, behind int ahead, behind int
hasUpstream bool hasUpstream bool
notOnServer bool // known to be missing from the git server
lastHost string // machine that made the last commit, from "[user@host]"
lastWhen time.Time // when that was
mirrors []string // configured mirror remotes present in this repo
} }
// overviewAll prints a status summary for all git projects under BASE. // commitHostRe pulls the host out of the "[user@host] subject" line that `push`
// writes, so the overview can say where a project was last worked on.
var commitHostRe = regexp.MustCompile(`^\[[^@\]]*@([^\]]+)\]`)
// overviewScanLimit bounds how many projects are inspected at once. The work is
// all subprocess latency, so some concurrency helps a lot and more does not.
const overviewScanLimit = 8
// overviewAll prints a status summary for all git projects under BASE, plus the
// projects that exist on only one side of the local/server divide.
func overviewAll() { func overviewAll() {
entries, err := os.ReadDir(BASE) entries, err := os.ReadDir(BASE)
if err != nil { if err != nil {
@@ -27,30 +51,52 @@ func overviewAll() {
return return
} }
var rows []projStatus // ask the server while the local tree is being walked
width := 0 type serverList struct {
names []string
err error
}
srvCh := make(chan serverList, 1)
go func() {
names, err := serverRepoNames()
srvCh <- serverList{names, err}
}()
// every directory gets a row, repository or not: one that is not a
// repository yet is exactly what `init` is for, and putting it in the table
// beats a separate list underneath
var local []string
for _, e := range entries { for _, e := range entries {
if !e.IsDir() || strings.HasPrefix(e.Name(), ".") { if e.IsDir() && !strings.HasPrefix(e.Name(), ".") {
continue local = append(local, e.Name())
}
dir := BASE + "/" + e.Name()
if !isDir(dir + "/.git") {
continue
}
rows = append(rows, projectStatus(e.Name(), dir))
if len(e.Name()) > width {
width = len(e.Name())
} }
} }
rows := scanProjects(local)
srv := <-srvCh
markUnpublished(rows, srv.names, srv.err)
// what needs doing first, alphabetical within each group
sort.SliceStable(rows, func(i, j int) bool {
return attentionRank(rows[i]) < attentionRank(rows[j])
})
if len(rows) == 0 { if len(rows) == 0 {
fmt.Println(col(cGray, "no git projects under "+BASE)) fmt.Println(col(cGray, "nothing under "+BASE))
return return
} }
dirtyN, syncN := 0, 0 w := measureOverview(rows)
repoN, dirtyN, syncN, initN := 0, 0, 0, 0
for _, r := range rows { for _, r := range rows {
fmt.Println(formatProjStatus(r, width)) fmt.Println(formatProjStatus(r, w))
if r.notOnServer {
initN++
}
if !r.isRepo {
continue
}
repoN++
if r.dirty { if r.dirty {
dirtyN++ dirtyN++
} }
@@ -58,51 +104,267 @@ func overviewAll() {
syncN++ syncN++
} }
} }
fmt.Printf("%s\n", col(cGray, fmt.Sprintf("%d projects · %d dirty · %d in sync", len(rows), dirtyN, syncN)))
summary := fmt.Sprintf("%d projects · %d dirty · %d in sync", repoN, dirtyN, syncN)
if initN > 0 {
summary += fmt.Sprintf(" · %d to init", initN)
}
fmt.Println(col(cGray, summary))
if srv.err != nil {
fmt.Println(col(cGray, " git server not reachable — local view only"))
}
} }
// projectStatus gathers the git state of a single project directory. // scanProjects collects the state of every project concurrently. Each project
// costs two git subprocesses, and serially that is the slowest thing mgsh does.
func scanProjects(names []string) []projStatus {
rows := make([]projStatus, len(names))
sem := make(chan struct{}, overviewScanLimit)
var wg sync.WaitGroup
for i, n := range names {
wg.Add(1)
go func(i int, n string) {
defer wg.Done()
sem <- struct{}{}
defer func() { <-sem }()
rows[i] = projectStatus(n, BASE+"/"+n)
}(i, n)
}
wg.Wait()
return rows
}
// markUnpublished flags the rows the git server has never seen — the ones
// `init` is for. A listing that failed leaves every row unmarked: not knowing
// is not the same as knowing they are missing, and marking all of them would
// tell the user to re-init their whole base directory.
func markUnpublished(rows []projStatus, server []string, err error) {
if err != nil {
return
}
onServer := map[string]bool{}
for _, n := range server {
onServer[n] = true
}
for i := range rows {
rows[i].notOnServer = !onServer[rows[i].name]
}
}
// projectStatus gathers the git state of a single project directory. A
// directory without a repository is reported as it is, and costs no
// subprocesses at all.
func projectStatus(name, dir string) projStatus { func projectStatus(name, dir string) projStatus {
s := projStatus{name: name, branch: "-"} s := projStatus{name: name, branch: "-"}
if out, err := gitCapture(dir, "rev-parse", "--abbrev-ref", "HEAD"); err == nil { if s.isRepo = isDir(dir + "/.git"); !s.isRepo {
s.branch = strings.TrimSpace(out) return s
}
if out, err := gitCapture(dir, "status", "--porcelain"); err == nil && strings.TrimSpace(out) != "" {
s.dirty = true
}
// left/right counts against the upstream: "<behind>\t<ahead>"
if out, err := gitCapture(dir, "rev-list", "--left-right", "--count", "@{upstream}...HEAD"); err == nil {
if _, e := fmt.Sscanf(strings.TrimSpace(out), "%d\t%d", &s.behind, &s.ahead); e == nil {
s.hasUpstream = true
}
} }
readStatus(&s, dir)
readLastCommit(&s, dir)
s.mirrors = configuredMirrors(dir)
return s return s
} }
// formatProjStatus renders one aligned overview row. // readStatus fills in branch, upstream, ahead/behind and dirty from a single
func formatProjStatus(s projStatus, width int) string { // `git status` — the porcelain v2 header carries all four.
var marks []string func readStatus(s *projStatus, dir string) {
if s.dirty { out, err := gitCapture(dir, "status", "--porcelain=v2", "--branch")
marks = append(marks, col(cYellow, "*")) if err != nil {
return
} }
if s.ahead > 0 { for _, ln := range splitLines(out) {
marks = append(marks, col(cGreen, fmt.Sprintf("↑%d", s.ahead))) if !strings.HasPrefix(ln, "# ") {
} s.dirty = true // any entry line means the tree is not clean
if s.behind > 0 { continue
marks = append(marks, col(cRed, fmt.Sprintf("↓%d", s.behind))) }
} f := strings.Fields(ln)
state := strings.Join(marks, " ") if len(f) < 3 {
if state == "" { continue
if s.hasUpstream { }
state = col(cGreen, "✓") switch f[1] {
} else { case "branch.head":
state = col(cGray, "✓ (no upstream)") s.branch = f[2]
case "branch.upstream":
s.hasUpstream = true
case "branch.ab":
if len(f) >= 4 {
s.ahead, _ = strconv.Atoi(strings.TrimPrefix(f[2], "+"))
s.behind, _ = strconv.Atoi(strings.TrimPrefix(f[3], "-"))
}
} }
} }
}
line := " " + col(cGreen, padRight(s.name, width+2)) + state
if s.branch != "master" && s.branch != "main" && s.branch != "-" { // readLastCommit records when the project was last committed to and from which
line += col(cGray, " ("+s.branch+")") // machine, taken from the "[user@host]" prefix `push` writes.
} func readLastCommit(s *projStatus, dir string) {
return line out, err := gitCapture(dir, "log", "-1", "--format=%ct%x00%s")
if err != nil {
return
}
parts := strings.SplitN(strings.TrimSpace(out), "\x00", 2)
if len(parts) != 2 {
return
}
if epoch, err := strconv.ParseInt(parts[0], 10, 64); err == nil {
s.lastWhen = time.Unix(epoch, 0)
}
if m := commitHostRe.FindStringSubmatch(parts[1]); m != nil {
s.lastHost = m[1]
}
}
// configuredMirrors returns the mirror targets this repository actually has a
// remote for — free to determine, since it is only local git config.
func configuredMirrors(dir string) []string {
targets, _ := cfg.mirrorTargets()
if len(targets) == 0 {
return nil
}
out, err := gitCapture(dir, "remote")
if err != nil {
return nil
}
have := map[string]bool{}
for _, r := range splitLines(out) {
have[strings.TrimSpace(r)] = true
}
var found []string
for _, t := range targets {
if have[t.Name] {
found = append(found, t.Name)
}
}
return found
}
// overviewWidths are the column widths of the overview table, measured from the
// rows so every field starts at the same place. Ragged columns were what made
// the old one-line-per-project output hard to read.
type overviewWidths struct {
label, sync, host int
hint bool // any row carries an action hint
}
// hintWidth is the width of the action column, sized for its only word.
const hintWidth = 4
// measureOverview sizes the columns for a set of rows.
func measureOverview(rows []projStatus) overviewWidths {
var w overviewWidths
for _, r := range rows {
w.label = max(w.label, utf8.RuneCountInString(projLabel(r)))
w.sync = max(w.sync, utf8.RuneCountInString(syncState(r)))
w.host = max(w.host, utf8.RuneCountInString(r.lastHost))
w.hint = w.hint || r.notOnServer
}
return w
}
// projLabel is the first column: the project, with its branch appended when it
// is not the usual one. Keeping the branch attached to the name costs no extra
// column and keeps the table narrow.
func projLabel(s projStatus) string {
if s.branch != "" && s.branch != "-" && s.branch != "master" && s.branch != "main" {
return s.name + " (" + s.branch + ")"
}
return s.name
}
// syncState renders the relation to the upstream as one short field: ahead,
// behind, both, in sync, or "" for a branch that tracks nothing. The old
// spelled-out "(no upstream)" was fifteen columns wide and pushed every
// following field out of line.
func syncState(s projStatus) string {
switch {
case !s.isRepo:
return "" // nothing to compare: there is no repository here yet
case s.ahead > 0 && s.behind > 0:
return fmt.Sprintf("↑%d↓%d", s.ahead, s.behind)
case s.ahead > 0:
return fmt.Sprintf("↑%d", s.ahead)
case s.behind > 0:
return fmt.Sprintf("↓%d", s.behind)
case s.hasUpstream:
return "✓"
default:
return ""
}
}
// syncColor weights a row visually: anything needing action is coloured, a
// project that is clean and in sync recedes into grey.
func syncColor(s projStatus) string {
switch {
case s.behind > 0:
return cRed
case s.ahead > 0:
return cGreen
default:
return cGray
}
}
// attentionRank groups the rows: work in progress at the top, then everything
// that is settled, and last the directories the server does not have yet. With
// many projects, scanning the whole list for the two dirty ones is the actual
// work — and an un-inited directory is a different kind of task, not something
// to push past the daily ones.
func attentionRank(s projStatus) int {
switch {
case s.notOnServer:
return 2
case s.dirty || s.ahead > 0 || s.behind > 0:
return 0
default:
return 1
}
}
// formatProjStatus renders one row of the overview table.
func formatProjStatus(s projStatus, w overviewWidths) string {
dirty := " "
if s.dirty {
dirty = "*"
}
var b strings.Builder
b.WriteString(" ")
b.WriteString(col(cGreen, padRight(projLabel(s), w.label)))
b.WriteString(" " + col(cYellow, dirty) + " ")
b.WriteString(col(syncColor(s), padRight(syncState(s), w.sync)))
if w.host > 0 {
age := ""
if !s.lastWhen.IsZero() {
age = shortAge(time.Since(s.lastWhen))
}
b.WriteString(" " + col(cGray, padRight(s.lastHost, w.host)))
b.WriteString(" " + col(cGray, fmt.Sprintf("%4s", age)))
}
if w.hint {
hint := ""
if s.notOnServer {
hint = "init"
}
b.WriteString(" " + col(cYellow, padRight(hint, hintWidth)))
}
if len(s.mirrors) > 0 {
b.WriteString(col(cGray, " → "+strings.Join(s.mirrors, " ")))
}
return strings.TrimRight(b.String(), " ")
}
// shortAge renders a duration compactly: 90s -> "1m", 36h -> "1d".
func shortAge(d time.Duration) string {
switch {
case d < time.Minute:
return "now"
case d < time.Hour:
return fmt.Sprintf("%dm", int(d.Minutes()))
case d < 24*time.Hour:
return fmt.Sprintf("%dh", int(d.Hours()))
default:
return fmt.Sprintf("%dd", int(d.Hours()/24))
}
} }
+182
View File
@@ -0,0 +1,182 @@
package main
// overview_test.go — the inventory view: what mgsh knows that plain git cannot.
import (
"errors"
"io"
"os"
"path/filepath"
"strings"
"testing"
"time"
)
// TestReadStatusParsesPorcelainV2 covers the single `git status` call that
// replaced three separate ones: branch, upstream, ahead/behind and dirty all
// come out of its header.
func TestReadStatusParsesPorcelainV2(t *testing.T) {
dir := t.TempDir()
bare := filepath.Join(t.TempDir(), "o.git")
mustGit(t, "", "init", "--bare", "-q", bare)
mustGit(t, dir, "init", "-q")
mustGit(t, dir, "config", "user.name", "t")
mustGit(t, dir, "config", "user.email", "t@e")
mustGit(t, dir, "commit", "-q", "--allow-empty", "-m", "[mike@laptop] work")
mustGit(t, dir, "remote", "add", "origin", bare)
mustGit(t, dir, "push", "-q", "-u", "origin", "HEAD")
var s projStatus
readStatus(&s, dir)
if !s.hasUpstream || s.ahead != 0 || s.behind != 0 || s.dirty {
t.Errorf("clean synced repo = %+v", s)
}
if s.branch == "" || s.branch == "-" {
t.Errorf("branch not read: %q", s.branch)
}
// one unstaged file and one unpushed commit
if err := os.WriteFile(filepath.Join(dir, "x"), []byte("x"), 0644); err != nil {
t.Fatal(err)
}
mustGit(t, dir, "commit", "-q", "--allow-empty", "-m", "[mike@desktop] more")
s = projStatus{}
readStatus(&s, dir)
if !s.dirty {
t.Error("untracked file did not register as dirty")
}
if s.ahead != 1 {
t.Errorf("ahead = %d, want 1", s.ahead)
}
// and the host stamp `push` writes is picked up
var l projStatus
readLastCommit(&l, dir)
if l.lastHost != "desktop" {
t.Errorf("lastHost = %q, want desktop", l.lastHost)
}
if l.lastWhen.IsZero() {
t.Error("lastWhen not read")
}
}
// TestCommitHostRe: only mgsh's own "[user@host]" stamp counts.
func TestCommitHostRe(t *testing.T) {
cases := map[string]string{
"[mike@laptop] fixed a thing": "laptop",
"[mike@build-01] ": "build-01",
"[@host] no user": "host",
"fixed a thing": "",
"[not a stamp] text": "",
"see [a@b] mid-line": "",
}
for subj, want := range cases {
got := ""
if m := commitHostRe.FindStringSubmatch(subj); m != nil {
got = m[1]
}
if got != want {
t.Errorf("host of %q = %q, want %q", subj, got, want)
}
}
}
// TestMarkUnpublishedUnreachableServer: a listing that failed must leave every
// row unmarked. Not knowing is not the same as knowing they are missing —
// marking all of them would tell the user to re-init their whole base.
func TestMarkUnpublishedUnreachableServer(t *testing.T) {
rows := []projStatus{{name: "a"}, {name: "b"}}
markUnpublished(rows, nil, errors.New("network is unreachable"))
for _, r := range rows {
if r.notOnServer {
t.Errorf("%s marked as missing although the server could not be listed", r.name)
}
}
}
// TestMarkUnpublished flags only what the server really does not have.
func TestMarkUnpublished(t *testing.T) {
rows := []projStatus{{name: "both"}, {name: "onlyhere"}}
markUnpublished(rows, []string{"both", "onlythere"}, nil)
if rows[0].notOnServer {
t.Error("a project present on both sides was marked")
}
if !rows[1].notOnServer {
t.Error("a local-only project was not marked")
}
}
// TestUnpublishedRowsCarryTheHint: the entries live in the table now, with the
// action in their own column, rather than in a list underneath it.
func TestUnpublishedRowsCarryTheHint(t *testing.T) {
useColor = false
rows := []projStatus{
{name: "published", isRepo: true, hasUpstream: true},
{name: "fresh", isRepo: true, notOnServer: true},
{name: "notarepo", notOnServer: true},
}
w := measureOverview(rows)
if !w.hint {
t.Fatal("hint column not reserved although rows need it")
}
got := []string{}
for _, r := range rows {
got = append(got, formatProjStatus(r, w))
}
if strings.Contains(got[0], "init") {
t.Errorf("a published project was hinted: %q", got[0])
}
for _, i := range []int{1, 2} {
if !strings.Contains(got[i], "init") {
t.Errorf("row %d missing the init hint: %q", i, got[i])
}
}
// a directory that is not a repository has no sync state to report
if strings.ContainsAny(got[2], "✓–↑↓") {
t.Errorf("non-repository row claims a git state: %q", got[2])
}
// with nothing to hint the column disappears entirely
if measureOverview(rows[:1]).hint {
t.Error("hint column reserved although no row needs it")
}
}
func TestShortAge(t *testing.T) {
cases := []struct {
d time.Duration
want string
}{
{30 * time.Second, "now"},
{90 * time.Second, "1m"},
{2 * time.Hour, "2h"},
{36 * time.Hour, "1d"},
{20 * 24 * time.Hour, "20d"},
}
for _, c := range cases {
if got := shortAge(c.d); got != c.want {
t.Errorf("shortAge(%v) = %q, want %q", c.d, got, c.want)
}
}
}
// captureStdout collects everything a function prints.
func captureStdout(t *testing.T, fn func()) string {
t.Helper()
old := os.Stdout
r, w, err := os.Pipe()
if err != nil {
t.Fatal(err)
}
os.Stdout = w
done := make(chan string)
go func() {
var b strings.Builder
io.Copy(&b, r)
done <- b.String()
}()
fn()
w.Close()
os.Stdout = old
return <-done
}
+11 -16
View File
@@ -4,23 +4,18 @@ package main
// git hosting server (Gitea, GitHub or GitLab), creating the repository via the // git hosting server (Gitea, GitHub or GitLab), creating the repository via the
// server's REST API when it does not exist yet. // server's REST API when it does not exist yet.
// //
// Configuration (in ~/.mgshrc, a project .mgshrc, or MGSH_* env) — either a // Configuration (in ~/.mgshrc, a project .mgshrc, or MGSH_* env): one
// single flat target: // remote.<name>.<field> block per server, which `pushremote` mirrors to in turn.
// //
// remoteurl = https://git.example.com base URL of the server // remote.gitlab.url = https://gitlab.example.com
// remotekey = <api-token> personal access token // remote.gitlab.key = <api-token>
// remotetype = gitea|github|gitlab optional; auto-detected from the URL // remote.gitlab.type = gitlab optional; detected from the url
// remote.gitlab.visibility = public or private (the default)
// remotes = gitlab optional: restrict/order the set
// //
// or any number of named ones, which `pushremote` mirrors to in turn: // Each target owns a git remote of its own name in the repository. There is no
// // second spelling: the pre-4.1 flat remoteurl/remotekey pair is migrated to
// remote.gitea.url = https://git.example.com // remote.public.* on load.
// remote.gitea.key = <api-token>
// remote.hub.url = https://github.com
// remote.hub.key = <api-token>
// remote.hub.visibility = public
// remotes = gitea, hub optional: restrict/order the set
//
// Each target owns a git remote of the same name in the repository.
// //
// The token is used for the API calls and, via an HTTP Basic auth header, for // The token is used for the API calls and, via an HTTP Basic auth header, for
// the git push. It is never written into the repository's git config, and it is // the git push. It is never written into the repository's git config, and it is
@@ -287,7 +282,7 @@ func handlePushRemote(args string) {
targets = pickRemotes(targets, names) targets = pickRemotes(targets, names)
if len(targets) == 0 { if len(targets) == 0 {
if len(names) == 0 { // an unknown @name already reported itself if len(names) == 0 { // an unknown @name already reported itself
errorln("pushremote needs 'remoteurl'/'remotekey' or a 'remote.<name>.*' block in " + configFile()) errorln("pushremote needs a 'remote.<name>.url' and 'remote.<name>.key' in " + configFile())
} }
return return
} }
+62
View File
@@ -329,3 +329,65 @@ func mustGit(t *testing.T, dir string, args ...string) {
t.Fatalf("git %v: %v\n%s", args, err, out) t.Fatalf("git %v: %v\n%s", args, err, out)
} }
} }
// TestListSurvivesFailingDu: `list` chains the listing and `du` into one remote
// command, and the exit status is the *last* command's. A server whose du fails
// — a shell that mis-parses the arguments, a du that is not there, a permission
// problem — must still get its repositories listed.
func TestListSurvivesFailingDu(t *testing.T) {
useProject(t, "x")
fakeServer(t, func(cmd string) (string, error) {
return "total 4\n" +
"drwxr-xr-x 7 git git 4096 Jan 3 14:32 notes.git\n" +
"drwxr-xr-x 7 git git 4096 Sep 28 2016 website.git\n" +
listMarker + "\n",
errors.New("exit status 1") // du blew up, ls did not
})
out := captureStdout(t, func() { runCommand("list") })
if strings.Contains(out, "could not list") {
t.Errorf("a failing du discarded a good listing:\n%s", out)
}
for _, want := range []string{"notes", "website", "2 repositories"} {
if !strings.Contains(out, want) {
t.Errorf("listing missing %q:\n%s", want, out)
}
}
// without sizes there must be no size column, not a column of zeroes
if strings.Contains(out, "0B") {
t.Errorf("zero sizes shown when du produced none:\n%s", out)
}
}
// TestListReportsATrulyFailedListing: when nothing usable came back, the error
// still has to surface.
func TestListReportsATrulyFailedListing(t *testing.T) {
useProject(t, "x")
fakeServer(t, func(cmd string) (string, error) {
return "", errors.New("ssh: connect failed")
})
out := captureStdout(t, func() { runCommand("list") })
if !strings.Contains(out, "could not list") {
t.Errorf("a failed listing was not reported:\n%s", out)
}
}
// TestListSendsNoShellSpecificSyntax guards the bug this replaced: the remote
// command is run by the git user's login shell, which may be csh, where
// "2>/dev/null" is an argument followed by a redirection rather than a
// redirection of stderr.
func TestListSendsNoShellSpecificSyntax(t *testing.T) {
useProject(t, "x")
sent := fakeServer(t, func(cmd string) (string, error) { return "", nil })
captureStdout(t, func() { runCommand("list") })
if len(*sent) == 0 {
t.Fatal("list sent nothing")
}
for _, c := range *sent {
if strings.Contains(c, "2>") || strings.Contains(c, "&>") {
t.Errorf("remote command uses sh-only redirection: %q", c)
}
}
}
+206
View File
@@ -0,0 +1,206 @@
package main
// secrets.go — a last look at what `push` is about to commit.
//
// `push` runs `git add --all .`, so anything lying in the project — an .env, a
// stray key file, a token pasted into a config — is committed and pushed, and
// with `mirror = true` it reaches a *public* server in the same breath. That is
// the one action in mgsh that cannot be undone: a deleted server repository can
// come back from an archive, a published credential is burnt.
//
// So the staged diff is scanned for a small set of high-signal patterns before
// the commit is made. This is not a complete secret scanner and does not try to
// be one; it aims for a high hit rate on the things that actually leak, with
// few enough false alarms that the prompt still means something. Turn it off
// with `secretscan = off`.
import (
"fmt"
"regexp"
"strconv"
"strings"
)
// secretHit is one suspicious added line.
type secretHit struct {
file string
lineNo int
kind string
text string
}
// secretPattern matches one kind of credential. `certain` patterns are
// unmistakable and are reported as they are; the others match a shape that
// merely looks like a secret and are filtered through looksLikePlaceholder.
type secretPattern struct {
kind string
re *regexp.Regexp
certain bool
}
var secretPatterns = []secretPattern{
{"private key", regexp.MustCompile(`-----BEGIN (?:[A-Z]+ )?PRIVATE KEY-----`), true},
{"GitHub token", regexp.MustCompile(`\bgh[pousr]_[A-Za-z0-9]{20,}`), true},
{"GitLab token", regexp.MustCompile(`\bglpat-[A-Za-z0-9_-]{16,}`), true},
{"AWS access key", regexp.MustCompile(`\b(?:AKIA|ASIA)[0-9A-Z]{16}\b`), true},
{"Slack token", regexp.MustCompile(`\bxox[baprs]-[A-Za-z0-9-]{10,}`), true},
{"PyPI token", regexp.MustCompile(`\bpypi-AgEIcHlwaS5vcmc[A-Za-z0-9_-]{10,}`), true},
{"credential assignment", regexp.MustCompile(
`(?i)\b(?:password|passwd|secret|api[_-]?key|apikey|access[_-]?key|auth[_-]?token|token)\b` +
`\s*[:=]\s*(?:"([^"\s]{12,})"|'([^'\s]{12,})'|([^\s"';,]{20,}))\s*;?\s*$`), false},
}
var (
diffFileRe = regexp.MustCompile(`^\+\+\+ b/(.*)$`)
diffHunkRe = regexp.MustCompile(`^@@ -\d+(?:,\d+)? \+(\d+)`)
// a value that is plainly a reference or a stand-in, not a credential
constRefRe = regexp.MustCompile(`^[A-Z][A-Z0-9_]*$`)
dottedRefRe = regexp.MustCompile(`^[\w-]+(?:\.[\w-]+)+$`)
maskedRe = regexp.MustCompile(`^[*x•.]+$`)
)
// placeholderWords are the values people write when they mean "fill this in".
var placeholderWords = map[string]bool{
"changeme": true, "change_me": true, "password": true, "secret": true,
"token": true, "your_token": true, "your-token": true, "yourtoken": true,
"todo": true, "none": true, "null": true, "example": true, "redacted": true,
}
// looksLikePlaceholder reports whether a matched value is obviously not a real
// credential: a template slot, an environment reference, a constant name, or a
// masked stand-in. Documentation and example files are full of these, and every
// one of them that reaches the prompt makes the prompt worth less.
func looksLikePlaceholder(v string) bool {
v = strings.Trim(v, `"'`)
if v == "" {
return true
}
if strings.ContainsAny(v, "<>${}()") { // <token>, ${VAR}, $(cmd), {{ tpl }}
return true
}
if maskedRe.MatchString(v) || constRefRe.MatchString(v) || dottedRefRe.MatchString(v) {
return true
}
if placeholderWords[strings.ToLower(v)] {
return true
}
// a value made of one repeated character carries no information
if strings.Count(v, string(v[0])) == len(v) {
return true
}
return false
}
// scanDiff finds suspicious added lines in a unified diff. Only added lines are
// examined: removing a secret is what we want people to do.
func scanDiff(diff string) []secretHit {
var hits []secretHit
file := ""
lineNo := 0
for _, ln := range strings.Split(diff, "\n") {
switch {
case strings.HasPrefix(ln, "+++ "):
file = ""
if m := diffFileRe.FindStringSubmatch(ln); m != nil {
file = m[1]
}
continue
case strings.HasPrefix(ln, "@@"):
if m := diffHunkRe.FindStringSubmatch(ln); m != nil {
lineNo, _ = strconv.Atoi(m[1])
}
continue
case strings.HasPrefix(ln, "---") || strings.HasPrefix(ln, "diff ") ||
strings.HasPrefix(ln, "index ") || strings.HasPrefix(ln, "new file") ||
strings.HasPrefix(ln, "deleted file") || strings.HasPrefix(ln, "similarity "):
continue
case strings.HasPrefix(ln, "-"):
continue // removed line: not our problem
case !strings.HasPrefix(ln, "+"):
lineNo++ // context line
continue
}
text := ln[1:]
if kind := matchSecret(text); kind != "" {
hits = append(hits, secretHit{file: file, lineNo: lineNo, kind: kind, text: text})
}
lineNo++
}
return hits
}
// allowMarker suppresses the check for one line. Any scanner needs a per-line
// escape: a project will eventually hold something credential-shaped on
// purpose, and switching the whole check off for that is far too blunt.
const allowMarker = "mgsh:allow"
// matchSecret returns the kind of credential a line appears to contain, or "".
func matchSecret(text string) string {
if strings.Contains(text, allowMarker) {
return ""
}
for _, p := range secretPatterns {
m := p.re.FindStringSubmatch(text)
if m == nil {
continue
}
if p.certain {
return p.kind
}
// the first non-empty capture group is the value that was assigned
value := ""
for _, g := range m[1:] {
if g != "" {
value = g
break
}
}
if !looksLikePlaceholder(value) {
return p.kind
}
}
return ""
}
// secretScanEnabled reports whether the scan runs. It is on unless explicitly
// switched off, so a typo in the setting leaves the safety net in place.
func secretScanEnabled() bool { return !falsy(cfg.SecretScan) }
// secretsApproved scans what `push` has staged. With nothing suspicious found
// it returns true silently; otherwise it shows the findings and asks. Returns
// false when the push should stop.
func secretsApproved(dir string) bool {
if !secretScanEnabled() {
return true
}
diff, err := gitCapture(dir, "diff", "--cached", "-U0", "--no-color")
if err != nil {
return true // nothing staged, or no HEAD yet: not our call to block
}
hits := scanDiff(diff)
if len(hits) == 0 {
return true
}
fmt.Println(col(cRed, fmt.Sprintf("%d possible credential(s) in what is about to be committed:", len(hits))))
for _, h := range hits {
where := h.file
if h.lineNo > 0 {
where += ":" + strconv.Itoa(h.lineNo)
}
fmt.Printf(" %s %s\n %s\n",
col(cYellow, where), col(cGray, h.kind), col(cRed, ellipsis(strings.TrimSpace(h.text), 100)))
}
fmt.Println(col(cGray, " (set 'secretscan = off' to skip this check)"))
return yesno("push anyway?", false)
}
// ellipsis shortens s to at most n characters.
func ellipsis(s string, n int) string {
if len(s) <= n {
return s
}
return s[:n-1] + "…"
}
+212
View File
@@ -0,0 +1,212 @@
package main
import (
"os"
"path/filepath"
"strings"
"testing"
)
// TestMatchSecretCatchesRealCredentials: the shapes that actually leak.
func TestMatchSecretCatchesRealCredentials(t *testing.T) {
lines := []string{
"-----BEGIN OPENSSH PRIVATE KEY-----",
"-----BEGIN RSA PRIVATE KEY-----",
" ghp_aB3dEfGh1jKlMn0pQrStUvWxYz012345678",
"GITLAB=glpat-aB3dEfGh1jKlMn0pQrSt",
`aws_access_key_id = AKIAIOSFODNN7EXAMPLE`,
"slack: xoxb-1234567890-abcdefghij",
`API_KEY="s3cr3tV4lu3W1thStuff"`,
"password = hunter2hunter2hunter2",
"token: 'aB3dEfGh1jKlMn0pQrSt'",
"auth-token=9f8e7d6c5b4a39281706abcdef123456",
}
for _, ln := range lines {
if matchSecret(ln) == "" {
t.Errorf("missed a credential in %q", ln)
}
}
}
// TestMatchSecretIgnoresNoise: everyday code and documentation must not trip
// the prompt, or people learn to answer "yes" without reading it.
func TestMatchSecretIgnoresNoise(t *testing.T) {
lines := []string{
"remotekey = <personal-access-token>", // our own README
"# remotekey = <personal-access-token>", // and mgshrc.example
"token = process.env.GITHUB_TOKEN", // reference, not a value
"const token = getToken()", // call
"password = ${DB_PASSWORD}", // template
`api_key = "changeme"`, // placeholder
"secret: TODO", //
"key gh***************xk", // masked, from `config`
"password = xxxxxxxxxxxxxxxxxxxxxxx", // masked
"// the token is never persisted in the repo", // prose
"apiKey := os.Getenv(\"MGSH_REMOTEKEY\")", // lookup
"token = SOME_CONSTANT_NAME", // constant
"secret = my.config.value", // dotted reference
"+++ b/token.go", // diff furniture
"password = short", // too short to be one
"Authorization: Basic <base64(owner:token)>", // documentation
"remote.hub.key = <personal-access-token>",
}
for _, ln := range lines {
if kind := matchSecret(ln); kind != "" {
t.Errorf("false positive (%s) on %q", kind, ln)
}
}
}
// TestScanDiffReportsFileAndLine: the report has to point at the right place,
// and must ignore removed lines — deleting a secret is the desired action.
func TestScanDiffReportsFileAndLine(t *testing.T) {
diff := `diff --git a/.env b/.env
new file mode 100644
--- /dev/null
+++ b/.env
@@ -0,0 +1,3 @@
+HOME=/tmp
+API_KEY="s3cr3tV4lu3W1thStuff"
+DEBUG=1
diff --git a/old.txt b/old.txt
--- a/old.txt
+++ b/old.txt
@@ -7,1 +7,0 @@
-password = hunter2hunter2hunter2
`
hits := scanDiff(diff)
if len(hits) != 1 {
t.Fatalf("expected exactly one hit, got %d: %+v", len(hits), hits)
}
h := hits[0]
if h.file != ".env" {
t.Errorf("file = %q, want .env", h.file)
}
if h.lineNo != 2 {
t.Errorf("lineNo = %d, want 2", h.lineNo)
}
if !strings.Contains(h.text, "API_KEY") {
t.Errorf("text = %q", h.text)
}
}
// TestScanDiffCountsLinesAcrossHunks keeps the line numbers honest when a file
// is edited in several places.
func TestScanDiffCountsLinesAcrossHunks(t *testing.T) {
diff := `+++ b/config.yml
@@ -1,0 +1,1 @@
+harmless: yes
@@ -40,0 +41,2 @@
+also fine
+aws_key = AKIAIOSFODNN7EXAMPLE
`
hits := scanDiff(diff)
if len(hits) != 1 || hits[0].lineNo != 42 {
t.Fatalf("hits = %+v, want one at line 42", hits)
}
}
// TestSecretsApprovedBlocksThePush drives the real thing: a staged .env, the
// scan, and the answer deciding whether push continues.
func TestSecretsApprovedBlocksThePush(t *testing.T) {
dir := t.TempDir()
mustGit(t, dir, "init", "-q")
mustGit(t, dir, "config", "user.name", "t")
mustGit(t, dir, "config", "user.email", "t@e")
mustGit(t, dir, "commit", "-q", "--allow-empty", "-m", "base")
old := cfg
defer func() { cfg = old }()
cfg = Config{}
// clean tree: no prompt, no interference
asked := fakeAnswers(t, false)
if !secretsApproved(dir) {
t.Fatal("a clean tree must not block the push")
}
if len(*asked) != 0 {
t.Fatalf("asked about a clean tree: %v", *asked)
}
if err := os.WriteFile(filepath.Join(dir, ".env"),
[]byte("API_KEY=\"s3cr3tV4lu3W1thStuff\"\n"), 0600); err != nil {
t.Fatal(err)
}
mustGit(t, dir, "add", "--all", ".")
declined := fakeAnswers(t, false)
if secretsApproved(dir) {
t.Error("a staged credential must stop the push when declined")
}
if len(*declined) == 0 {
t.Error("the user was never asked")
}
accepted := fakeAnswers(t, true)
if !secretsApproved(dir) {
t.Error("an explicit yes must let the push through")
}
if len(*accepted) == 0 {
t.Error("the user was never asked")
}
// and the escape hatch really switches it off
cfg.SecretScan = "off"
never := fakeAnswers(t, false)
if !secretsApproved(dir) {
t.Error("secretscan = off must not block")
}
if len(*never) != 0 {
t.Errorf("secretscan = off still asked: %v", *never)
}
}
// TestSecretScanDefaultsToOn: only a deliberate "off" disables it, so a typo
// leaves the safety net in place.
func TestSecretScanDefaultsToOn(t *testing.T) {
old := cfg
defer func() { cfg = old }()
for _, v := range []string{"", "true", "on", "yes", "wharrgarbl", "1"} {
cfg = Config{SecretScan: v}
if !secretScanEnabled() {
t.Errorf("secretscan = %q disabled the scan", v)
}
}
for _, v := range []string{"off", "0", "false", "no", " OFF "} {
cfg = Config{SecretScan: v}
if secretScanEnabled() {
t.Errorf("secretscan = %q did not disable the scan", v)
}
}
}
// TestOwnDocsDoNotTripTheScanner: mgsh's own README and example config are full
// of credential-shaped text; committing mgsh itself must stay quiet.
func TestOwnDocsDoNotTripTheScanner(t *testing.T) {
for _, f := range []string{"README.md", "mgshrc.example"} {
data, err := os.ReadFile(f)
if err != nil {
t.Fatal(err)
}
for i, ln := range strings.Split(string(data), "\n") {
if kind := matchSecret(ln); kind != "" {
t.Errorf("%s:%d would trip the scanner (%s): %q", f, i+1, kind, ln)
}
}
}
}
// TestAllowMarkerSuppressesOneLine: the per-line escape for something that only
// looks like a credential and is meant to stay.
func TestAllowMarkerSuppressesOneLine(t *testing.T) {
line := `API_KEY="s3cr3tV4lu3W1thStuff"`
if matchSecret(line) == "" {
t.Fatal("test line is not detected at all")
}
if kind := matchSecret(line + " # mgsh:allow — sample value"); kind != "" {
t.Errorf("mgsh:allow did not suppress the hit (%s)", kind)
}
if kind := matchSecret("-----BEGIN OPENSSH PRIVATE KEY----- mgsh:allow"); kind != "" {
t.Errorf("mgsh:allow did not suppress a certain pattern (%s)", kind)
}
}
+181
View File
@@ -0,0 +1,181 @@
package main
// shellcomplete.go — Tab completion for the '!' shell escape.
//
// `!vi <Tab>` should behave like it does in a shell: the first word completes
// against the executables on PATH, everything after it against the filesystem.
// Paths resolve relative to the active project directory, because that is where
// forwardShell runs the command.
//
// Word splitting here is whitespace only. Quoting and backslash escapes are the
// shell's business at execution time; getting them right for completion too
// would buy little for a one-off escape hatch.
import (
"os"
"path/filepath"
"sort"
"strings"
"sync"
)
// shellCommandList supplies the executable names for the command position. A
// variable so tests can hand over a fixed set instead of whatever happens to be
// installed on the machine running them.
var shellCommandList = pathExecutables
// completeShellLine returns the candidates and the prefix they replace for a
// line that is headed for a shell. ok is false for any other line, which is
// then left to the builtin command tree.
func completeShellLine(typed string) (cands []string, prefix string, ok bool) {
body, allowCommand, ok := shellLine(typed)
if !ok {
return nil, "", false
}
cands, prefix = shellCandidates(body, allowCommand)
return cands, prefix, true
}
// shellLine works out which part of a typed line will reach a shell, and
// whether its command word is still open for completion. Two things get there:
// a '!' escape, and an alias that expands to one — `alias ll '!ls -la'` makes
// everything after `ll` a shell argument just as surely.
//
// Only the alias itself is inspected, not what its expansion might expand to
// again: an alias chain can rewrite its arguments, and guessing at that would
// offer candidates for a command line that is not the one being built.
func shellLine(typed string) (body string, allowCommand, ok bool) {
trimmed := strings.TrimLeft(typed, " \t")
if rest, found := strings.CutPrefix(trimmed, "!"); found {
return rest, true, true
}
// the alias name has to be complete — while it is still being typed there
// is no way to know what it will turn out to be
sep := strings.IndexAny(trimmed, " \t")
if sep < 0 {
return "", false, false
}
name := trimmed[:sep]
if isBuiltin(name) { // a builtin can never be shadowed by an alias
return "", false, false
}
expansion, defined := aliases[name]
if !defined || !strings.HasPrefix(strings.TrimSpace(expansion), "!") {
return "", false, false
}
// the command comes from the alias body, so only arguments are left to complete
return trimmed[sep:], false, true
}
// shellCandidates completes the last word of a shell command line. allowCommand
// says whether its first word may still be completed against PATH.
func shellCandidates(body string, allowCommand bool) (cands []string, prefix string) {
word := body[strings.LastIndexAny(body, " \t")+1:]
inCommand := allowCommand && strings.TrimLeft(body[:len(body)-len(word)], " \t") == ""
// a command word without a separator names something on PATH; with one it
// is a path like ./script, exactly as a shell reads it
if inCommand && !strings.ContainsRune(word, '/') {
if word == "" {
return nil, "" // every executable on the machine helps nobody
}
return matchPrefix(shellCommandList(), word), word
}
dir, base := splitPathToken(word)
return matchPrefix(pathEntries(dir), base), base
}
// splitPathToken splits a path token into the directory part, kept exactly as
// typed, and the basename being completed. Completing only the basename is what
// keeps the candidate list readable: "src/ma<Tab>" offers "main.go", not the
// whole path again.
func splitPathToken(word string) (dir, base string) {
if i := strings.LastIndexByte(word, '/'); i >= 0 {
return word[:i+1], word[i+1:]
}
return "", word
}
// pathEntries lists what a directory token points at. Directories come back
// with a trailing slash, so completing one leads straight into it.
func pathEntries(dir string) []string {
root := DIR
switch {
case strings.HasPrefix(dir, "~/"):
home, err := os.UserHomeDir()
if err != nil {
return nil
}
root, dir = home, dir[2:]
case strings.HasPrefix(dir, "/"):
root = ""
}
entries, err := os.ReadDir(filepath.Join(root, dir))
if err != nil {
return nil
}
out := make([]string, 0, len(entries))
for _, e := range entries {
name := e.Name()
if e.IsDir() {
name += "/"
}
out = append(out, name)
}
return out
}
// matchPrefix keeps the candidates starting with prefix, sorted and without
// duplicates. A hidden entry only shows up once the prefix asks for it, as in a
// shell.
func matchPrefix(cands []string, prefix string) []string {
wantHidden := strings.HasPrefix(prefix, ".")
seen := map[string]bool{}
var out []string
for _, c := range cands {
if !strings.HasPrefix(c, prefix) || seen[c] {
continue
}
if !wantHidden && strings.HasPrefix(c, ".") {
continue
}
seen[c] = true
out = append(out, c)
}
sort.Strings(out)
return out
}
// pathExecutables lists the executable names on PATH. The scan happens once per
// session: PATH cannot change from inside mgsh, and a few thousand directory
// entries are not worth walking on every Tab.
var pathExecutables = sync.OnceValue(func() []string {
var out []string
seen := map[string]bool{}
for _, dir := range filepath.SplitList(os.Getenv("PATH")) {
if dir == "" {
dir = "."
}
entries, err := os.ReadDir(dir)
if err != nil {
continue
}
for _, e := range entries {
name := e.Name()
if seen[name] {
continue // the first one on PATH is the one that would run
}
fi, err := e.Info()
if err != nil || fi.IsDir() || fi.Mode().Perm()&0o111 == 0 {
continue
}
seen[name] = true
out = append(out, name)
}
}
sort.Strings(out)
return out
})
+260
View File
@@ -0,0 +1,260 @@
package main
import (
"os"
"path/filepath"
"strings"
"testing"
)
// complete runs the real dispatch a Tab press goes through.
func complete(typed string) ([]string, string) {
cands, prefix, _ := completeShellLine(typed)
return cands, prefix
}
// fakeCommands installs a fixed set of PATH executables for the test.
func fakeCommands(t *testing.T, names ...string) {
t.Helper()
old := shellCommandList
shellCommandList = func() []string { return names }
t.Cleanup(func() { shellCommandList = old })
}
// shellTree lays out a directory to complete against and points DIR at it.
func shellTree(t *testing.T) string {
t.Helper()
dir := t.TempDir()
for _, d := range []string{"src", "src/deep", ".hidden"} {
if err := os.MkdirAll(filepath.Join(dir, d), 0755); err != nil {
t.Fatal(err)
}
}
for _, f := range []string{"main.go", "main_test.go", "Makefile", ".env", "src/util.go"} {
if err := os.WriteFile(filepath.Join(dir, f), []byte("x"), 0644); err != nil {
t.Fatal(err)
}
}
old := DIR
DIR = dir
t.Cleanup(func() { DIR = old })
return dir
}
func TestShellCandidatesCommandWord(t *testing.T) {
fakeCommands(t, "vi", "vim", "view", "grep", "git")
shellTree(t)
cands, prefix := complete("!vi")
if prefix != "vi" {
t.Errorf("prefix = %q, want vi", prefix)
}
if strings.Join(cands, ",") != "vi,view,vim" {
t.Errorf("candidates = %v, want vi,view,vim sorted", cands)
}
// a bare '!' must not dump every executable on the machine
if cands, _ := complete("!"); len(cands) != 0 {
t.Errorf("bare '!' offered %d candidates", len(cands))
}
// leading blanks are allowed, as runCommand allows them
if cands, _ := complete(" !gi"); strings.Join(cands, ",") != "git" {
t.Errorf("indented escape = %v, want git", cands)
}
// a command word with a separator is a path, not a PATH lookup
if cands, prefix := complete("!./ma"); prefix != "ma" ||
strings.Join(cands, ",") != "main.go,main_test.go" {
t.Errorf("./ma = %v (prefix %q), want the local files", cands, prefix)
}
}
func TestShellCandidatesArguments(t *testing.T) {
fakeCommands(t, "vi")
shellTree(t)
// paths resolve against the project directory, where `!` commands run
cands, prefix := complete("!vi ma")
if prefix != "ma" || strings.Join(cands, ",") != "main.go,main_test.go" {
t.Errorf("candidates = %v (prefix %q)", cands, prefix)
}
// an empty argument lists the directory — dot entries stay out of the way
cands, prefix = complete("!vi ")
if prefix != "" {
t.Errorf("prefix = %q, want empty", prefix)
}
if strings.Join(cands, ",") != "Makefile,main.go,main_test.go,src/" {
t.Errorf("directory listing = %v", cands)
}
// ... until the prefix asks for them
if cands, _ := complete("!vi ."); strings.Join(cands, ",") != ".env,.hidden/" {
t.Errorf("dot prefix = %v, want the hidden entries", cands)
}
// a directory completes with its slash, so the next Tab walks into it
cands, prefix = complete("!vi sr")
if prefix != "sr" || strings.Join(cands, ",") != "src/" {
t.Errorf("directory candidate = %v (prefix %q)", cands, prefix)
}
// inside a directory only the basename is completed, which is what keeps
// the candidate list readable
cands, prefix = complete("!vi src/ut")
if prefix != "ut" || strings.Join(cands, ",") != "util.go" {
t.Errorf("nested candidate = %v (prefix %q), want util.go / ut", cands, prefix)
}
// later arguments complete the same way as the first
if cands, _ := complete("!diff main.go ma"); strings.Join(cands, ",") != "main.go,main_test.go" {
t.Errorf("second argument = %v", cands)
}
}
func TestShellCandidatesAbsoluteAndHome(t *testing.T) {
home := t.TempDir()
t.Setenv("HOME", home)
if err := os.WriteFile(filepath.Join(home, "notes.txt"), []byte("x"), 0644); err != nil {
t.Fatal(err)
}
shellTree(t)
fakeCommands(t, "vi")
if cands, prefix := complete("!vi ~/no"); prefix != "no" ||
strings.Join(cands, ",") != "notes.txt" {
t.Errorf("~/ completion = %v (prefix %q)", cands, prefix)
}
abs := filepath.Join(home, "no")
if cands, prefix := complete("!vi " + abs); prefix != "no" ||
strings.Join(cands, ",") != "notes.txt" {
t.Errorf("absolute completion = %v (prefix %q)", cands, prefix)
}
}
// TestShellCompleterDoContract is the part that would corrupt the line if it
// were wrong: readline replaces the last `length` runes with a candidate, so
// the candidates must be suffixes and the length must count runes.
func TestShellCompleterDoContract(t *testing.T) {
fakeCommands(t, "vim", "view")
shellTree(t)
c := completer()
line := []rune("!vi")
got, length := c.Do(line, len(line))
if length != 2 { // "vi" — the '!' is not part of the word
t.Fatalf("length = %d, want 2", length)
}
// rebuilding the line from prefix + candidate must give the full word
for i, g := range got {
full := string(line[:len(line)-length]) + string(line[len(line)-length:]) + string(g)
if full != "!vim" && full != "!view" {
t.Errorf("candidate %d rebuilds to %q", i, full)
}
}
// a non-'!' line still goes to the builtin command tree (which appends its
// own trailing space on a unique match)
line = []rune("stat")
got, length = c.Do(line, len(line))
if length != 4 || len(got) == 0 || !strings.HasPrefix(string(got[0]), "us") {
t.Errorf("builtin completion = %q, %d; want a candidate starting \"us\" at 4", got, length)
}
// a multi-byte prefix must be measured in runes, not bytes
if _, n := runeSuffixes([]string{"übermorgen"}, "üb"); n != 2 {
t.Errorf("runeSuffixes length = %d, want 2 runes", n)
}
}
// TestPathExecutablesFindsRealBinaries checks the PATH scan against a directory
// it controls: only files with an execute bit, no directories.
func TestPathExecutablesFindsRealBinaries(t *testing.T) {
dir := t.TempDir()
if err := os.WriteFile(filepath.Join(dir, "runnable"), []byte("#!/bin/sh\n"), 0755); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(filepath.Join(dir, "plainfile"), []byte("x"), 0644); err != nil {
t.Fatal(err)
}
if err := os.MkdirAll(filepath.Join(dir, "subdir"), 0755); err != nil {
t.Fatal(err)
}
t.Setenv("PATH", dir)
// pathExecutables caches for the session, so exercise the scan directly
var names []string
entries, err := os.ReadDir(dir)
if err != nil {
t.Fatal(err)
}
for _, e := range entries {
fi, err := e.Info()
if err != nil || fi.IsDir() || fi.Mode().Perm()&0o111 == 0 {
continue
}
names = append(names, e.Name())
}
if strings.Join(names, ",") != "runnable" {
t.Errorf("executable scan = %v, want only runnable", names)
}
}
// withAliases installs a fixed alias set for the test.
func withAliases(t *testing.T, m map[string]string) {
t.Helper()
old := aliases
aliases = m
t.Cleanup(func() { aliases = old })
}
// TestShellCandidatesThroughAlias: an alias that expands to a '!' escape turns
// everything after its name into shell arguments, so it completes as such.
func TestShellCandidatesThroughAlias(t *testing.T) {
shellTree(t)
fakeCommands(t, "vi", "ls")
withAliases(t, map[string]string{
"ll": "!ls -la",
"e": "!vi $1",
"co": "checkout $1", // expands to a builtin, not a shell command
"status": "!git status", // shadows a builtin: must not count
})
// arguments of a shell alias complete against the filesystem
if cands, prefix := complete("ll ma"); prefix != "ma" ||
strings.Join(cands, ",") != "main.go,main_test.go" {
t.Errorf("alias argument = %v (prefix %q)", cands, prefix)
}
// including the empty one, which lists the directory
if cands, _ := complete("e "); strings.Join(cands, ",") != "Makefile,main.go,main_test.go,src/" {
t.Errorf("empty alias argument = %v", cands)
}
// and paths inside it
if cands, prefix := complete("ll src/ut"); prefix != "ut" ||
strings.Join(cands, ",") != "util.go" {
t.Errorf("nested alias argument = %v (prefix %q)", cands, prefix)
}
// the command word of an alias is fixed by its body, so PATH is never
// offered — `ll vi` means the file "vi", not the editor
if cands, _ := complete("ll vi"); len(cands) != 0 {
t.Errorf("alias argument matched PATH: %v", cands)
}
// an alias to a builtin is not a shell line at all
if _, _, ok := shellLine("co ma"); ok {
t.Error("an alias expanding to a builtin was treated as a shell line")
}
// nor is a name that a builtin owns, since runCommand never expands those
if _, _, ok := shellLine("status ma"); ok {
t.Error("a builtin name was resolved through an alias")
}
// nor an undefined name
if _, _, ok := shellLine("nosuch ma"); ok {
t.Error("an undefined alias was treated as a shell line")
}
// while the alias name itself is still being typed there is nothing to know
if _, _, ok := shellLine("ll"); ok {
t.Error("an incomplete alias name was resolved")
}
}
+3 -4
View File
@@ -42,8 +42,8 @@ func showConfig() {
{"gitname", cfg.GitName}, {"gitname", cfg.GitName},
{"gitemail", cfg.GitEmail}, {"gitemail", cfg.GitEmail},
{"pushdefault", cfg.PushDefault}, {"pushdefault", cfg.PushDefault},
{"editor", cfg.Editor},
{"mirror", cfg.Mirror}, {"mirror", cfg.Mirror},
{"secretscan", cfg.SecretScan},
{"remotes", cfg.RemoteNames}, {"remotes", cfg.RemoteNames},
} }
@@ -145,9 +145,8 @@ func envName(key string) string { return "MGSH_" + strings.ToUpper(key) }
func configKeys() []string { func configKeys() []string {
keys := []string{ keys := []string{
"base", "githost", "gitport", "gituser", "gitpath", "gitkey", "base", "githost", "gitport", "gituser", "gitpath", "gitkey",
"gitname", "gitemail", "pushdefault", "editor", "gitname", "gitemail", "pushdefault",
"remoteurl", "remotekey", "remotetype", "remotevisibility", "remotes", "mirror", "secretscan",
"remotes", "mirror",
} }
sort.Strings(keys) sort.Strings(keys)
return keys return keys
+11
View File
@@ -39,6 +39,17 @@ func truthy(s string) bool {
return false return false
} }
// falsy reports whether a config string explicitly means "off". It is not the
// negation of truthy: for a setting that defaults to on, an unset value or a
// typo must leave it on, and only a deliberate "off" may switch it off.
func falsy(s string) bool {
switch strings.ToLower(strings.TrimSpace(s)) {
case "0", "false", "no", "off":
return true
}
return false
}
func fileExists(p string) bool { func fileExists(p string) bool {
fi, err := os.Stat(p) fi, err := os.Stat(p)
return err == nil && !fi.IsDir() return err == nil && !fi.IsDir()
+1 -1
View File
@@ -1 +1 @@
4.0.24 4.0.50