8 Commits
Author SHA1 Message Date
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
15 changed files with 1318 additions and 253 deletions
+107 -55
View File
@@ -53,8 +53,9 @@ 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`/`open`, 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).
@@ -70,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:
@@ -134,14 +163,20 @@ worked-on project sits closest to the prompt:
``` ```
< src > list < src > list
Betaflight3.0.0 Sep 28 2016 Betaflight3.0.0 Sep 28 2016 181M
website Mar 3 2024 website Mar 3 2024 2.1M
notes Jan 3 14:32 notes Jan 3 14:32 876K
3 repositories 3 repositories · 184M
``` ```
`list -a` lists the archives instead, with their sizes; a pattern filters by The size is the repository's real disk usage on the server, asked of `du` in
name (`list note`). 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
@@ -150,26 +185,45 @@ thing that sees the local base directory *and* the git server at once.
``` ```
< src > overview < src > overview
mgsh * ↑2 · desktop 3h · → hub,gitea notes * ↑2 laptop 3h
notes · laptop 2d website * ✓ desktop 2d → hub
website ✓ (no upstream) · laptop 20d Betaflight3.0.0 ✓ workstation 20d → gitea hub
3 projects · 1 dirty · 1 in sync experiments init
not on the git server: scratch, experiments (init) sandbox (wip) laptop 1h init
not cloned here: oldproject (clone) 4 projects · 2 dirty · 2 in sync · 2 to init
``` ```
Per project: dirty marker, commits ahead/behind the upstream, the branch when it Every field sits in its own column, so the eye can go down one instead of
is not `master`/`main`, and the mirror targets the repository has a remote for. 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.
The machine and age come from the commit itself — `push` writes `[user@host]` | column | meaning |
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 | name | the project, with its branch appended when it is not `master`/`main` |
usually the piece of information you actually wanted. | `*` | 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 |
The two lists at the end are the join no git command can do: local projects the Every directory under the base gets a row, including those that are not
server has never seen (`init` them) and server repositories missing on this repositories at all — `init` is exactly what turns one into a project, so it
machine (`clone` them). If the server cannot be reached, mgsh says so instead of belongs in the table rather than in a list underneath it. Such a row simply has
claiming everything is missing. 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 ### Credential check
@@ -234,38 +288,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
remoteurl = https://git.example.com # base URL of the server
remotekey = <personal-access-token> # API token
# remotetype = gitea # optional; auto-detected from remoteurl
# remotevisibility = private # visibility of created repos (default private)
# mirror = true # `push` also mirrors via pushremote
```
Several servers get one `remote.<name>.*` block each:
```ini ```ini
remote.gitea.url = https://git.example.com remote.gitea.url = https://git.example.com
remote.gitea.key = <personal-access-token> 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.hub.url = https://github.com remote.gitlab.url = https://gitlab.example.com
remote.hub.key = <personal-access-token> remote.gitlab.key = <personal-access-token>
remote.hub.visibility = public remote.gitlab.type = gitlab
remote.gitlab.visibility = public
# remotes = gitea, hub # optional: restrict and order the set # remotes = gitea, gitlab # optional: restrict and order the set
# mirror = true # `push` also mirrors via pushremote
``` ```
`<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
`remote.public.*` in place on the next start and says so, keeping the git remote
name those versions used.
| 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.
@@ -279,9 +332,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
@@ -365,8 +419,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 |
|---|---|---| |---|---|---|
@@ -380,14 +436,10 @@ project `.mgshrc` may override the setting.
| `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`) | | `editor` | project | opener used by `open`/`view` when the project has no Xcode workspace (default `coda`) |
| `remoteurl` | project | base URL of a single mirror server (target name `public`) | | `remote.<name>.url` | project | base URL of the mirror target `<name>` |
| `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) | | `secretscan` | project | `off` disables the credential check `push` runs before committing (on by default; only an explicit `off` disables it) |
+6 -3
View File
@@ -4,6 +4,7 @@ import (
"fmt" "fmt"
"strconv" "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
@@ -36,10 +37,12 @@ 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
} }
+72 -7
View File
@@ -24,6 +24,45 @@ var (
wsRe = regexp.MustCompile(`\s+`) wsRe = regexp.MustCompile(`\s+`)
) )
// listMarker separates the two sections of the combined listing command, so
// `list` gets both the long listing and the disk usage in one round trip.
const listMarker = "---mgsh---"
// 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. // lsEntry is one parsed entry of the server's listing.
type lsEntry struct { type lsEntry struct {
name string // with the ".git" / ".git.tar.gz" suffix removed name string // with the ".git" / ".git.tar.gz" suffix removed
@@ -203,22 +242,42 @@ func runCommandDepth(line string, depth int) bool {
one, many = "archive", "archives" one, many = "archive", "archives"
} }
pat := strings.ToLower(word(words, 1)) pat := strings.ToLower(word(words, 1))
lines, err := sshOut("/bin/ls -ltr " + shq(path)) remote := "/bin/ls -ltr " + shq(path)
if err != nil { if !opt["a"] {
errorln("could not list " + many + " on the git server") // archives are files and carry a real size; repositories are
break // 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 entries []lsEntry
for _, ln := range lines { var total int64
for _, ln := range lsLines {
e, ok := parseLsEntry(ln, suffix) e, ok := parseLsEntry(ln, suffix)
// the pattern filters the name, not the whole listing line — an // the pattern filters the name, not the whole listing line — an
// accidental match on the date or the owner helps nobody // accidental match on the date or the owner helps nobody
if !ok || (pat != "" && !strings.Contains(strings.ToLower(e.name), pat)) { if !ok || (pat != "" && !strings.Contains(strings.ToLower(e.name), pat)) {
continue continue
} }
if !opt["a"] {
e.size = sizes[e.name+suffix] // 0 when du said nothing
}
total += e.size
entries = append(entries, e) 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 len(entries) == 0 {
if err != nil {
errorln("could not list " + many + " on the git server")
break
}
what := "no " + many + " on the git server" what := "no " + many + " on the git server"
if pat != "" { if pat != "" {
what = "no " + many + " matching '" + word(words, 1) + "'" what = "no " + many + " matching '" + word(words, 1) + "'"
@@ -226,12 +285,18 @@ func runCommandDepth(line string, depth int) bool {
fmt.Println(col(cGray, what)) fmt.Println(col(cGray, what))
break break
} }
fmt.Print(formatRepoList(entries, opt["a"])) // no size column when the server gave no usable sizes, rather than a
// column of zeroes
fmt.Print(formatRepoList(entries, total > 0))
label := many label := many
if len(entries) == 1 { if len(entries) == 1 {
label = one label = one
} }
fmt.Println(col(cGray, fmt.Sprintf("%d %s", len(entries), label))) 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
+41 -4
View File
@@ -4,15 +4,52 @@ 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/open/view complete 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("open", readline.PcItemDynamic(dynLocalProjects)),
+140 -29
View File
@@ -6,6 +6,7 @@ import (
"os" "os"
"path/filepath" "path/filepath"
"regexp" "regexp"
"slices"
"sort" "sort"
"strings" "strings"
) )
@@ -33,10 +34,6 @@ type Config struct {
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) 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 SecretScan string // falsy -> `push` skips the credential scan
Remotes []RemoteTarget Remotes []RemoteTarget
@@ -54,22 +51,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.
@@ -147,11 +147,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
@@ -279,12 +322,14 @@ func writeConfigTemplate(path string) {
b.WriteString("# pushdefault = matching\n") b.WriteString("# pushdefault = matching\n")
b.WriteString("# editor = code\n\n") b.WriteString("# editor = code\n\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("# 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("# 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")
@@ -365,14 +410,36 @@ func applyConfig(c *Config, m map[string]string) {
set("gitemail", &c.GitEmail) set("gitemail", &c.GitEmail)
set("pushdefault", &c.PushDefault) set("pushdefault", &c.PushDefault)
set("editor", &c.Editor) 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)
set("secretscan", &c.SecretScan) set("secretscan", &c.SecretScan)
applyRemoteTargets(c, m) 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>.
@@ -439,11 +506,55 @@ func applyEnv(c *Config) {
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_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) 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
}
}
} }
+207 -3
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) {
@@ -467,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()
@@ -806,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
@@ -820,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)
@@ -837,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
@@ -943,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)
}
}
+14 -13
View File
@@ -23,23 +23,24 @@ gitpath = /home/git
# editor = code # 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) # `pushremote` pushes to every configured server in the order given,
# `pushremote @gitlab` to a single one.
# #
# Or any number of named servers. `pushremote` pushes to all of them in the
# order given, `pushremote @hub` to a single one. Each target gets a git remote
# of the same name in the repository.
# remote.gitea.url = https://git.example.com # remote.gitea.url = https://git.example.com
# remote.gitea.key = <personal-access-token> # remote.gitea.key = <personal-access-token>
# remote.hub.url = https://github.com # remote.gitea.type = gitea # optional; auto-detected from the url
# remote.hub.key = <personal-access-token> # remote.gitea.visibility = private # or public (default private)
# 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
# #
# 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 # mirror = true # `push` also mirrors via pushremote
# --- safety --- # --- safety ---
+155 -90
View File
@@ -5,9 +5,8 @@ package main
// mgsh is the only thing that sees all three places a project can live: the // 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 // local base directory, the internal ssh server, and the public mirrors. Joining
// those answers the questions plain git cannot — which projects were never // those answers the questions plain git cannot — which projects were never
// pushed to the server, which exist there but not on this machine, and which // pushed to the server, and which machine last touched each one (every `push`
// machine last touched each one (every `push` stamps "[user@host]" into the // stamps "[user@host]" into the commit message, so that comes for free).
// commit message, so that comes for free).
import ( import (
"fmt" "fmt"
@@ -18,15 +17,18 @@ import (
"strings" "strings"
"sync" "sync"
"time" "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]" lastHost string // machine that made the last commit, from "[user@host]"
lastWhen time.Time // when that was lastWhen time.Time // when that was
mirrors []string // configured mirror remotes present in this repo mirrors []string // configured mirror remotes present in this repo
@@ -60,33 +62,41 @@ func overviewAll() {
srvCh <- serverList{names, err} srvCh <- serverList{names, err}
}() }()
var local, repos []string // 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()) local = append(local, e.Name())
if isDir(BASE + "/" + e.Name() + "/.git") {
repos = append(repos, e.Name())
} }
} }
rows := scanProjects(repos) rows := scanProjects(local)
srv := <-srvCh srv := <-srvCh
markUnpublished(rows, srv.names, srv.err)
width := 0 // what needs doing first, alphabetical within each group
for _, n := range repos { sort.SliceStable(rows, func(i, j int) bool {
if len(n) > width { return attentionRank(rows[i]) < attentionRank(rows[j])
width = len(n) })
}
}
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
} }
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++
} }
@@ -94,12 +104,15 @@ func overviewAll() {
syncN++ syncN++
} }
} }
if len(rows) > 0 {
fmt.Println(col(cGray, fmt.Sprintf("%d projects · %d dirty · %d in sync",
len(rows), dirtyN, syncN)))
}
reportInventory(local, srv.names, srv.err) 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"))
}
} }
// scanProjects collects the state of every project concurrently. Each project // scanProjects collects the state of every project concurrently. Each project
@@ -121,50 +134,31 @@ func scanProjects(names []string) []projStatus {
return rows return rows
} }
// reportInventory names the projects that live on only one side: local ones the // markUnpublished flags the rows the git server has never seen — the ones
// server has never seen (candidates for `init`) and server repositories missing // `init` is for. A listing that failed leaves every row unmarked: not knowing
// here (candidates for `clone`). // is not the same as knowing they are missing, and marking all of them would
func reportInventory(local, server []string, err error) { // tell the user to re-init their whole base directory.
func markUnpublished(rows []projStatus, server []string, err error) {
if err != nil { if err != nil {
fmt.Println(col(cGray, " git server not reachable — local view only"))
return return
} }
have := map[string]bool{}
for _, n := range local {
have[n] = true
}
onServer := map[string]bool{} onServer := map[string]bool{}
for _, n := range server { for _, n := range server {
onServer[n] = true onServer[n] = true
} }
for i := range rows {
var missingRemote, missingLocal []string rows[i].notOnServer = !onServer[rows[i].name]
for _, n := range local {
if !onServer[n] {
missingRemote = append(missingRemote, n)
}
}
for _, n := range server {
if !have[n] {
missingLocal = append(missingLocal, n)
}
}
sort.Strings(missingRemote)
sort.Strings(missingLocal)
if len(missingRemote) > 0 {
fmt.Printf("%s %s\n", col(cYellow, " not on the git server:"),
strings.Join(missingRemote, ", ")+col(cGray, " (init)"))
}
if len(missingLocal) > 0 {
fmt.Printf("%s %s\n", col(cCyan, " not cloned here: "),
strings.Join(missingLocal, ", ")+col(cGray, " (clone)"))
} }
} }
// projectStatus gathers the git state of a single project directory. // 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 s.isRepo = isDir(dir + "/.git"); !s.isRepo {
return s
}
readStatus(&s, dir) readStatus(&s, dir)
readLastCommit(&s, dir) readLastCommit(&s, dir)
s.mirrors = configuredMirrors(dir) s.mirrors = configuredMirrors(dir)
@@ -244,50 +238,121 @@ func configuredMirrors(dir string) []string {
return found return found
} }
// formatProjStatus renders one aligned overview row. // overviewWidths are the column widths of the overview table, measured from the
func formatProjStatus(s projStatus, width int) string { // rows so every field starts at the same place. Ragged columns were what made
var marks []string // 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 { if s.dirty {
marks = append(marks, col(cYellow, "*")) dirty = "*"
}
if s.ahead > 0 {
marks = append(marks, col(cGreen, fmt.Sprintf("↑%d", s.ahead)))
}
if s.behind > 0 {
marks = append(marks, col(cRed, fmt.Sprintf("↓%d", s.behind)))
}
state := strings.Join(marks, " ")
if state == "" {
if s.hasUpstream {
state = col(cGreen, "✓")
} else {
state = col(cGray, "✓ (no upstream)")
}
} }
line := " " + col(cGreen, padRight(s.name, width+2)) + state var b strings.Builder
if s.branch != "master" && s.branch != "main" && s.branch != "-" { b.WriteString(" ")
line += col(cGray, " ("+s.branch+")") b.WriteString(col(cGreen, padRight(projLabel(s), w.label)))
} b.WriteString(" " + col(cYellow, dirty) + " ")
b.WriteString(col(syncColor(s), padRight(syncState(s), w.sync)))
var tail []string if w.host > 0 {
if s.lastHost != "" || !s.lastWhen.IsZero() { age := ""
t := s.lastHost
if !s.lastWhen.IsZero() { if !s.lastWhen.IsZero() {
if t != "" { age = shortAge(time.Since(s.lastWhen))
t += " "
} }
t += shortAge(time.Since(s.lastWhen)) b.WriteString(" " + col(cGray, padRight(s.lastHost, w.host)))
b.WriteString(" " + col(cGray, fmt.Sprintf("%4s", age)))
} }
tail = append(tail, t) if w.hint {
hint := ""
if s.notOnServer {
hint = "init"
}
b.WriteString(" " + col(cYellow, padRight(hint, hintWidth)))
} }
if len(s.mirrors) > 0 { if len(s.mirrors) > 0 {
tail = append(tail, "→ "+strings.Join(s.mirrors, ",")) b.WriteString(col(cGray, " → "+strings.Join(s.mirrors, " ")))
} }
if len(tail) > 0 { return strings.TrimRight(b.String(), " ")
line += col(cGray, " · "+strings.Join(tail, " · "))
}
return line
} }
// shortAge renders a duration compactly: 90s -> "1m", 36h -> "1d". // shortAge renders a duration compactly: 90s -> "1m", 36h -> "1d".
+51 -21
View File
@@ -82,33 +82,63 @@ func TestCommitHostRe(t *testing.T) {
} }
} }
// TestReportInventoryUnreachableServer: when the server cannot be listed, the // TestMarkUnpublishedUnreachableServer: a listing that failed must leave every
// overview must say so rather than claim every project is missing there. // row unmarked. Not knowing is not the same as knowing they are missing
func TestReportInventoryUnreachableServer(t *testing.T) { // marking all of them would tell the user to re-init their whole base.
out := captureStdout(t, func() { func TestMarkUnpublishedUnreachableServer(t *testing.T) {
reportInventory([]string{"a", "b"}, nil, errors.New("network is unreachable")) rows := []projStatus{{name: "a"}, {name: "b"}}
}) markUnpublished(rows, nil, errors.New("network is unreachable"))
if strings.Contains(out, "not on the git server") { for _, r := range rows {
t.Errorf("an unreachable server was reported as missing repositories: %q", out) if r.notOnServer {
t.Errorf("%s marked as missing although the server could not be listed", r.name)
} }
if !strings.Contains(out, "not reachable") {
t.Errorf("no hint that the server was unreachable: %q", out)
} }
} }
// TestReportInventorySplitsSides is the join that plain git cannot do. // TestMarkUnpublished flags only what the server really does not have.
func TestReportInventorySplitsSides(t *testing.T) { func TestMarkUnpublished(t *testing.T) {
out := captureStdout(t, func() { rows := []projStatus{{name: "both"}, {name: "onlyhere"}}
reportInventory([]string{"both", "onlyhere"}, []string{"both", "onlythere"}, nil) markUnpublished(rows, []string{"both", "onlythere"}, nil)
}) if rows[0].notOnServer {
if !strings.Contains(out, "not on the git server:") || !strings.Contains(out, "onlyhere") { t.Error("a project present on both sides was marked")
t.Errorf("local-only project not reported: %q", out)
} }
if !strings.Contains(out, "not cloned here:") || !strings.Contains(out, "onlythere") { if !rows[1].notOnServer {
t.Errorf("server-only project not reported: %q", out) t.Error("a local-only project was not marked")
} }
if strings.Contains(out, "both") { }
t.Errorf("a project present on both sides should not be listed: %q", out)
// 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")
} }
} }
+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)
}
}
}
+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", // a builtin, not a shell command
"open": "!xdg-open $1", // 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("open 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")
}
}
-1
View File
@@ -147,7 +147,6 @@ 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", "editor",
"remoteurl", "remotekey", "remotetype", "remotevisibility",
"remotes", "mirror", "secretscan", "remotes", "mirror", "secretscan",
} }
sort.Strings(keys) sort.Strings(keys)
+1 -1
View File
@@ -1 +1 @@
4.0.29 4.0.46