Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
cd5ab1a2bd | ||
|
|
65342bcd7c | ||
|
|
0d0d28560e | ||
|
|
2acca170e6 | ||
|
|
2a622046f2 | ||
|
|
61a7059f61 | ||
|
|
8ca05d6ad2 | ||
|
|
cb1ff98c3d | ||
|
|
86fb898df4 | ||
|
|
3a420093d1 | ||
|
|
8c68b28dc2 | ||
|
|
cad7a4ec2c | ||
|
|
59da8f376c |
@@ -7,6 +7,7 @@ directory. Go port of the original Perl `mgsh` (`mgsh.perl`).
|
||||
## Contents
|
||||
|
||||
- [Build](#build) · [Usage](#usage) · [Commands](#commands) · [Aliases](#aliases)
|
||||
- [Overview](#overview) · [Credential check](#credential-check)
|
||||
- [Public mirror (`pushremote`)](#public-mirror-pushremote) ·
|
||||
[Releases](#releases)
|
||||
- [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.
|
||||
|
||||
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
|
||||
`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
|
||||
projects for `cd`/`open`, server repos for `clone`/`show`, branches/tags for
|
||||
`checkout`/`tag`, mirror targets for `pushremote`, filesystem paths for `dist`),
|
||||
and colored `list`/`log`/error output.
|
||||
projects for `cd`, server repos for `clone`/`show`, branches/tags for
|
||||
`checkout`/`tag`, mirror targets for `pushremote`/`release`, filesystem paths for
|
||||
`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
|
||||
needs it; `rescan` refreshes it (and reloads the configuration).
|
||||
@@ -69,6 +71,34 @@ prefix it with `!`:
|
||||
< 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
|
||||
|
||||
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) |
|
||||
| `pull` / `fetch` | pull / fetch from the server |
|
||||
| `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 |
|
||||
| `edit [n]` | interactive rebase of the last n commits |
|
||||
| `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 |
|
||||
| `archive [comment]` | snapshot the server-side repo into `./archive` |
|
||||
| `init` | make a new repository from the current directory |
|
||||
@@ -114,7 +144,6 @@ project /Users/me/src/myproject/.mgshrc
|
||||
gitport 22
|
||||
gituser git
|
||||
gitpath /home/git
|
||||
editor code (.mgshrc)
|
||||
remotes hub (.mgshrc)
|
||||
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.
|
||||
`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
|
||||
|
||||
`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
|
||||
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
|
||||
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
|
||||
```
|
||||
|
||||
Several servers get one `remote.<name>.*` block each:
|
||||
|
||||
```ini
|
||||
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.visibility = public
|
||||
|
||||
# remotes = gitea, hub # optional: restrict and order the set
|
||||
```
|
||||
`<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 |
|
||||
|------------------------|-----------------------------------------------|
|
||||
| `pushremote` | every configured target, in order |
|
||||
| `pushremote @hub` | only `hub` |
|
||||
| `pushremote @hub @gitea` | those two |
|
||||
| `pushremote @gitea` | only `gitea` |
|
||||
| `pushremote @gitea @gitlab` | those two |
|
||||
| `pushremote a fix` | every target, description "a fix" |
|
||||
|
||||
Each target owns a git remote of the same name in the repository (the flat form
|
||||
uses `public`, as before), so `git push hub` keeps working outside mgsh. A
|
||||
Each target owns a git remote of the same name in the repository, so
|
||||
`git push gitlab` keeps working outside mgsh. A
|
||||
target that fails does not stop the others; with more than one target
|
||||
`pushremote` prints an `n/m remotes updated` summary. `remotes = …` restricts
|
||||
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
|
||||
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
|
||||
existing file is readable by others. The provider is auto-detected from `remoteurl` (`github.com` →
|
||||
GitHub, `gitlab*` → GitLab, otherwise Gitea) and can be forced with
|
||||
`remotetype`. Set `mirror = true` to have every `push` mirror automatically.
|
||||
existing file is readable by others. The provider is auto-detected from the url
|
||||
(`github.com` → GitHub, `gitlab*` → GitLab, otherwise Gitea) and can be forced
|
||||
with `remote.<name>.type`. Set `mirror = true` to have every `push` mirror
|
||||
automatically.
|
||||
|
||||
### Releases
|
||||
|
||||
@@ -274,7 +408,6 @@ gitpath = /home/git
|
||||
gitname = Your Name
|
||||
gitemail = you@example.com
|
||||
pushdefault = matching
|
||||
editor = code # fallback opener for `open`
|
||||
|
||||
alias co 'checkout $1'
|
||||
```
|
||||
@@ -284,8 +417,10 @@ alias co 'checkout $1'
|
||||
### Settings reference
|
||||
|
||||
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
|
||||
project `.mgshrc` may override the setting.
|
||||
(e.g. `MGSH_GITHOST`), which wins over both files; a mirror field is
|
||||
`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 |
|
||||
|---|---|---|
|
||||
@@ -298,17 +433,13 @@ project `.mgshrc` may override the setting.
|
||||
| `gitname` | global | `user.name` written to the **global** git config at startup |
|
||||
| `gitemail` | global | `user.email` 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`) |
|
||||
| `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>.url` | project | base URL of the mirror target `<name>` |
|
||||
| `remote.<name>.key` | project | API token for that target |
|
||||
| `remote.<name>.type` | project | provider override for that target |
|
||||
| `remote.<name>.visibility` | project | visibility for that target |
|
||||
| `remote.<name>.type` | project | `gitea`\|`github`\|`gitlab`; auto-detected from the url when unset |
|
||||
| `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 |
|
||||
| `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
|
||||
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
|
||||
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
|
||||
# ~/src/myproject/.mgshrc
|
||||
|
||||
@@ -46,7 +46,7 @@ var builtinCmds = map[string]bool{
|
||||
"diff": true, "pull": true, "fetch": true, "push": true, "edit": true,
|
||||
"pushremote": true, "overview": true, "archive": true, "init": 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,
|
||||
}
|
||||
|
||||
|
||||
@@ -2,7 +2,9 @@ package main
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strconv"
|
||||
"strings"
|
||||
"unicode/utf8"
|
||||
)
|
||||
|
||||
// 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))
|
||||
}
|
||||
|
||||
// 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 {
|
||||
if len(s) < n {
|
||||
return s + strings.Repeat(" ", n-len(s))
|
||||
if l := utf8.RuneCountInString(s); l < n {
|
||||
return s + strings.Repeat(" ", n-l)
|
||||
}
|
||||
return s
|
||||
}
|
||||
|
||||
// colorRepoLine colors a `list` entry: the leading `ls -ltr` date (3 fields) in
|
||||
// yellow and the repository name in green.
|
||||
func colorRepoLine(s string) string {
|
||||
if !useColor {
|
||||
return s
|
||||
// formatRepoList renders the server listing for `list`: the name first, in a
|
||||
// column wide enough for the longest one, then the date, and for archives the
|
||||
// size. Names come first because that is what the eye scans for; putting the
|
||||
// ragged date there instead is what made the old output hard to read.
|
||||
//
|
||||
// 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)
|
||||
if len(parts) >= 4 {
|
||||
date := strings.Join(parts[:3], " ")
|
||||
name := strings.Join(parts[3:], " ")
|
||||
return col(cYellow, date) + " " + col(cGreen, name)
|
||||
var b strings.Builder
|
||||
for _, e := range entries {
|
||||
fmt.Fprintf(&b, " %s %s", col(cGreen, padRight(e.name, width)), col(cYellow, e.date))
|
||||
if withSize {
|
||||
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
@@ -14,32 +14,85 @@ import (
|
||||
var (
|
||||
optRe = regexp.MustCompile(`^-(\w)$`)
|
||||
numRe = regexp.MustCompile(`^\d+$`)
|
||||
// a `ls -ltr` long-listing line: mode, link count, owner, group, size, then
|
||||
// the date columns and the name. Owner and group are matched as opaque
|
||||
// a `ls -ltr` long-listing line: mode, link count, owner, group, size, the
|
||||
// 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
|
||||
// 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$`)
|
||||
sanRe = regexp.MustCompile(`[,;:\\/='"|?><-]+`)
|
||||
wsRe = regexp.MustCompile(`\s+`)
|
||||
)
|
||||
|
||||
// lsEntry extracts the "<date columns> <name>" tail of a `ls -ltr` line whose
|
||||
// entry name ends in suffix, with the suffix removed. It returns "" for any
|
||||
// other line (the leading "total" line, entries of a different kind).
|
||||
func lsEntry(line, suffix string) string {
|
||||
// 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.
|
||||
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))
|
||||
if m == nil {
|
||||
return ""
|
||||
return lsEntry{}, false
|
||||
}
|
||||
name := m[1]
|
||||
name := m[5]
|
||||
if i := strings.Index(name, " -> "); i >= 0 {
|
||||
name = name[:i] // a symlinked bare repo lists as "link.git -> target.git"
|
||||
}
|
||||
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
|
||||
@@ -184,20 +237,66 @@ func runCommandDepth(line string, depth int) bool {
|
||||
if opt["a"] {
|
||||
path, suffix = "./archive", ".git.tar.gz"
|
||||
}
|
||||
pat := word(words, 1)
|
||||
lines, err := sshOut("/bin/ls -ltr " + shq(path))
|
||||
if err != nil {
|
||||
errorln("could not list repositories on the git server")
|
||||
break
|
||||
one, many := "repository", "repositories"
|
||||
if opt["a"] {
|
||||
one, many = "archive", "archives"
|
||||
}
|
||||
for _, ln := range lines {
|
||||
if pat != "" && !strings.Contains(strings.ToLower(ln), strings.ToLower(pat)) {
|
||||
pat := strings.ToLower(word(words, 1))
|
||||
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
|
||||
}
|
||||
if name := lsEntry(ln, suffix); name != "" {
|
||||
fmt.Println(colorRepoLine(name))
|
||||
if !opt["a"] {
|
||||
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
|
||||
prj := PRJ
|
||||
@@ -272,6 +371,13 @@ func runCommandDepth(line string, depth int) bool {
|
||||
}
|
||||
comment := strings.Join(fields[1:], " ")
|
||||
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))
|
||||
git(DIR, "commit", "-m", msg) // may be "nothing to commit"; continue anyway
|
||||
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
|
||||
if !requireProject() {
|
||||
break
|
||||
@@ -703,7 +770,6 @@ const gitignore = `.DS_Store
|
||||
|
||||
var helpItems = []struct{ cmd, desc string }{
|
||||
{"cd [project]", "change project (no argument: back to the base)"},
|
||||
{"open [project]", "open project"},
|
||||
{"init", "make new repository from current directory"},
|
||||
{"push [comment]", "push changes to git server"},
|
||||
{"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"},
|
||||
{"fetch", "fetch changes from git server"},
|
||||
{"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"},
|
||||
{"edit [number]", "edit last [number] commits (default is 10)"},
|
||||
{"clone [-a] <repository>", "clone repository from git server (-a for archive)"},
|
||||
|
||||
+42
-13
@@ -4,19 +4,54 @@ import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"unicode/utf8"
|
||||
|
||||
"github.com/chzyer/readline"
|
||||
)
|
||||
|
||||
// completer wires up Tab completion. Command names complete at the start of the
|
||||
// line; cd/open/view complete local project names; clone/show complete
|
||||
// completer wires up Tab completion. A line headed for a shell — a '!' escape,
|
||||
// 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
|
||||
// tag names; dist completes filesystem paths.
|
||||
func completer() *readline.PrefixCompleter {
|
||||
// tag names; pushremote/release complete mirror targets; dist completes
|
||||
// filesystem paths.
|
||||
func builtinCompleter() *readline.PrefixCompleter {
|
||||
return readline.NewPrefixCompleter(
|
||||
readline.PcItem("cd", readline.PcItemDynamic(dynLocalProjects)),
|
||||
readline.PcItem("open", readline.PcItemDynamic(dynLocalProjects)),
|
||||
readline.PcItem("view", readline.PcItemDynamic(dynLocalProjects)),
|
||||
readline.PcItem("clone",
|
||||
readline.PcItem("-a", readline.PcItemDynamic(dynServerArchives)),
|
||||
readline.PcItemDynamic(dynServerRepos),
|
||||
@@ -82,18 +117,12 @@ func fetchServerRepos() {
|
||||
if serverFetched {
|
||||
return
|
||||
}
|
||||
lines, err := sshOut("/bin/ls .")
|
||||
repos, err := serverRepoNames()
|
||||
if err != nil {
|
||||
// a transient failure (server down, no network) must not cache an
|
||||
// empty list for the rest of the session — the next Tab tries again
|
||||
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
|
||||
var archives []string
|
||||
if lines, err := sshOut("/bin/ls archive"); err == nil {
|
||||
|
||||
@@ -6,6 +6,7 @@ import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"regexp"
|
||||
"slices"
|
||||
"sort"
|
||||
"strings"
|
||||
)
|
||||
@@ -32,12 +33,8 @@ type Config struct {
|
||||
GitName string // git user.name 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)
|
||||
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`
|
||||
SecretScan string // falsy -> `push` skips the credential scan
|
||||
Remotes []RemoteTarget
|
||||
RemoteNames string // "remotes": explicit, ordered subset of targets to use
|
||||
}
|
||||
@@ -53,22 +50,25 @@ type RemoteTarget struct {
|
||||
Vis string // "private" (default) | "public"
|
||||
}
|
||||
|
||||
// legacyRemoteName is the target name for the flat remoteurl/remotekey pair,
|
||||
// matching the git remote that earlier versions created.
|
||||
// legacyRemoteName is the target the pre-4.1 flat remoteurl/remotekey settings
|
||||
// are migrated to. It matches the git remote those versions created, so a
|
||||
// converted configuration keeps pushing to the same place.
|
||||
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
|
||||
// names of targets that are defined but unusable (missing url or key) so the
|
||||
// caller can complain about them instead of silently skipping.
|
||||
func (c Config) mirrorTargets() (usable []RemoteTarget, incomplete []string) {
|
||||
var all []RemoteTarget
|
||||
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...)
|
||||
all := c.Remotes
|
||||
|
||||
// `remotes = a, b` narrows and orders the set — a project .mgshrc uses it
|
||||
// to mirror to only some of the globally configured servers.
|
||||
@@ -146,11 +146,54 @@ func loadConfig() Config {
|
||||
m := parseConfig(string(data))
|
||||
applyConfig(&c, m)
|
||||
warnConfigPerms(path, m)
|
||||
migrateRemoteKeys(path, string(data))
|
||||
}
|
||||
applyEnv(&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:
|
||||
// `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
|
||||
@@ -276,15 +319,17 @@ func writeConfigTemplate(path string) {
|
||||
b.WriteString("# gitname = Your Name\n")
|
||||
b.WriteString("# gitemail = you@example.com\n")
|
||||
b.WriteString("# pushdefault = matching\n")
|
||||
b.WriteString("# editor = code\n\n")
|
||||
b.WriteString("\n")
|
||||
b.WriteString("# --- public mirrors for `pushremote` ---\n")
|
||||
b.WriteString("# One block per server; `pushremote` pushes to all of them,\n")
|
||||
b.WriteString("# `pushremote @hub` to a single one.\n")
|
||||
b.WriteString("# remote.hub.url = https://github.com\n")
|
||||
b.WriteString("# remote.hub.key = <personal-access-token>\n")
|
||||
b.WriteString("# remote.hub.visibility = public\n")
|
||||
b.WriteString("# remotes = hub # optional: restrict/order the set\n")
|
||||
b.WriteString("# mirror = true # `push` also mirrors\n\n")
|
||||
b.WriteString("# One 'remote.<name>.*' block per server. `pushremote` pushes to all\n")
|
||||
b.WriteString("# of them, `pushremote @gitlab` to a single one. <name> is also the\n")
|
||||
b.WriteString("# git remote created in the repository.\n")
|
||||
b.WriteString("# remote.gitlab.url = https://gitlab.example.com\n")
|
||||
b.WriteString("# remote.gitlab.key = <personal-access-token>\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("# A project may override any of these (except base and the git\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("gitemail", &c.GitEmail)
|
||||
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("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>.
|
||||
@@ -436,11 +503,55 @@ func applyEnv(c *Config) {
|
||||
env("MGSH_GITNAME", &c.GitName)
|
||||
env("MGSH_GITEMAIL", &c.GitEmail)
|
||||
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_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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -152,6 +152,22 @@ func sshOut(remote string) ([]string, error) {
|
||||
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
|
||||
// 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".
|
||||
|
||||
@@ -145,7 +145,7 @@ func parseArgs() (int, string, bool) {
|
||||
}
|
||||
cls := map[string]int{
|
||||
"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,
|
||||
"config": 1, "count": 1, "login": 1, "cloneall": 1, "release": 1,
|
||||
}
|
||||
|
||||
+308
-47
@@ -9,6 +9,7 @@ import (
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
"unicode/utf8"
|
||||
)
|
||||
|
||||
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
|
||||
in := "Sep 28 2016 Betaflight3.0.0"
|
||||
if got := colorRepoLine(in); got != in {
|
||||
t.Errorf("colorRepoLine with color off changed input: %q", got)
|
||||
entries := []lsEntry{
|
||||
{name: "short", date: "Sep 28 2016", size: 4096},
|
||||
{name: "a-much-longer-name", date: "Jan 3 14:32", size: 1536},
|
||||
}
|
||||
|
||||
useColor = true
|
||||
got := colorRepoLine(in)
|
||||
if !strings.Contains(got, "Betaflight3.0.0") || !strings.Contains(got, cGreen) || !strings.Contains(got, cYellow) {
|
||||
t.Errorf("colorRepoLine did not color parts: %q", got)
|
||||
out := formatRepoList(entries, false)
|
||||
lines := strings.Split(strings.TrimRight(out, "\n"), "\n")
|
||||
if len(lines) != 2 {
|
||||
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
|
||||
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) {
|
||||
@@ -95,7 +142,7 @@ func TestParseConfig(t *testing.T) {
|
||||
githost = 10.0.0.1
|
||||
GitPort: 22
|
||||
gituser = "deploy"
|
||||
editor = 'code'
|
||||
gitkey = 'mgit_rsa'
|
||||
ignored line without separator
|
||||
base=/tmp/src
|
||||
`
|
||||
@@ -104,7 +151,7 @@ base=/tmp/src
|
||||
"githost": "10.0.0.1",
|
||||
"gitport": "22",
|
||||
"gituser": "deploy",
|
||||
"editor": "code",
|
||||
"gitkey": "mgit_rsa",
|
||||
"base": "/tmp/src",
|
||||
}
|
||||
for k, want := range checks {
|
||||
@@ -119,7 +166,7 @@ base=/tmp/src
|
||||
|
||||
func TestParseConfigInlineComments(t *testing.T) {
|
||||
rc := `
|
||||
editor = code # fallback opener for ` + "`open`" + `
|
||||
gitkey = mgit_rsa # fallback opener comment
|
||||
mirror = true # ` + "`push`" + ` also mirrors via pushremote
|
||||
gitport = 22 # ssh port
|
||||
remotekey = abc#123
|
||||
@@ -129,7 +176,7 @@ gitemail = # value is only a comment
|
||||
`
|
||||
m := parseConfig(rc)
|
||||
checks := map[string]string{
|
||||
"editor": "code",
|
||||
"gitkey": "mgit_rsa",
|
||||
"mirror": "true",
|
||||
"gitport": "22",
|
||||
"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) {
|
||||
cases := []struct{ line, suffix, want string }{
|
||||
// ownership is not assumed: any user/group must list
|
||||
{"drwxr-xr-x 7 git git 4096 Sep 28 2016 myproj.git", ".git", "Sep 28 2016 myproj"},
|
||||
{"drwxr-xr-x 7 deploy deploy 4096 Sep 28 2016 myproj.git", ".git", "Sep 28 2016 myproj"},
|
||||
{"drwxr-xr-x 7 mike staff 4096 Sep 28 2016 myproj.git", ".git", "Sep 28 2016 myproj"},
|
||||
{"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
|
||||
{"-rw-r--r-- 1 git git 512 Sep 28 2016 myproj.git.tar.gz", ".git.tar.gz", "Sep 28 2016 myproj"},
|
||||
{"-rw-r--r-- 1 git git 512 Sep 28 2016 myproj.git.tar.gz", ".git", ""},
|
||||
{"drwxr-xr-x 7 git git 4096 Sep 28 2016 myproj.git", ".git.tar.gz", ""},
|
||||
func TestParseLsEntry(t *testing.T) {
|
||||
cases := []struct {
|
||||
line, suffix string
|
||||
name, date string
|
||||
size int64
|
||||
ok bool
|
||||
}{
|
||||
// ownership is not assumed: any user/group must parse
|
||||
{"drwxr-xr-x 7 git git 4096 Sep 28 2016 myproj.git", ".git", "myproj", "Sep 28 2016", 4096, true},
|
||||
{"drwxr-xr-x 7 deploy deploy 4096 Sep 28 2016 myproj.git", ".git", "myproj", "Sep 28 2016", 4096, true},
|
||||
{"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
|
||||
{"total 48", ".git", ""},
|
||||
{"", ".git", ""},
|
||||
{"drwxr-xr-x 7 git git 4096 Sep 28 2016 notes", ".git", ""},
|
||||
{"total 48", ".git", "", "", 0, false},
|
||||
{"", ".git", "", "", 0, false},
|
||||
{"drwxr-xr-x 7 git git 4096 Sep 28 2016 notes", ".git", "", "", 0, false},
|
||||
}
|
||||
for _, c := range cases {
|
||||
if got := lsEntry(c.line, c.suffix); got != c.want {
|
||||
t.Errorf("lsEntry(%q, %q) = %q, want %q", c.line, c.suffix, got, c.want)
|
||||
e, ok := parseLsEntry(c.line, c.suffix)
|
||||
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) {
|
||||
// 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
|
||||
applyConfig(&c, parseConfig("remoteurl = https://git.example.com\nremotekey = tok\n"))
|
||||
usable, _ := c.mirrorTargets()
|
||||
@@ -481,7 +539,7 @@ func TestResolveProjectConfig(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
global := Config{
|
||||
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"}},
|
||||
}
|
||||
|
||||
@@ -492,7 +550,7 @@ func TestResolveProjectConfig(t *testing.T) {
|
||||
|
||||
rc := `
|
||||
githost = project.example
|
||||
editor = code
|
||||
gitkey = project_rsa
|
||||
base = /somewhere/else
|
||||
gitname = Project Name
|
||||
remote.hub.url = https://github.com
|
||||
@@ -504,8 +562,8 @@ remote.gitea.visibility = public
|
||||
}
|
||||
got := resolveConfig(global, dir)
|
||||
|
||||
if got.GitHost != "project.example" || got.Editor != "code" {
|
||||
t.Errorf("project overrides not applied: host=%q editor=%q", got.GitHost, got.Editor)
|
||||
if got.GitHost != "project.example" || got.GitKey != "project_rsa" {
|
||||
t.Errorf("project overrides not applied: host=%q gitkey=%q", got.GitHost, got.GitKey)
|
||||
}
|
||||
// base and the git identity stay global
|
||||
if got.Base != "/base" {
|
||||
@@ -749,6 +807,7 @@ func TestTruthy(t *testing.T) {
|
||||
func TestFormatProjStatus(t *testing.T) {
|
||||
useColor = false
|
||||
defer func() { useColor = false }()
|
||||
w := overviewWidths{label: 12, sync: 5, host: 7}
|
||||
cases := []struct {
|
||||
s projStatus
|
||||
contains []string
|
||||
@@ -763,10 +822,16 @@ func TestFormatProjStatus(t *testing.T) {
|
||||
{projStatus{name: "d", branch: "feature", dirty: true},
|
||||
[]string{"d", "*", "(feature)"}, nil},
|
||||
{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 {
|
||||
got := formatProjStatus(c.s, 8)
|
||||
c.s.isRepo = true // these all describe real repositories
|
||||
got := formatProjStatus(c.s, w)
|
||||
for _, sub := range c.contains {
|
||||
if !strings.Contains(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) {
|
||||
cases := []struct {
|
||||
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
@@ -20,27 +20,32 @@ gitpath = /home/git
|
||||
# gitname = Your Name
|
||||
# gitemail = you@example.com
|
||||
# pushdefault = matching
|
||||
# editor = code
|
||||
|
||||
# --- pushremote: mirror to public servers (gitea/github/gitlab) via their API ---
|
||||
# A single server, the flat form (this target is named "public"):
|
||||
# remoteurl = https://git.example.com
|
||||
# remotekey = <personal-access-token>
|
||||
# remotetype = gitea # optional; auto-detected from remoteurl
|
||||
# remotevisibility = private # visibility of created repos (default private)
|
||||
# One "remote.<name>.<field>" block per server, with the fields url, key, type
|
||||
# and visibility. <name> is yours to pick and becomes the git remote created in
|
||||
# the repository, so `git push gitlab` keeps working outside mgsh.
|
||||
#
|
||||
# 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.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
|
||||
# `pushremote` pushes to every configured server in the order given,
|
||||
# `pushremote @gitlab` to a single one.
|
||||
#
|
||||
# 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 ---
|
||||
# A <project>/.mgshrc overrides all of the above for that project only, except
|
||||
|
||||
+317
-55
@@ -1,25 +1,49 @@
|
||||
package main
|
||||
|
||||
// overview.go — the `overview` command (also reachable as `status -a`): 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.
|
||||
// overview.go — the `overview` command (also reachable as `status -a`).
|
||||
//
|
||||
// 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 (
|
||||
"fmt"
|
||||
"os"
|
||||
"regexp"
|
||||
"sort"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
"unicode/utf8"
|
||||
)
|
||||
|
||||
// projStatus is the collected state of one project for the overview.
|
||||
type projStatus struct {
|
||||
name string
|
||||
branch string
|
||||
isRepo bool // has a .git of its own
|
||||
dirty bool
|
||||
ahead, behind int
|
||||
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() {
|
||||
entries, err := os.ReadDir(BASE)
|
||||
if err != nil {
|
||||
@@ -27,30 +51,52 @@ func overviewAll() {
|
||||
return
|
||||
}
|
||||
|
||||
var rows []projStatus
|
||||
width := 0
|
||||
// ask the server while the local tree is being walked
|
||||
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 {
|
||||
if !e.IsDir() || strings.HasPrefix(e.Name(), ".") {
|
||||
continue
|
||||
}
|
||||
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())
|
||||
if e.IsDir() && !strings.HasPrefix(e.Name(), ".") {
|
||||
local = append(local, 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 {
|
||||
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 {
|
||||
fmt.Println(formatProjStatus(r, width))
|
||||
fmt.Println(formatProjStatus(r, w))
|
||||
if r.notOnServer {
|
||||
initN++
|
||||
}
|
||||
if !r.isRepo {
|
||||
continue
|
||||
}
|
||||
repoN++
|
||||
if r.dirty {
|
||||
dirtyN++
|
||||
}
|
||||
@@ -58,51 +104,267 @@ func overviewAll() {
|
||||
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 {
|
||||
s := projStatus{name: name, branch: "-"}
|
||||
if out, err := gitCapture(dir, "rev-parse", "--abbrev-ref", "HEAD"); err == nil {
|
||||
s.branch = strings.TrimSpace(out)
|
||||
}
|
||||
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
|
||||
}
|
||||
if s.isRepo = isDir(dir + "/.git"); !s.isRepo {
|
||||
return s
|
||||
}
|
||||
readStatus(&s, dir)
|
||||
readLastCommit(&s, dir)
|
||||
s.mirrors = configuredMirrors(dir)
|
||||
return s
|
||||
}
|
||||
|
||||
// formatProjStatus renders one aligned overview row.
|
||||
func formatProjStatus(s projStatus, width int) string {
|
||||
var marks []string
|
||||
if s.dirty {
|
||||
marks = append(marks, col(cYellow, "*"))
|
||||
// readStatus fills in branch, upstream, ahead/behind and dirty from a single
|
||||
// `git status` — the porcelain v2 header carries all four.
|
||||
func readStatus(s *projStatus, dir string) {
|
||||
out, err := gitCapture(dir, "status", "--porcelain=v2", "--branch")
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
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)")
|
||||
for _, ln := range splitLines(out) {
|
||||
if !strings.HasPrefix(ln, "# ") {
|
||||
s.dirty = true // any entry line means the tree is not clean
|
||||
continue
|
||||
}
|
||||
f := strings.Fields(ln)
|
||||
if len(f) < 3 {
|
||||
continue
|
||||
}
|
||||
switch f[1] {
|
||||
case "branch.head":
|
||||
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 != "-" {
|
||||
line += col(cGray, " ("+s.branch+")")
|
||||
}
|
||||
return line
|
||||
}
|
||||
|
||||
// readLastCommit records when the project was last committed to and from which
|
||||
// machine, taken from the "[user@host]" prefix `push` writes.
|
||||
func readLastCommit(s *projStatus, dir string) {
|
||||
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))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -4,23 +4,18 @@ package main
|
||||
// git hosting server (Gitea, GitHub or GitLab), creating the repository via the
|
||||
// server's REST API when it does not exist yet.
|
||||
//
|
||||
// Configuration (in ~/.mgshrc, a project .mgshrc, or MGSH_* env) — either a
|
||||
// single flat target:
|
||||
// Configuration (in ~/.mgshrc, a project .mgshrc, or MGSH_* env): one
|
||||
// remote.<name>.<field> block per server, which `pushremote` mirrors to in turn.
|
||||
//
|
||||
// remoteurl = https://git.example.com base URL of the server
|
||||
// remotekey = <api-token> personal access token
|
||||
// remotetype = gitea|github|gitlab optional; auto-detected from the URL
|
||||
// remote.gitlab.url = https://gitlab.example.com
|
||||
// remote.gitlab.key = <api-token>
|
||||
// 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:
|
||||
//
|
||||
// remote.gitea.url = https://git.example.com
|
||||
// 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.
|
||||
// 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.public.* on load.
|
||||
//
|
||||
// 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
|
||||
@@ -287,7 +282,7 @@ func handlePushRemote(args string) {
|
||||
targets = pickRemotes(targets, names)
|
||||
if len(targets) == 0 {
|
||||
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
|
||||
}
|
||||
|
||||
@@ -329,3 +329,65 @@ func mustGit(t *testing.T, dir string, args ...string) {
|
||||
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
@@ -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
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
})
|
||||
@@ -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
@@ -42,8 +42,8 @@ func showConfig() {
|
||||
{"gitname", cfg.GitName},
|
||||
{"gitemail", cfg.GitEmail},
|
||||
{"pushdefault", cfg.PushDefault},
|
||||
{"editor", cfg.Editor},
|
||||
{"mirror", cfg.Mirror},
|
||||
{"secretscan", cfg.SecretScan},
|
||||
{"remotes", cfg.RemoteNames},
|
||||
}
|
||||
|
||||
@@ -145,9 +145,8 @@ func envName(key string) string { return "MGSH_" + strings.ToUpper(key) }
|
||||
func configKeys() []string {
|
||||
keys := []string{
|
||||
"base", "githost", "gitport", "gituser", "gitpath", "gitkey",
|
||||
"gitname", "gitemail", "pushdefault", "editor",
|
||||
"remoteurl", "remotekey", "remotetype", "remotevisibility",
|
||||
"remotes", "mirror",
|
||||
"gitname", "gitemail", "pushdefault",
|
||||
"remotes", "mirror", "secretscan",
|
||||
}
|
||||
sort.Strings(keys)
|
||||
return keys
|
||||
|
||||
@@ -39,6 +39,17 @@ func truthy(s string) bool {
|
||||
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 {
|
||||
fi, err := os.Stat(p)
|
||||
return err == nil && !fi.IsDir()
|
||||
|
||||
+1
-1
@@ -1 +1 @@
|
||||
4.0.24
|
||||
4.0.50
|
||||
|
||||
Reference in New Issue
Block a user