3 Commits
Author SHA1 Message Date
mikeandClaude Opus 5 8c68b28dc2 Format list as an aligned table
The name is what the eye looks for, but it came last, behind a ragged
date column, so nothing lined up. Worse, colorRepoLine rebuilt the line
with strings.Fields and single spaces, which destroyed the alignment ls
had produced -- the output was aligned only when colour was off.

The listing line is now parsed properly instead of being split at the
size field: name, date and size come out as fields, the date is re-padded
to a fixed twelve columns so "Sep 28  2016" and "Jan  3 14:32" agree, and
the name leads in a column sized to the longest entry. Colour decorates
that layout without changing it, which a test now checks by stripping the
escapes and comparing. `list -a` shows archive sizes, which were parsed
and thrown away before.

An empty result says so instead of printing nothing, which was
indistinguishable from a failure, and the count line matches the rest of
mgsh. The pattern now filters on the repository name rather than the
whole listing line: matching the owner or the date was never intended and
`list 2016` quietly did it.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-26 16:53:54 +02:00
mikeandClaude Opus 5 cad7a4ec2c Turn overview into an inventory across local and server
overview showed dirty and ahead/behind per project, which git can do on
its own. mgsh is the only thing that sees both the local base directory
and the ssh server, and joining those answers the questions git cannot:
which projects were never pushed to the server (candidates for `init`),
and which exist there but not on this machine (candidates for `clone`).
Both lists are printed after the summary. An unreachable server is
reported as such, rather than as "everything is missing".

Each row also names the machine that made the last commit and how long
ago. That costs nothing: `push` has always stamped "[user@host]" into
the commit message, and nothing ever read it back. On a setup spanning
several machines it is usually the piece one actually wanted. Rows also
show which mirror targets the repository has a remote for, which is
local git config and therefore free.

The walk is now concurrent and cheaper per project: `git status
--porcelain=v2 --branch` yields branch, upstream, ahead/behind and dirty
in one subprocess where three were used before.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-26 16:17:40 +02:00
mikeandClaude Opus 5 59da8f376c Check the staged diff for credentials before push commits
`push` runs `git add --all .`, so anything lying in the project gets
committed, and with `mirror = true` it reaches a public server in the
same breath. It is the one action in mgsh that cannot be undone: a
deleted server repository comes back from an archive, a published
credential does not.

The staged diff is now scanned before the commit is made -- private keys,
GitHub/GitLab/Slack/AWS/PyPI tokens, and credential-shaped assignments --
and a hit is shown with file and line before asking whether to continue.
Declining leaves the changes staged but uncommitted, so removing the file
and adding a .gitignore entry is all it takes.

The hard part is not detection but silence. A scanner that cries wolf
gets answered with a reflexive "y" and stops being a safety net, so
values that are plainly environment references, dotted identifiers,
constant names, template slots or masked stand-ins are filtered out. A
test scans mgsh's own README and mgshrc.example -- both full of
credential-shaped text -- and fails if either would trip the check. It
caught the documentation for this very feature, which is why the README
describes the sample output instead of reproducing it.

