Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
8c68b28dc2 | ||
|
|
cad7a4ec2c | ||
|
|
59da8f376c |
@@ -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
|
||||||
|
|||||||
@@ -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)
|
var b strings.Builder
|
||||||
if len(parts) >= 4 {
|
for _, e := range entries {
|
||||||
date := strings.Join(parts[:3], " ")
|
fmt.Fprintf(&b, " %s %s", col(cGreen, padRight(e.name, width)), col(cYellow, e.date))
|
||||||
name := strings.Join(parts[3:], " ")
|
if withSize {
|
||||||
return col(cYellow, date) + " " + col(cGreen, name)
|
fmt.Fprintf(&b, " %s", col(cGray, fmt.Sprintf("%7s", humanSize(e.size))))
|
||||||
|
}
|
||||||
|
b.WriteByte('\n')
|
||||||
}
|
}
|
||||||
return col(cGreen, s)
|
return b.String()
|
||||||
|
}
|
||||||
|
|
||||||
|
// humanSize renders a byte count compactly, the way `ls -h` does: a decimal
|
||||||
|
// only while it still carries information, so "3.2M" but "512K".
|
||||||
|
func humanSize(n int64) string {
|
||||||
|
const unit = 1024
|
||||||
|
if n < unit {
|
||||||
|
return strconv.FormatInt(n, 10) + "B"
|
||||||
|
}
|
||||||
|
div, exp := int64(unit), 0
|
||||||
|
for v := n / unit; v >= unit && exp < 4; v /= unit {
|
||||||
|
div *= unit
|
||||||
|
exp++
|
||||||
|
}
|
||||||
|
v := float64(n) / float64(div)
|
||||||
|
if v < 10 {
|
||||||
|
return fmt.Sprintf("%.1f%c", v, "KMGTP"[exp])
|
||||||
|
}
|
||||||
|
return fmt.Sprintf("%.0f%c", v, "KMGTP"[exp])
|
||||||
}
|
}
|
||||||
|
|||||||
+59
-18
@@ -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
@@ -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 {
|
||||||
|
|||||||
@@ -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)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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".
|
||||||
|
|||||||
+93
-36
@@ -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)
|
||||||
|
}
|
||||||
|
// 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)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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:
|
||||||
|
|||||||
+221
-24
@@ -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,25 +94,154 @@ 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)
|
||||||
|
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
|
||||||
}
|
}
|
||||||
if out, err := gitCapture(dir, "status", "--porcelain"); err == nil && strings.TrimSpace(out) != "" {
|
for _, ln := range splitLines(out) {
|
||||||
s.dirty = true
|
if !strings.HasPrefix(ln, "# ") {
|
||||||
}
|
s.dirty = true // any entry line means the tree is not clean
|
||||||
// left/right counts against the upstream: "<behind>\t<ahead>"
|
continue
|
||||||
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 {
|
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
|
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], "-"))
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return s
|
}
|
||||||
|
|
||||||
|
// 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.
|
||||||
@@ -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))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -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
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
+2
-1
@@ -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
|
||||||
|
|||||||
@@ -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
@@ -1 +1 @@
|
|||||||
4.0.24
|
4.0.29
|
||||||
|
|||||||
Reference in New Issue
Block a user