diff --git a/README.md b/README.md index 859ff16..5ee8701 100644 --- a/README.md +++ b/README.md @@ -7,6 +7,7 @@ directory. Go port of the original Perl `mgsh` (`mgsh.perl`). ## Contents - [Build](#build) · [Usage](#usage) · [Commands](#commands) · [Aliases](#aliases) +- [Overview](#overview) · [Credential check](#credential-check) - [Public mirror (`pushremote`)](#public-mirror-pushremote) · [Releases](#releases) - [Configuration](#configuration) · [Settings reference](#settings-reference) · @@ -80,7 +81,7 @@ Run `help` for the full list. Highlights: | `pushremote [desc]` | mirror the repo to a public server (gitea/github/gitlab) | | `pull` / `fetch` | pull / fetch from the server | | `status [-a]` / `diff` | short git status (`-a`: overview of all projects) | -| `overview` | dirty / ahead-behind summary of all projects | +| `overview` | inventory of all projects, local and on the server | | `log` | show the project log | | `edit [n]` | interactive rebase of the last n commits | | `clone [-a] ` | clone a repository (or archive) from the server | @@ -125,6 +126,69 @@ pushremote targets (in push order): Tokens are masked, so the output is safe to paste into a bug report. `config -k` prints just the setting names, one per line. +### 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="" + deploy_key:1 private key + + (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 ` 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 (``, `${VAR}`) or masked stand-ins are ignored — +a test checks that mgsh's own README and `mgshrc.example`, both full of +credential-shaped text, stay quiet. Switch it off with `secretscan = off`. + ### Aliases `alias ''` defines a reusable shortcut, persisted to @@ -309,6 +373,7 @@ project `.mgshrc` may override the setting. | `remote..visibility` | project | visibility for that target | | `remotes` | project | comma- or space-separated list restricting and ordering the mirror targets | | `mirror` | project | truthy (`1`/`true`/`yes`/`on`) → every `push` also mirrors | +| `secretscan` | project | `off` disables the credential check `push` runs before committing (on by default; only an explicit `off` disables it) | The three settings written to the global git config are applied at startup, and only when they actually differ, so a plain `mgsh status` does not rewrite diff --git a/completion.go b/completion.go index bc261f6..231c898 100644 --- a/completion.go +++ b/completion.go @@ -82,18 +82,12 @@ func fetchServerRepos() { if serverFetched { return } - lines, err := sshOut("/bin/ls .") + repos, err := serverRepoNames() if err != nil { // a transient failure (server down, no network) must not cache an // empty list for the rest of the session — the next Tab tries again return } - var repos []string - for _, ln := range lines { - if m := gitDirRe.FindStringSubmatch(strings.TrimSpace(ln)); m != nil { - repos = append(repos, m[1]) - } - } // a missing ./archive is a permanent, unremarkable state: still cache var archives []string if lines, err := sshOut("/bin/ls archive"); err == nil { diff --git a/git.go b/git.go index 161084a..54371fb 100644 --- a/git.go +++ b/git.go @@ -152,6 +152,22 @@ func sshOut(remote string) ([]string, error) { return lines, err } +// serverRepoNames lists the bare repositories on the git server, without the +// ".git" suffix. +func serverRepoNames() ([]string, error) { + lines, err := sshOut("/bin/ls .") + if err != nil { + return nil, err + } + var out []string + for _, ln := range lines { + if m := gitDirRe.FindStringSubmatch(strings.TrimSpace(ln)); m != nil { + out = append(out, m[1]) + } + } + return out, nil +} + // serverEntryExists reports whether entry is present in the remote directory // path (relative to the git user's home). The error is returned rather than // folded into the bool so a failed lookup is never mistaken for "not there". diff --git a/overview.go b/overview.go index a5609f3..7e429fb 100644 --- a/overview.go +++ b/overview.go @@ -1,13 +1,23 @@ package main -// overview.go — the `overview` command (also reachable as `status -a`): a -// one-line-per-project summary of every git project under BASE, showing the -// dirty state and how far each branch is ahead/behind its upstream. +// overview.go — the `overview` command (also reachable as `status -a`). +// +// mgsh is the only thing that sees all three places a project can live: the +// local base directory, the internal ssh server, and the public mirrors. Joining +// those answers the questions plain git cannot — which projects were never +// pushed to the server, 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 ( "fmt" "os" + "regexp" + "sort" + "strconv" "strings" + "sync" + "time" ) // projStatus is the collected state of one project for the overview. @@ -17,9 +27,21 @@ type projStatus struct { dirty bool ahead, behind int 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() { entries, err := os.ReadDir(BASE) if err != nil { @@ -27,27 +49,41 @@ func overviewAll() { return } - var rows []projStatus - width := 0 + // ask the server while the local tree is being walked + type serverList struct { + names []string + err error + } + srvCh := make(chan serverList, 1) + go func() { + names, err := serverRepoNames() + srvCh <- serverList{names, err} + }() + + var local, repos []string for _, e := range entries { if !e.IsDir() || strings.HasPrefix(e.Name(), ".") { continue } - dir := BASE + "/" + e.Name() - if !isDir(dir + "/.git") { - continue + local = append(local, e.Name()) + if isDir(BASE + "/" + e.Name() + "/.git") { + 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 { fmt.Println(col(cGray, "no git projects under "+BASE)) - return } - dirtyN, syncN := 0, 0 for _, r := range rows { fmt.Println(formatProjStatus(r, width)) @@ -58,25 +94,154 @@ func overviewAll() { 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. func projectStatus(name, dir string) projStatus { s := projStatus{name: name, branch: "-"} - if out, err := gitCapture(dir, "rev-parse", "--abbrev-ref", "HEAD"); err == nil { - s.branch = strings.TrimSpace(out) + readStatus(&s, dir) + 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) != "" { - s.dirty = true - } - // left/right counts against the upstream: "\t" - 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 { + 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], "-")) + } } } - 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. @@ -104,5 +269,37 @@ func formatProjStatus(s projStatus, width int) string { if s.branch != "master" && s.branch != "main" && 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 } + +// 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)) + } +} diff --git a/overview_test.go b/overview_test.go new file mode 100644 index 0000000..37dd93f --- /dev/null +++ b/overview_test.go @@ -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 +} diff --git a/version.txt b/version.txt index 0227d4a..f5cd8d2 100644 --- a/version.txt +++ b/version.txt @@ -1 +1 @@ -4.0.24 +4.0.27