For a line that legitimately looks like a credential there is
`mgsh:allow`, which suppresses that one line; `secretscan = off` turns
the check off entirely. Only an explicit "off" does that -- a typo in the
setting leaves the safety net in place, which is what the new falsy()
is for.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-26 16:17:30 +02:00
15 changed files with 1108 additions and 100 deletions
+84 -2
View File
@@ -7,6 +7,7 @@ directory. Go port of the original Perl `mgsh` (`mgsh.perl`).
## Contents ## Contents
- [Build](#build) · [Usage](#usage) · [Commands](#commands) · [Aliases](#aliases) - [Build](#build) · [Usage](#usage) · [Commands](#commands) · [Aliases](#aliases)
- [Overview](#overview) · [Credential check](#credential-check)
- [Public mirror (`pushremote`)](#public-mirror-pushremote) · - [Public mirror (`pushremote`)](#public-mirror-pushremote) ·
[Releases](#releases) [Releases](#releases)
- [Configuration](#configuration) · [Settings reference](#settings-reference) · - [Configuration](#configuration) · [Settings reference](#settings-reference) ·
@@ -80,11 +81,11 @@ Run `help` for the full list. Highlights:
| `pushremote [desc]` | mirror the repo to a public server (gitea/github/gitlab) | | `pushremote [desc]` | mirror the repo to a public server (gitea/github/gitlab) |
| `pull` / `fetch` | pull / fetch from the server | | `pull` / `fetch` | pull / fetch from the server |
| `status [-a]` / `diff` | short git status (`-a`: overview of all projects) | | `status [-a]` / `diff` | short git status (`-a`: overview of all projects) |
| `overview` | dirty / ahead-behind summary of all projects | | `overview` | inventory of all projects, local and on the server |
| `log` | show the project log | | `log` | show the project log |
| `edit [n]` | interactive rebase of the last n commits | | `edit [n]` | interactive rebase of the last n commits |
| `clone [-a] <repo>` | clone a repository (or archive) from the server | | `clone [-a] <repo>` | clone a repository (or archive) from the server |
| `list [-a] [pattern]` | list repositories on the server | | `list [-a] [pattern]` | list repositories on the server (`-a`: archives, with sizes) |
| `show <repo>` | show a repository log directly on the server | | `show <repo>` | show a repository log directly on the server |
| `archive [comment]` | snapshot the server-side repo into `./archive` | | `archive [comment]` | snapshot the server-side repo into `./archive` |
| `init` | make a new repository from the current directory | | `init` | make a new repository from the current directory |
@@ -125,6 +126,86 @@ pushremote targets (in push order):
Tokens are masked, so the output is safe to paste into a bug report. Tokens are masked, so the output is safe to paste into a bug report.
`config -k` prints just the setting names, one per line. `config -k` prints just the setting names, one per line.
### Listing the server
`list` shows what is on the git server, name first and aligned, ordered by
modification time — `push` touches the bare repository, so the most recently
worked-on project sits closest to the prompt:
```
< src > list
Betaflight3.0.0 Sep 28 2016
website Mar 3 2024
notes Jan 3 14:32
3 repositories
```
`list -a` lists the archives instead, with their sizes; 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
mgsh * ↑2 · desktop 3h · → hub,gitea
notes ✓ · laptop 2d
website ✓ (no upstream) · laptop 20d
3 projects · 1 dirty · 1 in sync
not on the git server: scratch, experiments (init)
not cloned here: oldproject (clone)
```
Per project: dirty marker, commits ahead/behind the upstream, the branch when it
is not `master`/`main`, and the mirror targets the repository has a remote for.
The machine 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 two lists at the end are the join no git command can do: local projects the
server has never seen (`init` them) and server repositories missing on this
machine (`clone` them). If the server cannot be reached, mgsh says so instead of
claiming everything is missing.
### Credential check
`push` runs `git add --all .`, so anything lying in the project gets committed —
and with `mirror = true` it reaches a public server in the same breath. That is
the only action in mgsh that cannot be undone: a deleted server repository comes
back from an archive, a published credential does not.
So before anything is committed, the staged diff is checked for private keys,
GitHub/GitLab/Slack/AWS/PyPI tokens and credential-shaped assignments:
```
< src/notes > push new notes
2 possible credential(s) in what is about to be committed:
.env:3 credential assignment
API_KEY="<the offending line is shown in full here>"
deploy_key:1 private key
<the BEGIN … PRIVATE KEY header is shown here>
(set 'secretscan = off' to skip this check)
push anyway? y/N ?
```
Declining stops the push with nothing committed; the changes stay staged, so
`git restore --staged <file>` and a `.gitignore` entry are all it takes.
For a line that only *looks* like a credential and is meant to stay, put
`mgsh:allow` in it — a comment on that line is enough. That is better than
turning the whole check off for one false positive.
This is not a complete secret scanner and does not try to be one. It aims for a
high hit rate on what actually leaks, with few enough false alarms that the
prompt still means something: values that are plainly environment references,
constants, template slots (`<token>`, `${VAR}`) or masked stand-ins are ignored —
a test checks that mgsh's own README and `mgshrc.example`, both full of
credential-shaped text, stay quiet. Switch it off with `secretscan = off`.
### Aliases ### Aliases
`alias <name> '<command>'` defines a reusable shortcut, persisted to `alias <name> '<command>'` defines a reusable shortcut, persisted to
@@ -309,6 +390,7 @@ project `.mgshrc` may override the setting.
| `remote.<name>.visibility` | project | visibility for that target | | `remote.<name>.visibility` | project | visibility for that target |
| `remotes` | project | comma- or space-separated list restricting and ordering the mirror targets | | `remotes` | project | comma- or space-separated list restricting and ordering the mirror targets |
| `mirror` | project | truthy (`1`/`true`/`yes`/`on`) → every `push` also mirrors | | `mirror` | project | truthy (`1`/`true`/`yes`/`on`) → every `push` also mirrors |
| `secretscan` | project | `off` disables the credential check `push` runs before committing (on by default; only an explicit `off` disables it) |
The three settings written to the global git config are applied at startup, and The three settings written to the global git config are applied at startup, and
only when they actually differ, so a plain `mgsh status` does not rewrite only when they actually differ, so a plain `mgsh status` does not rewrite
+42 -11
View File
@@ -2,6 +2,7 @@ package main
import ( import (
"fmt" "fmt"
"strconv"
"strings" "strings"
) )
@@ -43,17 +44,47 @@ func padRight(s string, n int) string {
return s return s
} }
// colorRepoLine colors a `list` entry: the leading `ls -ltr` date (3 fields) in // formatRepoList renders the server listing for `list`: the name first, in a
// yellow and the repository name in green. // column wide enough for the longest one, then the date, and for archives the
func colorRepoLine(s string) string { // size. Names come first because that is what the eye scans for; putting the
if !useColor { // ragged date there instead is what made the old output hard to read.
return s //
// The order is left as it arrives: `ls -ltr` sorts by modification time, and
// `push` touches the bare repository, so the most recently worked-on project
// ends up closest to the prompt.
func formatRepoList(entries []lsEntry, withSize bool) string {
width := 0
for _, e := range entries {
if len(e.name) > width {
width = len(e.name)
} }
parts := strings.Fields(s)
if len(parts) >= 4 {
date := strings.Join(parts[:3], " ")
name := strings.Join(parts[3:], " ")
return col(cYellow, date) + " " + col(cGreen, name)
} }
return col(cGreen, s) 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 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])
} }
+58 -17
View File
@@ -14,32 +14,46 @@ import (
var ( var (
optRe = regexp.MustCompile(`^-(\w)$`) optRe = regexp.MustCompile(`^-(\w)$`)
numRe = regexp.MustCompile(`^\d+$`) numRe = regexp.MustCompile(`^\d+$`)
// a `ls -ltr` long-listing line: mode, link count, owner, group, size, then // a `ls -ltr` long-listing line: mode, link count, owner, group, size, the
// the date columns and the name. Owner and group are matched as opaque // three date columns, then the name. Owner and group are matched as opaque
// fields — the bare repositories need not belong to a user or group // fields — the bare repositories need not belong to a user or group
// literally named "git". // literally named "git".
lsEntryRe = regexp.MustCompile(`^\S+\s+\d+\s+\S+\s+\S+\s+\d+\s+(.*)$`) lsEntryRe = regexp.MustCompile(`^\S+\s+\d+\s+\S+\s+\S+\s+(\d+)\s+(\S+)\s+(\S+)\s+(\S+)\s+(.*)$`)
gitDirRe = regexp.MustCompile(`^(.*)\.git$`) gitDirRe = regexp.MustCompile(`^(.*)\.git$`)
sanRe = regexp.MustCompile(`[,;:\\/='"|?><-]+`) sanRe = regexp.MustCompile(`[,;:\\/='"|?><-]+`)
wsRe = regexp.MustCompile(`\s+`) wsRe = regexp.MustCompile(`\s+`)
) )
// lsEntry extracts the "<date columns> <name>" tail of a `ls -ltr` line whose // lsEntry is one parsed entry of the server's listing.
// entry name ends in suffix, with the suffix removed. It returns "" for any type lsEntry struct {
// other line (the leading "total" line, entries of a different kind). name string // with the ".git" / ".git.tar.gz" suffix removed
func lsEntry(line, suffix string) string { date string // the ls date columns, normalised to a fixed 12 columns
size int64
}
// parseLsEntry reads one `ls -ltr` line whose entry name ends in suffix. It
// returns false for anything else: the leading "total" line, entries of another
// kind, or output that does not look like a long listing at all.
func parseLsEntry(line, suffix string) (lsEntry, bool) {
m := lsEntryRe.FindStringSubmatch(strings.TrimSpace(line)) m := lsEntryRe.FindStringSubmatch(strings.TrimSpace(line))
if m == nil { if m == nil {
return "" return lsEntry{}, false
} }
name := m[1] name := m[5]
if i := strings.Index(name, " -> "); i >= 0 { if i := strings.Index(name, " -> "); i >= 0 {
name = name[:i] // a symlinked bare repo lists as "link.git -> target.git" name = name[:i] // a symlinked bare repo lists as "link.git -> target.git"
} }
if !strings.HasSuffix(name, suffix) { if !strings.HasSuffix(name, suffix) {
return "" return lsEntry{}, false
} }
return strings.TrimSuffix(name, suffix) size, _ := strconv.ParseInt(m[1], 10, 64)
return lsEntry{
name: strings.TrimSuffix(name, suffix),
// ls pads these itself, but only in its own column widths; re-pad so
// "Sep 28 2016" and "Jan 3 14:32" line up at 12 either way
date: fmt.Sprintf("%s %2s %5s", m[2], m[3], m[4]),
size: size,
}, true
} }
// validProject reports whether name is usable as a project name: a single path // validProject reports whether name is usable as a project name: a single path
@@ -184,20 +198,40 @@ func runCommandDepth(line string, depth int) bool {
if opt["a"] { if opt["a"] {
path, suffix = "./archive", ".git.tar.gz" path, suffix = "./archive", ".git.tar.gz"
} }
pat := word(words, 1) one, many := "repository", "repositories"
if opt["a"] {
one, many = "archive", "archives"
}
pat := strings.ToLower(word(words, 1))
lines, err := sshOut("/bin/ls -ltr " + shq(path)) lines, err := sshOut("/bin/ls -ltr " + shq(path))
if err != nil { if err != nil {
errorln("could not list repositories on the git server") errorln("could not list " + many + " on the git server")
break break
} }
var entries []lsEntry
for _, ln := range lines { for _, ln := range lines {
if pat != "" && !strings.Contains(strings.ToLower(ln), strings.ToLower(pat)) { e, ok := parseLsEntry(ln, suffix)
// the pattern filters the name, not the whole listing line — an
// accidental match on the date or the owner helps nobody
if !ok || (pat != "" && !strings.Contains(strings.ToLower(e.name), pat)) {
continue continue
} }
if name := lsEntry(ln, suffix); name != "" { entries = append(entries, e)
fmt.Println(colorRepoLine(name))
} }
if len(entries) == 0 {
what := "no " + many + " on the git server"
if pat != "" {
what = "no " + many + " matching '" + word(words, 1) + "'"
} }
fmt.Println(col(cGray, what))
break
}
fmt.Print(formatRepoList(entries, opt["a"]))
label := many
if len(entries) == 1 {
label = one
}
fmt.Println(col(cGray, fmt.Sprintf("%d %s", len(entries), label)))
case "show": // show a repository's log directly on the server case "show": // show a repository's log directly on the server
prj := PRJ prj := PRJ
@@ -272,6 +306,13 @@ func runCommandDepth(line string, depth int) bool {
} }
comment := strings.Join(fields[1:], " ") comment := strings.Join(fields[1:], " ")
git(DIR, "add", "--all", ".") git(DIR, "add", "--all", ".")
// last look before anything is committed: `add --all` sweeps up whatever
// is lying around, and with mirroring on it goes straight to a public
// server. Nothing has been committed yet, so declining costs nothing.
if !secretsApproved(DIR) {
errorln("push cancelled — your changes are staged but not committed")
break
}
msg := strings.TrimSpace(fmt.Sprintf("[%s@%s] %s", USER, HOST, comment)) msg := strings.TrimSpace(fmt.Sprintf("[%s@%s] %s", USER, HOST, comment))
git(DIR, "commit", "-m", msg) // may be "nothing to commit"; continue anyway git(DIR, "commit", "-m", msg) // may be "nothing to commit"; continue anyway
if !gitOK(DIR, "push") { if !gitOK(DIR, "push") {
@@ -711,7 +752,7 @@ var helpItems = []struct{ cmd, desc string }{
{"pull", "pull changes from git server"}, {"pull", "pull changes from git server"},
{"fetch", "fetch changes from git server"}, {"fetch", "fetch changes from git server"},
{"status [-a]", "short git status (-a: overview of all projects)"}, {"status [-a]", "short git status (-a: overview of all projects)"},
{"overview", "status of all projects (dirty, ahead/behind)"}, {"overview", "inventory of all projects, local and on the server"},
{"diff [args]", "show git diff"}, {"diff [args]", "show git diff"},
{"edit [number]", "edit last [number] commits (default is 10)"}, {"edit [number]", "edit last [number] commits (default is 10)"},
{"clone [-a] <repository>", "clone repository from git server (-a for archive)"}, {"clone [-a] <repository>", "clone repository from git server (-a for archive)"},
+1 -7
View File
@@ -82,18 +82,12 @@ func fetchServerRepos() {
if serverFetched { if serverFetched {
return return
} }
lines, err := sshOut("/bin/ls .") repos, err := serverRepoNames()
if err != nil { if err != nil {
// a transient failure (server down, no network) must not cache an // a transient failure (server down, no network) must not cache an
// empty list for the rest of the session — the next Tab tries again // empty list for the rest of the session — the next Tab tries again
return return
} }
var repos []string
for _, ln := range lines {
if m := gitDirRe.FindStringSubmatch(strings.TrimSpace(ln)); m != nil {
repos = append(repos, m[1])
}
}
// a missing ./archive is a permanent, unremarkable state: still cache // a missing ./archive is a permanent, unremarkable state: still cache
var archives []string var archives []string
if lines, err := sshOut("/bin/ls archive"); err == nil { if lines, err := sshOut("/bin/ls archive"); err == nil {
+3
View File
@@ -38,6 +38,7 @@ type Config struct {
RemoteType string // "gitea"|"github"|"gitlab" (auto-detected when empty) RemoteType string // "gitea"|"github"|"gitlab" (auto-detected when empty)
RemoteVis string // visibility of created repos: "private" (default)|"public" RemoteVis string // visibility of created repos: "private" (default)|"public"
Mirror string // truthy -> `push` also mirrors via `pushremote` Mirror string // truthy -> `push` also mirrors via `pushremote`
SecretScan string // falsy -> `push` skips the credential scan
Remotes []RemoteTarget Remotes []RemoteTarget
RemoteNames string // "remotes": explicit, ordered subset of targets to use RemoteNames string // "remotes": explicit, ordered subset of targets to use
} }
@@ -370,6 +371,7 @@ func applyConfig(c *Config, m map[string]string) {
set("remotevisibility", &c.RemoteVis) set("remotevisibility", &c.RemoteVis)
set("remotes", &c.RemoteNames) set("remotes", &c.RemoteNames)
set("mirror", &c.Mirror) set("mirror", &c.Mirror)
set("secretscan", &c.SecretScan)
applyRemoteTargets(c, m) applyRemoteTargets(c, m)
} }
@@ -443,4 +445,5 @@ func applyEnv(c *Config) {
env("MGSH_REMOTEVISIBILITY", &c.RemoteVis) env("MGSH_REMOTEVISIBILITY", &c.RemoteVis)
env("MGSH_REMOTES", &c.RemoteNames) env("MGSH_REMOTES", &c.RemoteNames)
env("MGSH_MIRROR", &c.Mirror) env("MGSH_MIRROR", &c.Mirror)
env("MGSH_SECRETSCAN", &c.SecretScan)
} }
+16
View File
@@ -152,6 +152,22 @@ func sshOut(remote string) ([]string, error) {
return lines, err return lines, err
} }
// serverRepoNames lists the bare repositories on the git server, without the
// ".git" suffix.
func serverRepoNames() ([]string, error) {
lines, err := sshOut("/bin/ls .")
if err != nil {
return nil, err
}
var out []string
for _, ln := range lines {
if m := gitDirRe.FindStringSubmatch(strings.TrimSpace(ln)); m != nil {
out = append(out, m[1])
}
}
return out, nil
}
// serverEntryExists reports whether entry is present in the remote directory // serverEntryExists reports whether entry is present in the remote directory
// path (relative to the git user's home). The error is returned rather than // path (relative to the git user's home). The error is returned rather than
// folded into the bool so a failed lookup is never mistaken for "not there". // folded into the bool so a failed lookup is never mistaken for "not there".
+91 -34
View File
@@ -74,19 +74,65 @@ func TestFormatLogRecentCompact(t *testing.T) {
} }
} }
func TestColorRepoLine(t *testing.T) { func TestFormatRepoList(t *testing.T) {
useColor = false useColor = false
in := "Sep 28 2016 Betaflight3.0.0" entries := []lsEntry{
if got := colorRepoLine(in); got != in { {name: "short", date: "Sep 28 2016", size: 4096},
t.Errorf("colorRepoLine with color off changed input: %q", got) {name: "a-much-longer-name", date: "Jan 3 14:32", size: 1536},
} }
useColor = true out := formatRepoList(entries, false)
got := colorRepoLine(in) lines := strings.Split(strings.TrimRight(out, "\n"), "\n")
if !strings.Contains(got, "Betaflight3.0.0") || !strings.Contains(got, cGreen) || !strings.Contains(got, cYellow) { if len(lines) != 2 {
t.Errorf("colorRepoLine did not color parts: %q", got) t.Fatalf("expected 2 lines, got %d: %q", len(lines), out)
} }
// order is preserved: `ls -ltr` already sorted by modification time
if !strings.Contains(lines[0], "short") || !strings.Contains(lines[1], "a-much-longer-name") {
t.Errorf("order not preserved: %q", out)
}
// the date starts at the same column on every line
if strings.Index(lines[0], "Sep") != strings.Index(lines[1], "Jan") {
t.Errorf("date column not aligned:\n%s", out)
}
if strings.Contains(out, "4.0K") {
t.Errorf("size shown for repositories: %q", out)
}
if withSize := formatRepoList(entries, true); !strings.Contains(withSize, "4.0K") ||
!strings.Contains(withSize, "1.5K") {
t.Errorf("archive sizes missing: %q", withSize)
}
// colour must decorate the layout, never change it
useColor = true
colored := formatRepoList(entries, false)
useColor = false useColor = false
strip := func(s string) string {
for _, c := range []string{cReset, cGreen, cYellow, cGray} {
s = strings.ReplaceAll(s, c, "")
}
return s
}
if strip(colored) != out {
t.Errorf("colour changed the layout:\n%q\n%q", strip(colored), out)
}
}
func TestHumanSize(t *testing.T) {
cases := []struct {
n int64
want string
}{
{0, "0B"}, {512, "512B"}, {1024, "1.0K"}, {1536, "1.5K"},
{1024 * 1024, "1.0M"}, {3 * 1024 * 1024 * 1024, "3.0G"},
// past 10 the decimal carries nothing, as with `ls -h`
{512 * 1024, "512K"}, {99 * 1024 * 1024, "99M"},
}
for _, c := range cases {
if got := humanSize(c.n); got != c.want {
t.Errorf("humanSize(%d) = %q, want %q", c.n, got, c.want)
}
}
} }
func TestParseConfig(t *testing.T) { func TestParseConfig(t *testing.T) {
@@ -147,38 +193,49 @@ gitemail = # value is only a comment
} }
} }
func TestLsEntry(t *testing.T) { func TestParseLsEntry(t *testing.T) {
cases := []struct{ line, suffix, want string }{ cases := []struct {
// ownership is not assumed: any user/group must list line, suffix string
{"drwxr-xr-x 7 git git 4096 Sep 28 2016 myproj.git", ".git", "Sep 28 2016 myproj"}, name, date string
{"drwxr-xr-x 7 deploy deploy 4096 Sep 28 2016 myproj.git", ".git", "Sep 28 2016 myproj"}, size int64
{"drwxr-xr-x 7 mike staff 4096 Sep 28 2016 myproj.git", ".git", "Sep 28 2016 myproj"}, ok bool
{"drwxr-xr-x. 7 git users 4096 Sep 28 2016 myproj.git", ".git", "Sep 28 2016 myproj"}, }{
// archives only match the archive suffix, and vice versa // ownership is not assumed: any user/group must parse
{"-rw-r--r-- 1 git git 512 Sep 28 2016 myproj.git.tar.gz", ".git.tar.gz", "Sep 28 2016 myproj"}, {"drwxr-xr-x 7 git git 4096 Sep 28 2016 myproj.git", ".git", "myproj", "Sep 28 2016", 4096, true},
{"-rw-r--r-- 1 git git 512 Sep 28 2016 myproj.git.tar.gz", ".git", ""}, {"drwxr-xr-x 7 deploy deploy 4096 Sep 28 2016 myproj.git", ".git", "myproj", "Sep 28 2016", 4096, true},
{"drwxr-xr-x 7 git git 4096 Sep 28 2016 myproj.git", ".git.tar.gz", ""}, {"drwxr-xr-x. 7 git users 4096 Sep 28 2016 myproj.git", ".git", "myproj", "Sep 28 2016", 4096, true},
// a recent entry carries a time instead of a year, and still lines up
{"drwxr-xr-x 7 mike staff 224 Jan 3 14:32 myproj.git", ".git", "myproj", "Jan 3 14:32", 224, true},
// a symlinked bare repo lists its target too — only the link name counts
{"lrwxrwxrwx 1 git git 14 Sep 28 2016 myproj.git -> /srv/other.git", ".git", "myproj", "Sep 28 2016", 14, true},
// archives carry a size worth showing
{"-rw-r--r-- 1 git git 524288 Sep 28 2016 myproj.git.tar.gz", ".git.tar.gz", "myproj", "Sep 28 2016", 524288, true},
// suffixes must not cross over
{"-rw-r--r-- 1 git git 512 Sep 28 2016 myproj.git.tar.gz", ".git", "", "", 0, false},
{"drwxr-xr-x 7 git git 4096 Sep 28 2016 myproj.git", ".git.tar.gz", "", "", 0, false},
{"lrwxrwxrwx 1 git git 5 Sep 28 2016 notes -> x.git", ".git", "", "", 0, false},
// non-entries // non-entries
{"total 48", ".git", ""}, {"total 48", ".git", "", "", 0, false},
{"", ".git", ""}, {"", ".git", "", "", 0, false},
{"drwxr-xr-x 7 git git 4096 Sep 28 2016 notes", ".git", ""}, {"drwxr-xr-x 7 git git 4096 Sep 28 2016 notes", ".git", "", "", 0, false},
} }
for _, c := range cases { for _, c := range cases {
if got := lsEntry(c.line, c.suffix); got != c.want { e, ok := parseLsEntry(c.line, c.suffix)
t.Errorf("lsEntry(%q, %q) = %q, want %q", c.line, c.suffix, got, c.want) if ok != c.ok {
t.Errorf("parseLsEntry(%q, %q) ok = %v, want %v", c.line, c.suffix, ok, c.ok)
continue
} }
if !ok {
continue
} }
} if e.name != c.name || e.size != c.size {
t.Errorf("parseLsEntry(%q) = %+v, want name %q size %d", c.line, e, c.name, c.size)
func TestLsEntrySymlink(t *testing.T) { }
// a symlinked bare repo lists its target too — only the link name counts // every date renders to the same width, whichever form ls used
in := "lrwxrwxrwx 1 git git 14 Sep 28 2016 myproj.git -> /srv/other.git" if e.date != c.date || len(e.date) != 12 {
if got := lsEntry(in, ".git"); got != "Sep 28 2016 myproj" { t.Errorf("parseLsEntry(%q) date = %q (len %d), want %q at 12",
t.Errorf("lsEntry(symlink) = %q, want %q", got, "Sep 28 2016 myproj") c.line, e.date, len(e.date), c.date)
} }
// 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)
} }
} }
+5
View File
@@ -42,6 +42,11 @@ gitpath = /home/git
# #
# mirror = true # `push` also mirrors via pushremote # mirror = true # `push` also mirrors via pushremote
# --- safety ---
# `push` checks the staged diff for private keys and API tokens before it
# commits, and asks before continuing. Only an explicit "off" disables it.
# secretscan = off
# --- per-project overrides --- # --- per-project overrides ---
# A <project>/.mgshrc overrides all of the above for that project only, except # A <project>/.mgshrc overrides all of the above for that project only, except
# base, gitname, gitemail and pushdefault, which stay global. Typical use: # base, gitname, gitemail and pushdefault, which stay global. Typical use:
+224 -27
View File
@@ -1,13 +1,23 @@
package main package main
// overview.go — the `overview` command (also reachable as `status -a`): a // overview.go — the `overview` command (also reachable as `status -a`).
// one-line-per-project summary of every git project under BASE, showing the //
// dirty state and how far each branch is ahead/behind its upstream. // mgsh is the only thing that sees all three places a project can live: the
// local base directory, the internal ssh server, and the public mirrors. Joining
// those answers the questions plain git cannot — which projects were never
// pushed to the server, which exist there but not on this machine, and which
// machine last touched each one (every `push` stamps "[user@host]" into the
// commit message, so that comes for free).
import ( import (
"fmt" "fmt"
"os" "os"
"regexp"
"sort"
"strconv"
"strings" "strings"
"sync"
"time"
) )
// projStatus is the collected state of one project for the overview. // projStatus is the collected state of one project for the overview.
@@ -17,9 +27,21 @@ type projStatus struct {
dirty bool dirty bool
ahead, behind int ahead, behind int
hasUpstream bool hasUpstream bool
lastHost string // machine that made the last commit, from "[user@host]"
lastWhen time.Time // when that was
mirrors []string // configured mirror remotes present in this repo
} }
// overviewAll prints a status summary for all git projects under BASE. // commitHostRe pulls the host out of the "[user@host] subject" line that `push`
// writes, so the overview can say where a project was last worked on.
var commitHostRe = regexp.MustCompile(`^\[[^@\]]*@([^\]]+)\]`)
// overviewScanLimit bounds how many projects are inspected at once. The work is
// all subprocess latency, so some concurrency helps a lot and more does not.
const overviewScanLimit = 8
// overviewAll prints a status summary for all git projects under BASE, plus the
// projects that exist on only one side of the local/server divide.
func overviewAll() { func overviewAll() {
entries, err := os.ReadDir(BASE) entries, err := os.ReadDir(BASE)
if err != nil { if err != nil {
@@ -27,27 +49,41 @@ func overviewAll() {
return return
} }
var rows []projStatus // ask the server while the local tree is being walked
width := 0 type serverList struct {
names []string
err error
}
srvCh := make(chan serverList, 1)
go func() {
names, err := serverRepoNames()
srvCh <- serverList{names, err}
}()
var local, repos []string
for _, e := range entries { for _, e := range entries {
if !e.IsDir() || strings.HasPrefix(e.Name(), ".") { if !e.IsDir() || strings.HasPrefix(e.Name(), ".") {
continue continue
} }
dir := BASE + "/" + e.Name() local = append(local, e.Name())
if !isDir(dir + "/.git") { if isDir(BASE + "/" + e.Name() + "/.git") {
continue repos = append(repos, e.Name())
} }
rows = append(rows, projectStatus(e.Name(), dir)) }
if len(e.Name()) > width {
width = len(e.Name()) rows := scanProjects(repos)
srv := <-srvCh
width := 0
for _, n := range repos {
if len(n) > width {
width = len(n)
} }
} }
if len(rows) == 0 { if len(rows) == 0 {
fmt.Println(col(cGray, "no git projects under "+BASE)) fmt.Println(col(cGray, "no git projects under "+BASE))
return
} }
dirtyN, syncN := 0, 0 dirtyN, syncN := 0, 0
for _, r := range rows { for _, r := range rows {
fmt.Println(formatProjStatus(r, width)) fmt.Println(formatProjStatus(r, width))
@@ -58,27 +94,156 @@ func overviewAll() {
syncN++ syncN++
} }
} }
fmt.Printf("%s\n", col(cGray, fmt.Sprintf("%d projects · %d dirty · %d in sync", len(rows), dirtyN, syncN))) if len(rows) > 0 {
fmt.Println(col(cGray, fmt.Sprintf("%d projects · %d dirty · %d in sync",
len(rows), dirtyN, syncN)))
}
reportInventory(local, srv.names, srv.err)
}
// 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
}
// reportInventory names the projects that live on only one side: local ones the
// server has never seen (candidates for `init`) and server repositories missing
// here (candidates for `clone`).
func reportInventory(local, server []string, err error) {
if err != nil {
fmt.Println(col(cGray, " git server not reachable — local view only"))
return
}
have := map[string]bool{}
for _, n := range local {
have[n] = true
}
onServer := map[string]bool{}
for _, n := range server {
onServer[n] = true
}
var missingRemote, missingLocal []string
for _, n := range local {
if !onServer[n] {
missingRemote = append(missingRemote, n)
}
}
for _, n := range server {
if !have[n] {
missingLocal = append(missingLocal, n)
}
}
sort.Strings(missingRemote)
sort.Strings(missingLocal)
if len(missingRemote) > 0 {
fmt.Printf("%s %s\n", col(cYellow, " not on the git server:"),
strings.Join(missingRemote, ", ")+col(cGray, " (init)"))
}
if len(missingLocal) > 0 {
fmt.Printf("%s %s\n", col(cCyan, " not cloned here: "),
strings.Join(missingLocal, ", ")+col(cGray, " (clone)"))
}
} }
// projectStatus gathers the git state of a single project directory. // projectStatus gathers the git state of a single project directory.
func projectStatus(name, dir string) projStatus { func projectStatus(name, dir string) projStatus {
s := projStatus{name: name, branch: "-"} s := projStatus{name: name, branch: "-"}
if out, err := gitCapture(dir, "rev-parse", "--abbrev-ref", "HEAD"); err == nil { readStatus(&s, dir)
s.branch = strings.TrimSpace(out) readLastCommit(&s, dir)
} s.mirrors = configuredMirrors(dir)
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
}
}
return s return s
} }
// 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
}
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], "-"))
}
}
}
}
// 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
}
// formatProjStatus renders one aligned overview row. // formatProjStatus renders one aligned overview row.
func formatProjStatus(s projStatus, width int) string { func formatProjStatus(s projStatus, width int) string {
var marks []string var marks []string
@@ -104,5 +269,37 @@ func formatProjStatus(s projStatus, width int) string {
if s.branch != "master" && s.branch != "main" && s.branch != "-" { if s.branch != "master" && s.branch != "main" && s.branch != "-" {
line += col(cGray, " ("+s.branch+")") line += col(cGray, " ("+s.branch+")")
} }
var tail []string
if s.lastHost != "" || !s.lastWhen.IsZero() {
t := s.lastHost
if !s.lastWhen.IsZero() {
if t != "" {
t += " "
}
t += shortAge(time.Since(s.lastWhen))
}
tail = append(tail, t)
}
if len(s.mirrors) > 0 {
tail = append(tail, "→ "+strings.Join(s.mirrors, ","))
}
if len(tail) > 0 {
line += col(cGray, " · "+strings.Join(tail, " · "))
}
return line return line
} }
// 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))
}
}
+152
View File
@@ -0,0 +1,152 @@
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)
}
}
}
// TestReportInventoryUnreachableServer: when the server cannot be listed, the
// overview must say so rather than claim every project is missing there.
func TestReportInventoryUnreachableServer(t *testing.T) {
out := captureStdout(t, func() {
reportInventory([]string{"a", "b"}, nil, errors.New("network is unreachable"))
})
if strings.Contains(out, "not on the git server") {
t.Errorf("an unreachable server was reported as missing repositories: %q", out)
}
if !strings.Contains(out, "not reachable") {
t.Errorf("no hint that the server was unreachable: %q", out)
}
}
// TestReportInventorySplitsSides is the join that plain git cannot do.
func TestReportInventorySplitsSides(t *testing.T) {
out := captureStdout(t, func() {
reportInventory([]string{"both", "onlyhere"}, []string{"both", "onlythere"}, nil)
})
if !strings.Contains(out, "not on the git server:") || !strings.Contains(out, "onlyhere") {
t.Errorf("local-only project not reported: %q", out)
}
if !strings.Contains(out, "not cloned here:") || !strings.Contains(out, "onlythere") {
t.Errorf("server-only project not reported: %q", out)
}
if strings.Contains(out, "both") {
t.Errorf("a project present on both sides should not be listed: %q", out)
}
}
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
}
+206
View File
@@ -0,0 +1,206 @@
package main
// secrets.go — a last look at what `push` is about to commit.
//
// `push` runs `git add --all .`, so anything lying in the project — an .env, a
// stray key file, a token pasted into a config — is committed and pushed, and
// with `mirror = true` it reaches a *public* server in the same breath. That is
// the one action in mgsh that cannot be undone: a deleted server repository can
// come back from an archive, a published credential is burnt.
//
// So the staged diff is scanned for a small set of high-signal patterns before
// the commit is made. This is not a complete secret scanner and does not try to
// be one; it aims for a high hit rate on the things that actually leak, with
// few enough false alarms that the prompt still means something. Turn it off
// with `secretscan = off`.
import (
"fmt"
"regexp"
"strconv"
"strings"
)
// secretHit is one suspicious added line.
type secretHit struct {
file string
lineNo int
kind string
text string
}
// secretPattern matches one kind of credential. `certain` patterns are
// unmistakable and are reported as they are; the others match a shape that
// merely looks like a secret and are filtered through looksLikePlaceholder.
type secretPattern struct {
kind string
re *regexp.Regexp
certain bool
}
var secretPatterns = []secretPattern{
{"private key", regexp.MustCompile(`-----BEGIN (?:[A-Z]+ )?PRIVATE KEY-----`), true},
{"GitHub token", regexp.MustCompile(`\bgh[pousr]_[A-Za-z0-9]{20,}`), true},
{"GitLab token", regexp.MustCompile(`\bglpat-[A-Za-z0-9_-]{16,}`), true},
{"AWS access key", regexp.MustCompile(`\b(?:AKIA|ASIA)[0-9A-Z]{16}\b`), true},
{"Slack token", regexp.MustCompile(`\bxox[baprs]-[A-Za-z0-9-]{10,}`), true},
{"PyPI token", regexp.MustCompile(`\bpypi-AgEIcHlwaS5vcmc[A-Za-z0-9_-]{10,}`), true},
{"credential assignment", regexp.MustCompile(
`(?i)\b(?:password|passwd|secret|api[_-]?key|apikey|access[_-]?key|auth[_-]?token|token)\b` +
`\s*[:=]\s*(?:"([^"\s]{12,})"|'([^'\s]{12,})'|([^\s"';,]{20,}))\s*;?\s*$`), false},
}
var (
diffFileRe = regexp.MustCompile(`^\+\+\+ b/(.*)$`)
diffHunkRe = regexp.MustCompile(`^@@ -\d+(?:,\d+)? \+(\d+)`)
// a value that is plainly a reference or a stand-in, not a credential
constRefRe = regexp.MustCompile(`^[A-Z][A-Z0-9_]*$`)
dottedRefRe = regexp.MustCompile(`^[\w-]+(?:\.[\w-]+)+$`)
maskedRe = regexp.MustCompile(`^[*x•.]+$`)
)
// placeholderWords are the values people write when they mean "fill this in".
var placeholderWords = map[string]bool{
"changeme": true, "change_me": true, "password": true, "secret": true,
"token": true, "your_token": true, "your-token": true, "yourtoken": true,
"todo": true, "none": true, "null": true, "example": true, "redacted": true,
}
// looksLikePlaceholder reports whether a matched value is obviously not a real
// credential: a template slot, an environment reference, a constant name, or a
// masked stand-in. Documentation and example files are full of these, and every
// one of them that reaches the prompt makes the prompt worth less.
func looksLikePlaceholder(v string) bool {
v = strings.Trim(v, `"'`)
if v == "" {
return true
}
if strings.ContainsAny(v, "<>${}()") { // <token>, ${VAR}, $(cmd), {{ tpl }}
return true
}
if maskedRe.MatchString(v) || constRefRe.MatchString(v) || dottedRefRe.MatchString(v) {
return true
}
if placeholderWords[strings.ToLower(v)] {
return true
}
// a value made of one repeated character carries no information
if strings.Count(v, string(v[0])) == len(v) {
return true
}
return false
}
// scanDiff finds suspicious added lines in a unified diff. Only added lines are
// examined: removing a secret is what we want people to do.
func scanDiff(diff string) []secretHit {
var hits []secretHit
file := ""
lineNo := 0
for _, ln := range strings.Split(diff, "\n") {
switch {
case strings.HasPrefix(ln, "+++ "):
file = ""
if m := diffFileRe.FindStringSubmatch(ln); m != nil {
file = m[1]
}
continue
case strings.HasPrefix(ln, "@@"):
if m := diffHunkRe.FindStringSubmatch(ln); m != nil {
lineNo, _ = strconv.Atoi(m[1])
}
continue
case strings.HasPrefix(ln, "---") || strings.HasPrefix(ln, "diff ") ||
strings.HasPrefix(ln, "index ") || strings.HasPrefix(ln, "new file") ||
strings.HasPrefix(ln, "deleted file") || strings.HasPrefix(ln, "similarity "):
continue
case strings.HasPrefix(ln, "-"):
continue // removed line: not our problem
case !strings.HasPrefix(ln, "+"):
lineNo++ // context line
continue
}
text := ln[1:]
if kind := matchSecret(text); kind != "" {
hits = append(hits, secretHit{file: file, lineNo: lineNo, kind: kind, text: text})
}
lineNo++
}
return hits
}
// allowMarker suppresses the check for one line. Any scanner needs a per-line
// escape: a project will eventually hold something credential-shaped on
// purpose, and switching the whole check off for that is far too blunt.
const allowMarker = "mgsh:allow"
// matchSecret returns the kind of credential a line appears to contain, or "".
func matchSecret(text string) string {
if strings.Contains(text, allowMarker) {
return ""
}
for _, p := range secretPatterns {
m := p.re.FindStringSubmatch(text)
if m == nil {
continue
}
if p.certain {
return p.kind
}
// the first non-empty capture group is the value that was assigned
value := ""
for _, g := range m[1:] {
if g != "" {
value = g
break
}
}
if !looksLikePlaceholder(value) {
return p.kind
}
}
return ""
}
// secretScanEnabled reports whether the scan runs. It is on unless explicitly
// switched off, so a typo in the setting leaves the safety net in place.
func secretScanEnabled() bool { return !falsy(cfg.SecretScan) }
// secretsApproved scans what `push` has staged. With nothing suspicious found
// it returns true silently; otherwise it shows the findings and asks. Returns
// false when the push should stop.
func secretsApproved(dir string) bool {
if !secretScanEnabled() {
return true
}
diff, err := gitCapture(dir, "diff", "--cached", "-U0", "--no-color")
if err != nil {
return true // nothing staged, or no HEAD yet: not our call to block
}
hits := scanDiff(diff)
if len(hits) == 0 {
return true
}
fmt.Println(col(cRed, fmt.Sprintf("%d possible credential(s) in what is about to be committed:", len(hits))))
for _, h := range hits {
where := h.file
if h.lineNo > 0 {
where += ":" + strconv.Itoa(h.lineNo)
}
fmt.Printf(" %s %s\n %s\n",
col(cYellow, where), col(cGray, h.kind), col(cRed, ellipsis(strings.TrimSpace(h.text), 100)))
}
fmt.Println(col(cGray, " (set 'secretscan = off' to skip this check)"))
return yesno("push anyway?", false)
}
// ellipsis shortens s to at most n characters.
func ellipsis(s string, n int) string {
if len(s) <= n {
return s
}
return s[:n-1] + "…"
}
+212
View File
@@ -0,0 +1,212 @@
package main
import (
"os"
"path/filepath"
"strings"
"testing"
)
// TestMatchSecretCatchesRealCredentials: the shapes that actually leak.
func TestMatchSecretCatchesRealCredentials(t *testing.T) {
lines := []string{
"-----BEGIN OPENSSH PRIVATE KEY-----",
"-----BEGIN RSA PRIVATE KEY-----",
" ghp_aB3dEfGh1jKlMn0pQrStUvWxYz012345678",
"GITLAB=glpat-aB3dEfGh1jKlMn0pQrSt",
`aws_access_key_id = AKIAIOSFODNN7EXAMPLE`,
"slack: xoxb-1234567890-abcdefghij",
`API_KEY="s3cr3tV4lu3W1thStuff"`,
"password = hunter2hunter2hunter2",
"token: 'aB3dEfGh1jKlMn0pQrSt'",
"auth-token=9f8e7d6c5b4a39281706abcdef123456",
}
for _, ln := range lines {
if matchSecret(ln) == "" {
t.Errorf("missed a credential in %q", ln)
}
}
}
// TestMatchSecretIgnoresNoise: everyday code and documentation must not trip
// the prompt, or people learn to answer "yes" without reading it.
func TestMatchSecretIgnoresNoise(t *testing.T) {
lines := []string{
"remotekey = <personal-access-token>", // our own README
"# remotekey = <personal-access-token>", // and mgshrc.example
"token = process.env.GITHUB_TOKEN", // reference, not a value
"const token = getToken()", // call
"password = ${DB_PASSWORD}", // template
`api_key = "changeme"`, // placeholder
"secret: TODO", //
"key gh***************xk", // masked, from `config`
"password = xxxxxxxxxxxxxxxxxxxxxxx", // masked
"// the token is never persisted in the repo", // prose
"apiKey := os.Getenv(\"MGSH_REMOTEKEY\")", // lookup
"token = SOME_CONSTANT_NAME", // constant
"secret = my.config.value", // dotted reference
"+++ b/token.go", // diff furniture
"password = short", // too short to be one
"Authorization: Basic <base64(owner:token)>", // documentation
"remote.hub.key = <personal-access-token>",
}
for _, ln := range lines {
if kind := matchSecret(ln); kind != "" {
t.Errorf("false positive (%s) on %q", kind, ln)
}
}
}
// TestScanDiffReportsFileAndLine: the report has to point at the right place,
// and must ignore removed lines — deleting a secret is the desired action.
func TestScanDiffReportsFileAndLine(t *testing.T) {
diff := `diff --git a/.env b/.env
new file mode 100644
--- /dev/null
+++ b/.env
@@ -0,0 +1,3 @@
+HOME=/tmp
+API_KEY="s3cr3tV4lu3W1thStuff"
+DEBUG=1
diff --git a/old.txt b/old.txt
--- a/old.txt
+++ b/old.txt
@@ -7,1 +7,0 @@
-password = hunter2hunter2hunter2
`
hits := scanDiff(diff)
if len(hits) != 1 {
t.Fatalf("expected exactly one hit, got %d: %+v", len(hits), hits)
}
h := hits[0]
if h.file != ".env" {
t.Errorf("file = %q, want .env", h.file)
}
if h.lineNo != 2 {
t.Errorf("lineNo = %d, want 2", h.lineNo)
}
if !strings.Contains(h.text, "API_KEY") {
t.Errorf("text = %q", h.text)
}
}
// TestScanDiffCountsLinesAcrossHunks keeps the line numbers honest when a file
// is edited in several places.
func TestScanDiffCountsLinesAcrossHunks(t *testing.T) {
diff := `+++ b/config.yml
@@ -1,0 +1,1 @@
+harmless: yes
@@ -40,0 +41,2 @@
+also fine
+aws_key = AKIAIOSFODNN7EXAMPLE
`
hits := scanDiff(diff)
if len(hits) != 1 || hits[0].lineNo != 42 {
t.Fatalf("hits = %+v, want one at line 42", hits)
}
}
// TestSecretsApprovedBlocksThePush drives the real thing: a staged .env, the
// scan, and the answer deciding whether push continues.
func TestSecretsApprovedBlocksThePush(t *testing.T) {
dir := t.TempDir()
mustGit(t, dir, "init", "-q")
mustGit(t, dir, "config", "user.name", "t")
mustGit(t, dir, "config", "user.email", "t@e")
mustGit(t, dir, "commit", "-q", "--allow-empty", "-m", "base")
old := cfg
defer func() { cfg = old }()
cfg = Config{}
// clean tree: no prompt, no interference
asked := fakeAnswers(t, false)
if !secretsApproved(dir) {
t.Fatal("a clean tree must not block the push")
}
if len(*asked) != 0 {
t.Fatalf("asked about a clean tree: %v", *asked)
}
if err := os.WriteFile(filepath.Join(dir, ".env"),
[]byte("API_KEY=\"s3cr3tV4lu3W1thStuff\"\n"), 0600); err != nil {
t.Fatal(err)
}
mustGit(t, dir, "add", "--all", ".")
declined := fakeAnswers(t, false)
if secretsApproved(dir) {
t.Error("a staged credential must stop the push when declined")
}
if len(*declined) == 0 {
t.Error("the user was never asked")
}
accepted := fakeAnswers(t, true)
if !secretsApproved(dir) {
t.Error("an explicit yes must let the push through")
}
if len(*accepted) == 0 {
t.Error("the user was never asked")
}
// and the escape hatch really switches it off
cfg.SecretScan = "off"
never := fakeAnswers(t, false)
if !secretsApproved(dir) {
t.Error("secretscan = off must not block")
}
if len(*never) != 0 {
t.Errorf("secretscan = off still asked: %v", *never)
}
}
// TestSecretScanDefaultsToOn: only a deliberate "off" disables it, so a typo
// leaves the safety net in place.
func TestSecretScanDefaultsToOn(t *testing.T) {
old := cfg
defer func() { cfg = old }()
for _, v := range []string{"", "true", "on", "yes", "wharrgarbl", "1"} {
cfg = Config{SecretScan: v}
if !secretScanEnabled() {
t.Errorf("secretscan = %q disabled the scan", v)
}
}
for _, v := range []string{"off", "0", "false", "no", " OFF "} {
cfg = Config{SecretScan: v}
if secretScanEnabled() {
t.Errorf("secretscan = %q did not disable the scan", v)
}
}
}
// TestOwnDocsDoNotTripTheScanner: mgsh's own README and example config are full
// of credential-shaped text; committing mgsh itself must stay quiet.
func TestOwnDocsDoNotTripTheScanner(t *testing.T) {
for _, f := range []string{"README.md", "mgshrc.example"} {
data, err := os.ReadFile(f)
if err != nil {
t.Fatal(err)
}
for i, ln := range strings.Split(string(data), "\n") {
if kind := matchSecret(ln); kind != "" {
t.Errorf("%s:%d would trip the scanner (%s): %q", f, i+1, kind, ln)
}
}
}
}
// TestAllowMarkerSuppressesOneLine: the per-line escape for something that only
// looks like a credential and is meant to stay.
func TestAllowMarkerSuppressesOneLine(t *testing.T) {
line := `API_KEY="s3cr3tV4lu3W1thStuff"`
if matchSecret(line) == "" {
t.Fatal("test line is not detected at all")
}
if kind := matchSecret(line + " # mgsh:allow — sample value"); kind != "" {
t.Errorf("mgsh:allow did not suppress the hit (%s)", kind)
}
if kind := matchSecret("-----BEGIN OPENSSH PRIVATE KEY----- mgsh:allow"); kind != "" {
t.Errorf("mgsh:allow did not suppress a certain pattern (%s)", kind)
}
}
+2 -1
View File
@@ -44,6 +44,7 @@ func showConfig() {
{"pushdefault", cfg.PushDefault}, {"pushdefault", cfg.PushDefault},
{"editor", cfg.Editor}, {"editor", cfg.Editor},
{"mirror", cfg.Mirror}, {"mirror", cfg.Mirror},
{"secretscan", cfg.SecretScan},
{"remotes", cfg.RemoteNames}, {"remotes", cfg.RemoteNames},
} }
@@ -147,7 +148,7 @@ func configKeys() []string {
"base", "githost", "gitport", "gituser", "gitpath", "gitkey", "base", "githost", "gitport", "gituser", "gitpath", "gitkey",
"gitname", "gitemail", "pushdefault", "editor", "gitname", "gitemail", "pushdefault", "editor",
"remoteurl", "remotekey", "remotetype", "remotevisibility", "remoteurl", "remotekey", "remotetype", "remotevisibility",
"remotes", "mirror", "remotes", "mirror", "secretscan",
} }
sort.Strings(keys) sort.Strings(keys)
return keys return keys
+11
View File
@@ -39,6 +39,17 @@ func truthy(s string) bool {
return false return false
} }
// falsy reports whether a config string explicitly means "off". It is not the
// negation of truthy: for a setting that defaults to on, an unset value or a
// typo must leave it on, and only a deliberate "off" may switch it off.
func falsy(s string) bool {
switch strings.ToLower(strings.TrimSpace(s)) {
case "0", "false", "no", "off":
return true
}
return false
}
func fileExists(p string) bool { func fileExists(p string) bool {
fi, err := os.Stat(p) fi, err := os.Stat(p)
return err == nil && !fi.IsDir() return err == nil && !fi.IsDir()
+1 -1
View File
@@ -1 +1 @@
4.0.24 4.0.29