6 Commits
Author SHA1 Message Date
mikeandClaude Opus 5 2a622046f2 Define mirror targets one way: remote.<name>.<field>
There were two spellings for the same thing -- a flat
remoteurl/remotekey/remotetype/remotevisibility set for a single server,
and remote.<name>.* blocks for several. The flat one is gone; every
target, including a lone one, is now a named block with the fields url,
key, type and visibility.

An existing ~/.mgshrc is converted on the next start. Only the key is
rewritten, so values, comments, alignment, commented-out lines and the
file's 0600 mode survive untouched, and mgsh prints each rename rather
than doing it quietly. The target is named "public", which is what the
old settings called the git remote they created, so a converted setup
keeps pushing to the same place under the same remote name. A file that
carries both spellings keeps what the new one says.

The environment follows the same shape: MGSH_REMOTEURL and friends are
replaced by MGSH_REMOTE_<NAME>_<FIELD>, so MGSH_REMOTE_GITLAB_KEY sets
remote.gitlab.key. The field is read from the end of the variable name,
which leaves target names free to contain underscores.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-26 18:00:26 +02:00
mikeandClaude Opus 5 61a7059f61 Fold the unpublished projects into the overview table
They were a list underneath the table, which meant reading the same
project names in two different shapes. They are rows now, with the action
in an "init" column that only appears when some row needs it, and they
sort to the bottom as their own group: an un-inited directory is a
different kind of task and should not push the daily ones down.

Every directory under the base gets a row, not just the repositories --
`init` is exactly what turns a plain directory into a project, so leaving
those out would have hidden the ones the column is for. Such a row has no
git state to show and costs no subprocesses either, since projectStatus
now checks for .git before running any.

The count line gained "N to init"; the projects count still counts
repositories, so the two numbers stay meaningful side by side. An
unreachable server marks nothing at all, as before.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-26 17:49:59 +02:00
mikeandClaude Opus 5 8ca05d6ad2 Drop the "not cloned here" line from overview
It answered a question `list` already answers, and it did so on every
run: the point of the overview is the state of the projects you have,
not a second listing of the server. reportInventory became
reportUnpublished and now reports one thing -- the local projects the
server has never seen, which are the ones `init` is for.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-26 17:45:02 +02:00
mikeandClaude Opus 5 cb1ff98c3d Lay overview out as a table
The status field was not a column: "*", "↑2", "✓" and "✓ (no upstream)"
are four different widths, so everything after them started somewhere
else on every line and the eye had to hunt along each row instead of
going down one.

Each field now has its own measured column: name (with the branch
appended when it is not master/main), a one-character dirty marker, the
sync state, host and age, then the mirrors. "(no upstream)" was fifteen
columns wide for something that is not even a problem, and is now "–".
Colour weights the row rather than decorating it -- a project that is
clean and in sync goes grey, the arrows and the dirty marker keep their
colour -- and the rows needing action sort to the top, alphabetically
within each group so positions stay predictable.

padRight counted bytes, which was fine while everything it padded was
ASCII; the arrows and check marks are three bytes and one column, so it
counts runes now.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-26 17:42:37 +02:00
mikeandClaude Opus 5 86fb898df4 Fix list breaking when the server's du fails
Two mistakes in the size support, reported from a real server.

The remote command used "2>/dev/null" to silence du. That is sh syntax,
and the git user's login shell need not be sh: in csh it parses as an
argument "2" followed by a redirection of stdout, so du was handed a
file named "2", complained, and exited non-zero. The redirection is
gone -- without it there is no bogus argument to trip over, and the
command now uses nothing that differs between sh and csh.

Worse, the exit status of the chain is the *last* command's, so that
failing du made sshOut return an error and `list` threw away a listing
that had arrived perfectly intact. It now reports a failure only when
nothing usable came back at all; a listing that parsed is shown whatever
the exit status, simply without the size column.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-26 17:35:50 +02:00
mikeandClaude Opus 5 3a420093d1 Show repository sizes in list
`list -a` had sizes because archives are files; repositories are
directories, and a long listing reports the inode size for those -- 4096
for every single one. Taking that number would have filled the column
with the same meaningless value, so the real disk usage is asked of `du`
instead, appended to the same remote command so it still costs one round
trip.

The column is dropped entirely when no usable sizes come back, rather
than showing a column of zeroes, so a server without a working `du`
degrades to the previous output. The summary line carries the total.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-26 17:30:12 +02:00
12 changed files with 805 additions and 247 deletions
+79 -56
View File
@@ -134,14 +134,20 @@ worked-on project sits closest to the prompt:
``` ```
< src > list < src > list
Betaflight3.0.0 Sep 28 2016 Betaflight3.0.0 Sep 28 2016 181M
website Mar 3 2024 website Mar 3 2024 2.1M
notes Jan 3 14:32 notes Jan 3 14:32 876K
3 repositories 3 repositories · 184M
``` ```
`list -a` lists the archives instead, with their sizes; a pattern filters by The size is the repository's real disk usage on the server, asked of `du` in
name (`list note`). the same round trip as the listing — a long listing reports the inode size for
a directory, which is the same number for every repository and says nothing. If
the server produces no usable sizes the column is left out rather than filled
with zeroes.
`list -a` lists the archives instead, whose sizes come from the listing itself;
a pattern filters by name (`list note`).
### Overview ### Overview
@@ -150,26 +156,45 @@ thing that sees the local base directory *and* the git server at once.
``` ```
< src > overview < src > overview
mgsh * ↑2 · desktop 3h · → hub,gitea notes * ↑2 laptop 3h
notes · laptop 2d website * ✓ desktop 2d → hub
website ✓ (no upstream) · laptop 20d Betaflight3.0.0 ✓ workstation 20d → gitea hub
3 projects · 1 dirty · 1 in sync experiments init
not on the git server: scratch, experiments (init) sandbox (wip) laptop 1h init
not cloned here: oldproject (clone) 4 projects · 2 dirty · 2 in sync · 2 to init
``` ```
Per project: dirty marker, commits ahead/behind the upstream, the branch when it Every field sits in its own column, so the eye can go down one instead of
is not `master`/`main`, and the mirror targets the repository has a remote for. hunting along each line. The projects that need something done come first, the
settled ones next, and the directories the git server does not have yet come
last — those are a different kind of task. Within each group the order stays
alphabetical, so positions do not jump around.
The machine and age come from the commit itself — `push` writes `[user@host]` | column | meaning |
into every message, so `overview` can say where a project was last worked on |---|---|
without storing anything. On a setup spanning a laptop and a workstation that is | name | the project, with its branch appended when it is not `master`/`main` |
usually the piece of information you actually wanted. | `*` | uncommitted changes |
| `↑n` `↓n` | commits ahead of / behind the upstream (`↑2↓1` when both) |
| `✓` | in sync with the upstream |
| `` | the branch tracks nothing — never pushed |
| `init` | the git server does not have this one; run `init` |
| host, age | who last committed and when, from the `[user@host]` stamp |
| `→` | mirror targets this repository has a remote for |
The two lists at the end are the join no git command can do: local projects the Every directory under the base gets a row, including those that are not
server has never seen (`init` them) and server repositories missing on this repositories at all — `init` is exactly what turns one into a project, so it
machine (`clone` them). If the server cannot be reached, mgsh says so instead of belongs in the table rather than in a list underneath it. Such a row simply has
claiming everything is missing. no git state to show.
The host and age come from the commit itself — `push` writes `[user@host]` into
every message, so `overview` can say where a project was last worked on without
storing anything. On a setup spanning a laptop and a workstation that is usually
the piece of information you actually wanted.
The `init` column is the join no git command can do. If the server cannot be
reached, no row is marked and mgsh says so — not knowing is not the same as
knowing they are missing. The other direction, repositories on the server that
are not here, is what `list` shows.
### Credential check ### Credential check
@@ -234,38 +259,37 @@ alias ec '!echo $1' # ec hello -> echo hello (shell)
Besides the internal ssh git server, `pushremote` mirrors the active project to Besides the internal ssh git server, `pushremote` mirrors the active project to
one or more public hosting servers (Gitea, GitHub or GitLab) over their REST one or more public hosting servers (Gitea, GitHub or GitLab) over their REST
API. A single server is configured flat: API. Each server is one `remote.<name>.<field>` block:
```ini ```ini
remoteurl = https://git.example.com # base URL of the server remote.gitea.url = https://git.example.com
remotekey = <personal-access-token> # API token remote.gitea.key = <personal-access-token>
# remotetype = gitea # optional; auto-detected from remoteurl remote.gitea.type = gitea # optional; auto-detected from the url
# remotevisibility = private # visibility of created repos (default private) remote.gitea.visibility = private # or public (default private)
# mirror = true # `push` also mirrors via pushremote
remote.gitlab.url = https://gitlab.example.com
remote.gitlab.key = <personal-access-token>
remote.gitlab.type = gitlab
remote.gitlab.visibility = public
# remotes = gitea, gitlab # optional: restrict and order the set
# mirror = true # `push` also mirrors via pushremote
``` ```
Several servers get one `remote.<name>.*` block each: `<name>` is yours to pick; there is no other spelling. Older versions had a flat
`remoteurl`/`remotekey` pair for a single server — mgsh converts those to
```ini `remote.public.*` in place on the next start and says so, keeping the git remote
remote.gitea.url = https://git.example.com name those versions used.
remote.gitea.key = <personal-access-token>
remote.hub.url = https://github.com
remote.hub.key = <personal-access-token>
remote.hub.visibility = public
# remotes = gitea, hub # optional: restrict and order the set
```
| command | pushes to | | command | pushes to |
|------------------------|-----------------------------------------------| |------------------------|-----------------------------------------------|
| `pushremote` | every configured target, in order | | `pushremote` | every configured target, in order |
| `pushremote @hub` | only `hub` | | `pushremote @gitea` | only `gitea` |
| `pushremote @hub @gitea` | those two | | `pushremote @gitea @gitlab` | those two |
| `pushremote a fix` | every target, description "a fix" | | `pushremote a fix` | every target, description "a fix" |
Each target owns a git remote of the same name in the repository (the flat form Each target owns a git remote of the same name in the repository, so
uses `public`, as before), so `git push hub` keeps working outside mgsh. A `git push gitlab` keeps working outside mgsh. A
target that fails does not stop the others; with more than one target target that fails does not stop the others; with more than one target
`pushremote` prints an `n/m remotes updated` summary. `remotes = …` restricts `pushremote` prints an `n/m remotes updated` summary. `remotes = …` restricts
and orders the set, which is mostly useful in a project `.mgshrc` — see below. and orders the set, which is mostly useful in a project `.mgshrc` — see below.
@@ -279,9 +303,10 @@ The token is sent as a one-shot HTTP auth header: it is never written into the
repo's git config, and it reaches git through the environment rather than the repo's git config, and it reaches git through the environment rather than the
command line, so it does not show up in the process table. Because `~/.mgshrc` command line, so it does not show up in the process table. Because `~/.mgshrc`
then holds a credential, mgsh creates it mode `600` and warns at startup if an then holds a credential, mgsh creates it mode `600` and warns at startup if an
existing file is readable by others. The provider is auto-detected from `remoteurl` (`github.com` existing file is readable by others. The provider is auto-detected from the url
GitHub, `gitlab*` → GitLab, otherwise Gitea) and can be forced with (`github.com`GitHub, `gitlab*` → GitLab, otherwise Gitea) and can be forced
`remotetype`. Set `mirror = true` to have every `push` mirror automatically. with `remote.<name>.type`. Set `mirror = true` to have every `push` mirror
automatically.
### Releases ### Releases
@@ -365,8 +390,10 @@ alias co 'checkout $1'
### Settings reference ### Settings reference
Every setting can also be given as an environment variable named `MGSH_<KEY>` Every setting can also be given as an environment variable named `MGSH_<KEY>`
(e.g. `MGSH_GITHOST`), which wins over both files. "Scope" says whether a (e.g. `MGSH_GITHOST`), which wins over both files; a mirror field is
project `.mgshrc` may override the setting. `MGSH_REMOTE_<NAME>_<FIELD>`, so `MGSH_REMOTE_GITLAB_KEY` sets
`remote.gitlab.key`. "Scope" says whether a project `.mgshrc` may override the
setting.
| setting | scope | meaning | | setting | scope | meaning |
|---|---|---| |---|---|---|
@@ -380,14 +407,10 @@ project `.mgshrc` may override the setting.
| `gitemail` | global | `user.email` written to the global git config | | `gitemail` | global | `user.email` written to the global git config |
| `pushdefault` | global | `push.default` written to the global git config | | `pushdefault` | global | `push.default` written to the global git config |
| `editor` | project | opener used by `open`/`view` when the project has no Xcode workspace (default `coda`) | | `editor` | project | opener used by `open`/`view` when the project has no Xcode workspace (default `coda`) |
| `remoteurl` | project | base URL of a single mirror server (target name `public`) | | `remote.<name>.url` | project | base URL of the mirror target `<name>` |
| `remotekey` | project | API token for `remoteurl` |
| `remotetype` | project | `gitea`\|`github`\|`gitlab`; auto-detected from the URL when unset |
| `remotevisibility` | project | `private` (default) or `public` for repositories created by `pushremote` |
| `remote.<name>.url` | project | base URL of the named mirror target |
| `remote.<name>.key` | project | API token for that target | | `remote.<name>.key` | project | API token for that target |
| `remote.<name>.type` | project | provider override for that target | | `remote.<name>.type` | project | `gitea`\|`github`\|`gitlab`; auto-detected from the url when unset |
| `remote.<name>.visibility` | project | visibility for that target | | `remote.<name>.visibility` | project | `private` (default) or `public` for repositories `pushremote` creates |
| `remotes` | project | comma- or space-separated list restricting and ordering the mirror targets | | `remotes` | project | comma- or space-separated list restricting and ordering the mirror targets |
| `mirror` | project | truthy (`1`/`true`/`yes`/`on`) → every `push` also mirrors | | `mirror` | project | truthy (`1`/`true`/`yes`/`on`) → every `push` also mirrors |
| `secretscan` | project | `off` disables the credential check `push` runs before committing (on by default; only an explicit `off` disables it) | | `secretscan` | project | `off` disables the credential check `push` runs before committing (on by default; only an explicit `off` disables it) |
+6 -3
View File
@@ -4,6 +4,7 @@ import (
"fmt" "fmt"
"strconv" "strconv"
"strings" "strings"
"unicode/utf8"
) )
// Colors for the prompt, banner and output, using the Catppuccin Mocha palette // Colors for the prompt, banner and output, using the Catppuccin Mocha palette
@@ -36,10 +37,12 @@ func errorln(msg string) {
fmt.Println(col(cRed, msg)) fmt.Println(col(cRed, msg))
} }
// padRight pads an ASCII string with trailing spaces to width n. // padRight pads s with trailing spaces to a width of n columns. It counts
// runes, not bytes: the overview pads fields holding ↑ ↓ ✓, each of which is
// one column wide but three bytes long.
func padRight(s string, n int) string { func padRight(s string, n int) string {
if len(s) < n { if l := utf8.RuneCountInString(s); l < n {
return s + strings.Repeat(" ", n-len(s)) return s + strings.Repeat(" ", n-l)
} }
return s return s
} }
+72 -7
View File
@@ -24,6 +24,45 @@ var (
wsRe = regexp.MustCompile(`\s+`) wsRe = regexp.MustCompile(`\s+`)
) )
// listMarker separates the two sections of the combined listing command, so
// `list` gets both the long listing and the disk usage in one round trip.
const listMarker = "---mgsh---"
// duRe matches one `du -sk` line: kilobytes, then the path.
var duRe = regexp.MustCompile(`^(\d+)\s+(.*)$`)
// splitAtMarker divides the remote output into the part before and after the
// marker line. Everything is in the first section when the marker is absent —
// which is what happens when only a plain listing was asked for.
func splitAtMarker(lines []string, marker string) (before, after []string) {
for i, ln := range lines {
if strings.TrimSpace(ln) == marker {
return lines[:i], lines[i+1:]
}
}
return lines, nil
}
// parseDuSizes turns `du -sk` output into a name -> bytes map. A long listing
// reports the inode size for a directory — the same number for every bare
// repository — so this is the only way to say how large one actually is.
// A symlinked repository reports the size of the link, not of its target.
func parseDuSizes(lines []string) map[string]int64 {
out := map[string]int64{}
for _, ln := range lines {
m := duRe.FindStringSubmatch(strings.TrimRight(ln, "\r"))
if m == nil {
continue
}
kb, err := strconv.ParseInt(m[1], 10, 64)
if err != nil {
continue
}
out[strings.TrimPrefix(strings.TrimSpace(m[2]), "./")] = kb * 1024
}
return out
}
// lsEntry is one parsed entry of the server's listing. // lsEntry is one parsed entry of the server's listing.
type lsEntry struct { type lsEntry struct {
name string // with the ".git" / ".git.tar.gz" suffix removed name string // with the ".git" / ".git.tar.gz" suffix removed
@@ -203,22 +242,42 @@ func runCommandDepth(line string, depth int) bool {
one, many = "archive", "archives" one, many = "archive", "archives"
} }
pat := strings.ToLower(word(words, 1)) pat := strings.ToLower(word(words, 1))
lines, err := sshOut("/bin/ls -ltr " + shq(path)) remote := "/bin/ls -ltr " + shq(path)
if err != nil { if !opt["a"] {
errorln("could not list " + many + " on the git server") // archives are files and carry a real size; repositories are
break // directories, whose listed size is the inode's, so ask du in the
// same round trip. Nothing shell-specific here on purpose: the
// login shell may be csh, where "2>/dev/null" is not a redirection
// but an argument followed by one.
remote += "; echo " + shq(listMarker) + "; du -sk *.git"
} }
lines, err := sshOut(remote)
lsLines, duLines := splitAtMarker(lines, listMarker)
sizes := parseDuSizes(duLines)
var entries []lsEntry var entries []lsEntry
for _, ln := range lines { var total int64
for _, ln := range lsLines {
e, ok := parseLsEntry(ln, suffix) e, ok := parseLsEntry(ln, suffix)
// the pattern filters the name, not the whole listing line — an // the pattern filters the name, not the whole listing line — an
// accidental match on the date or the owner helps nobody // accidental match on the date or the owner helps nobody
if !ok || (pat != "" && !strings.Contains(strings.ToLower(e.name), pat)) { if !ok || (pat != "" && !strings.Contains(strings.ToLower(e.name), pat)) {
continue continue
} }
if !opt["a"] {
e.size = sizes[e.name+suffix] // 0 when du said nothing
}
total += e.size
entries = append(entries, e) entries = append(entries, e)
} }
// The exit status belongs to the last command in the chain, so a `du`
// that fails must not discard a listing that arrived intact. Only
// complain when nothing usable came back at all.
if len(entries) == 0 { if len(entries) == 0 {
if err != nil {
errorln("could not list " + many + " on the git server")
break
}
what := "no " + many + " on the git server" what := "no " + many + " on the git server"
if pat != "" { if pat != "" {
what = "no " + many + " matching '" + word(words, 1) + "'" what = "no " + many + " matching '" + word(words, 1) + "'"
@@ -226,12 +285,18 @@ func runCommandDepth(line string, depth int) bool {
fmt.Println(col(cGray, what)) fmt.Println(col(cGray, what))
break break
} }
fmt.Print(formatRepoList(entries, opt["a"])) // no size column when the server gave no usable sizes, rather than a
// column of zeroes
fmt.Print(formatRepoList(entries, total > 0))
label := many label := many
if len(entries) == 1 { if len(entries) == 1 {
label = one label = one
} }
fmt.Println(col(cGray, fmt.Sprintf("%d %s", len(entries), label))) summary := fmt.Sprintf("%d %s", len(entries), label)
if total > 0 {
summary += " · " + humanSize(total)
}
fmt.Println(col(cGray, summary))
case "show": // show a repository's log directly on the server case "show": // show a repository's log directly on the server
prj := PRJ prj := PRJ
+141 -30
View File
@@ -6,6 +6,7 @@ import (
"os" "os"
"path/filepath" "path/filepath"
"regexp" "regexp"
"slices"
"sort" "sort"
"strings" "strings"
) )
@@ -33,10 +34,6 @@ type Config struct {
GitEmail string // git user.email to set globally ("" = leave alone) GitEmail string // git user.email to set globally ("" = leave alone)
PushDefault string // git push.default to set globally ("" = leave alone) PushDefault string // git push.default to set globally ("" = leave alone)
Editor string // editor/opener used as fallback by `open` ("" = coda) Editor string // editor/opener used as fallback by `open` ("" = coda)
RemoteURL string // public mirror server base URL (Gitea/GitHub/GitLab)
RemoteKey string // API token for the mirror server (used by `pushremote`)
RemoteType string // "gitea"|"github"|"gitlab" (auto-detected when empty)
RemoteVis string // visibility of created repos: "private" (default)|"public"
Mirror string // truthy -> `push` also mirrors via `pushremote` Mirror string // truthy -> `push` also mirrors via `pushremote`
SecretScan string // falsy -> `push` skips the credential scan SecretScan string // falsy -> `push` skips the credential scan
Remotes []RemoteTarget Remotes []RemoteTarget
@@ -54,22 +51,25 @@ type RemoteTarget struct {
Vis string // "private" (default) | "public" Vis string // "private" (default) | "public"
} }
// legacyRemoteName is the target name for the flat remoteurl/remotekey pair, // legacyRemoteName is the target the pre-4.1 flat remoteurl/remotekey settings
// matching the git remote that earlier versions created. // are migrated to. It matches the git remote those versions created, so a
// converted configuration keeps pushing to the same place.
const legacyRemoteName = "public" const legacyRemoteName = "public"
// legacyRemoteKeys maps the old flat spelling onto the named-target form. A
// mirror target is defined one way now, not two.
var legacyRemoteKeys = map[string]string{
"remoteurl": "remote." + legacyRemoteName + ".url",
"remotekey": "remote." + legacyRemoteName + ".key",
"remotetype": "remote." + legacyRemoteName + ".type",
"remotevisibility": "remote." + legacyRemoteName + ".visibility",
}
// mirrorTargets returns the usable mirror targets in configured order, plus the // mirrorTargets returns the usable mirror targets in configured order, plus the
// names of targets that are defined but unusable (missing url or key) so the // names of targets that are defined but unusable (missing url or key) so the
// caller can complain about them instead of silently skipping. // caller can complain about them instead of silently skipping.
func (c Config) mirrorTargets() (usable []RemoteTarget, incomplete []string) { func (c Config) mirrorTargets() (usable []RemoteTarget, incomplete []string) {
var all []RemoteTarget all := c.Remotes
if c.RemoteURL != "" || c.RemoteKey != "" {
all = append(all, RemoteTarget{
Name: legacyRemoteName, URL: c.RemoteURL, Key: c.RemoteKey,
Type: c.RemoteType, Vis: c.RemoteVis,
})
}
all = append(all, c.Remotes...)
// `remotes = a, b` narrows and orders the set — a project .mgshrc uses it // `remotes = a, b` narrows and orders the set — a project .mgshrc uses it
// to mirror to only some of the globally configured servers. // to mirror to only some of the globally configured servers.
@@ -147,11 +147,54 @@ func loadConfig() Config {
m := parseConfig(string(data)) m := parseConfig(string(data))
applyConfig(&c, m) applyConfig(&c, m)
warnConfigPerms(path, m) warnConfigPerms(path, m)
migrateRemoteKeys(path, string(data))
} }
applyEnv(&c) applyEnv(&c)
return c return c
} }
// migrateRemoteKeys converts the pre-4.1 flat remote settings in a config file
// to the remote.<name>.<field> spelling, so a mirror target is defined one way
// and not two. Only the key is rewritten: values, comments, blank lines and the
// file's permissions stay exactly as they are, and commented-out lines are left
// alone. Reports what it changed rather than doing it silently.
func migrateRemoteKeys(path, data string) {
lines := strings.Split(data, "\n")
var renamed []string
for i, ln := range lines {
trimmed := strings.TrimLeft(ln, " \t")
if trimmed == "" || strings.HasPrefix(trimmed, "#") {
continue
}
sep := strings.IndexAny(trimmed, "=:")
if sep < 0 {
continue
}
key := strings.TrimRight(trimmed[:sep], " \t")
dotted, ok := legacyRemoteKeys[strings.ToLower(key)]
if !ok {
continue
}
indent := ln[:len(ln)-len(trimmed)]
gap := trimmed[len(key):sep] // whatever alignment was there
lines[i] = indent + dotted + gap + trimmed[sep:]
renamed = append(renamed, key+" → "+dotted)
}
if len(renamed) == 0 {
return
}
if err := os.WriteFile(path, []byte(strings.Join(lines, "\n")), configMode); err != nil {
errorln("could not update " + path + ": " + err.Error())
return
}
fmt.Println(col(cGray, path+": mirror settings renamed to the remote.<name>.* form"))
for _, r := range renamed {
fmt.Println(col(cGray, " "+r))
}
}
// projectGlobalOnly lists settings a project-level .mgshrc must not change: // projectGlobalOnly lists settings a project-level .mgshrc must not change:
// `base` decides where projects live in the first place, and the git identity // `base` decides where projects live in the first place, and the git identity
// keys are written to the user's *global* git config at startup — applying // keys are written to the user's *global* git config at startup — applying
@@ -279,13 +322,15 @@ func writeConfigTemplate(path string) {
b.WriteString("# pushdefault = matching\n") b.WriteString("# pushdefault = matching\n")
b.WriteString("# editor = code\n\n") b.WriteString("# editor = code\n\n")
b.WriteString("# --- public mirrors for `pushremote` ---\n") b.WriteString("# --- public mirrors for `pushremote` ---\n")
b.WriteString("# One block per server; `pushremote` pushes to all of them,\n") b.WriteString("# One 'remote.<name>.*' block per server. `pushremote` pushes to all\n")
b.WriteString("# `pushremote @hub` to a single one.\n") b.WriteString("# of them, `pushremote @gitlab` to a single one. <name> is also the\n")
b.WriteString("# remote.hub.url = https://github.com\n") b.WriteString("# git remote created in the repository.\n")
b.WriteString("# remote.hub.key = <personal-access-token>\n") b.WriteString("# remote.gitlab.url = https://gitlab.example.com\n")
b.WriteString("# remote.hub.visibility = public\n") b.WriteString("# remote.gitlab.key = <personal-access-token>\n")
b.WriteString("# remotes = hub # optional: restrict/order the set\n") b.WriteString("# remote.gitlab.type = gitlab # optional; detected from the url\n")
b.WriteString("# mirror = true # `push` also mirrors\n\n") b.WriteString("# remote.gitlab.visibility = private # or public (default private)\n")
b.WriteString("# remotes = gitlab # optional: restrict/order the set\n")
b.WriteString("# mirror = true # `push` also mirrors\n\n")
b.WriteString("# A project may override any of these (except base and the git\n") b.WriteString("# A project may override any of these (except base and the git\n")
b.WriteString("# identity) in its own <project>/.mgshrc.\n") b.WriteString("# identity) in its own <project>/.mgshrc.\n")
@@ -365,14 +410,36 @@ func applyConfig(c *Config, m map[string]string) {
set("gitemail", &c.GitEmail) set("gitemail", &c.GitEmail)
set("pushdefault", &c.PushDefault) set("pushdefault", &c.PushDefault)
set("editor", &c.Editor) set("editor", &c.Editor)
set("remoteurl", &c.RemoteURL)
set("remotekey", &c.RemoteKey)
set("remotetype", &c.RemoteType)
set("remotevisibility", &c.RemoteVis)
set("remotes", &c.RemoteNames) set("remotes", &c.RemoteNames)
set("mirror", &c.Mirror) set("mirror", &c.Mirror)
set("secretscan", &c.SecretScan) set("secretscan", &c.SecretScan)
applyRemoteTargets(c, m) applyRemoteTargets(c, foldLegacyRemoteKeys(m))
}
// foldLegacyRemoteKeys rewrites the pre-4.1 flat remote settings into the
// named-target form, so a configuration that has not been converted yet still
// works while it is being read. The file itself is converted by
// migrateRemoteKeys; this only makes the current run behave.
func foldLegacyRemoteKeys(m map[string]string) map[string]string {
folded, copied := m, false
for old, dotted := range legacyRemoteKeys {
v, ok := m[old]
if !ok || v == "" {
continue
}
if _, taken := m[dotted]; taken {
continue // an explicit new-style setting always wins
}
if !copied { // copy on first write, never touch the caller's map
folded = make(map[string]string, len(m))
for k, val := range m {
folded[k] = val
}
copied = true
}
folded[dotted] = v
}
return folded
} }
// remoteFieldRe matches a named mirror target setting: remote.<name>.<field>. // remoteFieldRe matches a named mirror target setting: remote.<name>.<field>.
@@ -439,11 +506,55 @@ func applyEnv(c *Config) {
env("MGSH_GITEMAIL", &c.GitEmail) env("MGSH_GITEMAIL", &c.GitEmail)
env("MGSH_PUSHDEFAULT", &c.PushDefault) env("MGSH_PUSHDEFAULT", &c.PushDefault)
env("MGSH_EDITOR", &c.Editor) env("MGSH_EDITOR", &c.Editor)
env("MGSH_REMOTEURL", &c.RemoteURL)
env("MGSH_REMOTEKEY", &c.RemoteKey)
env("MGSH_REMOTETYPE", &c.RemoteType)
env("MGSH_REMOTEVISIBILITY", &c.RemoteVis)
env("MGSH_REMOTES", &c.RemoteNames) env("MGSH_REMOTES", &c.RemoteNames)
env("MGSH_MIRROR", &c.Mirror) env("MGSH_MIRROR", &c.Mirror)
env("MGSH_SECRETSCAN", &c.SecretScan) env("MGSH_SECRETSCAN", &c.SecretScan)
applyRemoteEnv(c)
}
// remoteFields are the settings a mirror target is made of.
var remoteFields = []string{"url", "key", "type", "visibility"}
// applyRemoteEnv reads MGSH_REMOTE_<NAME>_<FIELD>, the environment spelling of
// a remote.<name>.<field> setting — MGSH_REMOTE_GITLAB_KEY for
// remote.gitlab.key. The field is taken from the end, so a target name may
// contain underscores itself.
func applyRemoteEnv(c *Config) {
const prefix = "MGSH_REMOTE_"
// sorted, so a target these variables introduce lands in the push order the
// same way on every run
envs := os.Environ()
sort.Strings(envs)
for _, kv := range envs {
eq := strings.IndexByte(kv, '=')
if eq < 0 {
continue
}
name, value := kv[:eq], kv[eq+1:]
if value == "" || !strings.HasPrefix(name, prefix) {
continue
}
rest := name[len(prefix):]
us := strings.LastIndexByte(rest, '_')
if us <= 0 {
continue
}
target, field := strings.ToLower(rest[:us]), strings.ToLower(rest[us+1:])
if !slices.Contains(remoteFields, field) {
continue // MGSH_REMOTES and anything else that merely starts alike
}
t := &c.Remotes[c.remoteIndex(target)]
switch field {
case "url":
t.URL = value
case "key":
t.Key = value
case "type":
t.Type = value
case "visibility":
t.Vis = value
}
}
} }
+207 -3
View File
@@ -9,6 +9,7 @@ import (
"strings" "strings"
"testing" "testing"
"time" "time"
"unicode/utf8"
) )
func TestSanitizeComment(t *testing.T) { func TestSanitizeComment(t *testing.T) {
@@ -467,7 +468,7 @@ remote.broken.url = https://nowhere.example # no key -> unusable
} }
func TestMirrorTargetsLegacyAndSelection(t *testing.T) { func TestMirrorTargetsLegacyAndSelection(t *testing.T) {
// the flat remoteurl/remotekey pair stays supported, as target "public" // the pre-4.1 flat pair still loads, folded onto the target "public"
var c Config var c Config
applyConfig(&c, parseConfig("remoteurl = https://git.example.com\nremotekey = tok\n")) applyConfig(&c, parseConfig("remoteurl = https://git.example.com\nremotekey = tok\n"))
usable, _ := c.mirrorTargets() usable, _ := c.mirrorTargets()
@@ -806,6 +807,7 @@ func TestTruthy(t *testing.T) {
func TestFormatProjStatus(t *testing.T) { func TestFormatProjStatus(t *testing.T) {
useColor = false useColor = false
defer func() { useColor = false }() defer func() { useColor = false }()
w := overviewWidths{label: 12, sync: 5, host: 7}
cases := []struct { cases := []struct {
s projStatus s projStatus
contains []string contains []string
@@ -820,10 +822,16 @@ func TestFormatProjStatus(t *testing.T) {
{projStatus{name: "d", branch: "feature", dirty: true}, {projStatus{name: "d", branch: "feature", dirty: true},
[]string{"d", "*", "(feature)"}, nil}, []string{"d", "*", "(feature)"}, nil},
{projStatus{name: "e", branch: "master"}, // clean, no upstream {projStatus{name: "e", branch: "master"}, // clean, no upstream
[]string{"e", "no upstream"}, []string{"*"}}, []string{"e", ""}, []string{"*", "✓"}},
{projStatus{name: "f", branch: "master", hasUpstream: true, ahead: 1, behind: 2},
[]string{"f", "↑1↓2"}, []string{"✓"}}, // diverged shows both
{projStatus{name: "g", branch: "master", hasUpstream: true, lastHost: "laptop",
mirrors: []string{"hub", "gitea"}},
[]string{"g", "laptop", "→ hub gitea"}, nil},
} }
for _, c := range cases { for _, c := range cases {
got := formatProjStatus(c.s, 8) c.s.isRepo = true // these all describe real repositories
got := formatProjStatus(c.s, w)
for _, sub := range c.contains { for _, sub := range c.contains {
if !strings.Contains(got, sub) { if !strings.Contains(got, sub) {
t.Errorf("formatProjStatus(%+v) = %q, missing %q", c.s, got, sub) t.Errorf("formatProjStatus(%+v) = %q, missing %q", c.s, got, sub)
@@ -837,6 +845,62 @@ func TestFormatProjStatus(t *testing.T) {
} }
} }
// TestOverviewColumnsAlign is the point of the table: every field has to start
// at the same column on every row, whatever the name lengths or the multi-byte
// status glyphs do.
func TestOverviewColumnsAlign(t *testing.T) {
useColor = false
// host names must not occur anywhere else in a row, or the index search
// below would find them inside a project or branch name instead
rows := []projStatus{
{name: "a", branch: "master", hasUpstream: true, ahead: 12, behind: 3, lastHost: "workstation"},
{name: "a-very-long-project-name", branch: "wip", dirty: true, lastHost: "buildbox"},
{name: "mid", branch: "main", hasUpstream: true, lastHost: "laptop"},
}
w := measureOverview(rows)
var widths []int
for _, r := range rows {
line := formatProjStatus(r, w)
// the host column starts right after the padded sync field
idx := strings.Index(line, r.lastHost)
if idx < 0 {
t.Fatalf("host %q missing from %q", r.lastHost, line)
}
widths = append(widths, utf8.RuneCountInString(line[:idx]))
}
for i := 1; i < len(widths); i++ {
if widths[i] != widths[0] {
t.Errorf("host column starts at %d on row %d, %d on row 0:\n%s",
widths[i], i, widths[0], strings.Join([]string{
formatProjStatus(rows[0], w), formatProjStatus(rows[i], w)}, "\n"))
}
}
}
func TestAttentionRank(t *testing.T) {
ranks := []struct {
s projStatus
want int
}{
{projStatus{isRepo: true, dirty: true}, 0},
{projStatus{isRepo: true, ahead: 1}, 0},
{projStatus{isRepo: true, behind: 1}, 0},
{projStatus{isRepo: true, hasUpstream: true}, 1},
{projStatus{isRepo: true}, 1}, // clean, no upstream
// not on the server is a different kind of task and goes last, even
// when the working tree is dirty — it cannot be pushed anyway
{projStatus{isRepo: true, notOnServer: true}, 2},
{projStatus{isRepo: true, dirty: true, notOnServer: true}, 2},
{projStatus{notOnServer: true}, 2},
}
for _, c := range ranks {
if got := attentionRank(c.s); got != c.want {
t.Errorf("attentionRank(%+v) = %d, want %d", c.s, got, c.want)
}
}
}
func TestDetectRemoteKind(t *testing.T) { func TestDetectRemoteKind(t *testing.T) {
cases := []struct { cases := []struct {
url, override string url, override string
@@ -943,3 +1007,143 @@ func TestExpandAlias(t *testing.T) {
} }
} }
} }
func TestSplitAtMarker(t *testing.T) {
lines := []string{"a", "b", "---mgsh---", "c", "d"}
before, after := splitAtMarker(lines, "---mgsh---")
if strings.Join(before, ",") != "a,b" || strings.Join(after, ",") != "c,d" {
t.Errorf("split = %v / %v", before, after)
}
// no marker: everything is the first section, so a server that produced no
// du output simply yields no sizes
before, after = splitAtMarker([]string{"a", "b"}, "---mgsh---")
if strings.Join(before, ",") != "a,b" || after != nil {
t.Errorf("split without marker = %v / %v", before, after)
}
}
func TestParseDuSizes(t *testing.T) {
lines := []string{
"185432\tBetaflight3.0.0.git",
"2144\twebsite.git",
"876 spaced-with-blanks.git", // some du implementations use spaces
"1024\t./with-dot-slash.git",
"1500\tmy project.git", // a name with a space survives
"garbage",
"",
}
got := parseDuSizes(lines)
want := map[string]int64{
"Betaflight3.0.0.git": 185432 * 1024,
"website.git": 2144 * 1024,
"spaced-with-blanks.git": 876 * 1024,
"with-dot-slash.git": 1024 * 1024,
"my project.git": 1500 * 1024,
}
if len(got) != len(want) {
t.Fatalf("parseDuSizes = %v, want %d entries", got, len(want))
}
for k, v := range want {
if got[k] != v {
t.Errorf("parseDuSizes[%q] = %d, want %d", k, got[k], v)
}
}
}
// TestMigrateRemoteKeys rewrites the pre-4.1 flat spelling in place. Only the
// key changes: values, comments and everything else stay byte for byte.
func TestMigrateRemoteKeys(t *testing.T) {
dir := t.TempDir()
path := filepath.Join(dir, ".mgshrc")
before := `# my config
base = /home/me/src
remoteurl = https://git.example.com # the mirror
remotekey = s3cr3t-token
remotevisibility: public
# remotetype = gitea (commented out, must stay put)
alias co 'checkout $1'
`
if err := os.WriteFile(path, []byte(before), 0600); err != nil {
t.Fatal(err)
}
out := captureStdout(t, func() { migrateRemoteKeys(path, before) })
got := readFile(t, path)
for _, want := range []string{
"remote.public.url = https://git.example.com # the mirror",
"remote.public.key = s3cr3t-token",
" remote.public.visibility: public",
"# remotetype = gitea (commented out, must stay put)",
"base = /home/me/src",
"alias co 'checkout $1'",
} {
if !strings.Contains(got, want) {
t.Errorf("migrated file missing %q:\n%s", want, got)
}
}
if strings.Contains(got, "\nremoteurl") || strings.Contains(got, "\nremotekey") {
t.Errorf("old spelling left behind:\n%s", got)
}
if !strings.Contains(out, "remoteurl → remote.public.url") {
t.Errorf("migration was not reported: %q", out)
}
// the file keeps its private mode
if fi, err := os.Stat(path); err != nil {
t.Fatal(err)
} else if fi.Mode().Perm() != 0o600 {
t.Errorf("mode after migration = %04o, want 0600", fi.Mode().Perm())
}
// running again changes nothing and says nothing
second := captureStdout(t, func() { migrateRemoteKeys(path, readFile(t, path)) })
if strings.TrimSpace(second) != "" {
t.Errorf("a converted file was migrated again: %q", second)
}
if readFile(t, path) != got {
t.Error("a second migration changed the file")
}
}
// TestLegacyKeysDoNotOverrideExplicitOnes: a config carrying both spellings must
// keep what the new one says.
func TestLegacyKeysDoNotOverrideExplicitOnes(t *testing.T) {
var c Config
applyConfig(&c, parseConfig(
"remoteurl = https://old.example\nremote.public.url = https://new.example\n"+
"remote.public.key = tok\n"))
targets, _ := c.mirrorTargets()
if len(targets) != 1 || targets[0].URL != "https://new.example" {
t.Errorf("targets = %+v, want the remote.public.url value", targets)
}
}
// TestRemoteEnvOverrides: MGSH_REMOTE_<NAME>_<FIELD> is the environment
// spelling of remote.<name>.<field>.
func TestRemoteEnvOverrides(t *testing.T) {
t.Setenv("MGSH_REMOTE_GITLAB_URL", "https://gitlab.example")
t.Setenv("MGSH_REMOTE_GITLAB_KEY", "env-token")
t.Setenv("MGSH_REMOTE_GITLAB_VISIBILITY", "public")
t.Setenv("MGSH_REMOTE_MY_HUB_URL", "https://hub.example") // name with an underscore
t.Setenv("MGSH_REMOTE_MY_HUB_KEY", "hub-token")
t.Setenv("MGSH_REMOTES", "") // must not be mistaken for a target field
var c Config
applyConfig(&c, parseConfig("remote.gitlab.url = https://from-file.example\nremote.gitlab.key = file-token\n"))
applyEnv(&c)
targets, incomplete := c.mirrorTargets()
if len(incomplete) != 0 {
t.Fatalf("incomplete targets: %v", incomplete)
}
byName := map[string]RemoteTarget{}
for _, tg := range targets {
byName[tg.Name] = tg
}
if g := byName["gitlab"]; g.URL != "https://gitlab.example" || g.Key != "env-token" || g.Vis != "public" {
t.Errorf("env did not override the file: %+v", g)
}
// the field is taken from the end, so the name may contain underscores
if h := byName["my_hub"]; h.URL != "https://hub.example" || h.Key != "hub-token" {
t.Errorf("MGSH_REMOTE_MY_HUB_* = %+v, want target my_hub", h)
}
}
+17 -16
View File
@@ -23,24 +23,25 @@ gitpath = /home/git
# editor = code # editor = code
# --- pushremote: mirror to public servers (gitea/github/gitlab) via their API --- # --- pushremote: mirror to public servers (gitea/github/gitlab) via their API ---
# A single server, the flat form (this target is named "public"): # One "remote.<name>.<field>" block per server, with the fields url, key, type
# remoteurl = https://git.example.com # and visibility. <name> is yours to pick and becomes the git remote created in
# remotekey = <personal-access-token> # the repository, so `git push gitlab` keeps working outside mgsh.
# remotetype = gitea # optional; auto-detected from remoteurl
# remotevisibility = private # visibility of created repos (default private)
# #
# Or any number of named servers. `pushremote` pushes to all of them in the # `pushremote` pushes to every configured server in the order given,
# order given, `pushremote @hub` to a single one. Each target gets a git remote # `pushremote @gitlab` to a single one.
# of the same name in the repository.
# remote.gitea.url = https://git.example.com
# remote.gitea.key = <personal-access-token>
# remote.hub.url = https://github.com
# remote.hub.key = <personal-access-token>
# remote.hub.type = github # optional; auto-detected from the url
# remote.hub.visibility = public # default private
# remotes = gitea, hub # optional: restrict and order the set
# #
# mirror = true # `push` also mirrors via pushremote # remote.gitea.url = https://git.example.com
# remote.gitea.key = <personal-access-token>
# remote.gitea.type = gitea # optional; auto-detected from the url
# remote.gitea.visibility = private # or public (default private)
#
# remote.gitlab.url = https://gitlab.example.com
# remote.gitlab.key = <personal-access-token>
# remote.gitlab.type = gitlab
# remote.gitlab.visibility = public
#
# remotes = gitea, gitlab # optional: restrict and order the set
# mirror = true # `push` also mirrors via pushremote
# --- safety --- # --- safety ---
# `push` checks the staged diff for private keys and API tokens before it # `push` checks the staged diff for private keys and API tokens before it
+157 -92
View File
@@ -5,9 +5,8 @@ package main
// mgsh is the only thing that sees all three places a project can live: the // mgsh is the only thing that sees all three places a project can live: the
// local base directory, the internal ssh server, and the public mirrors. Joining // local base directory, the internal ssh server, and the public mirrors. Joining
// those answers the questions plain git cannot — which projects were never // those answers the questions plain git cannot — which projects were never
// pushed to the server, which exist there but not on this machine, and which // pushed to the server, and which machine last touched each one (every `push`
// machine last touched each one (every `push` stamps "[user@host]" into the // stamps "[user@host]" into the commit message, so that comes for free).
// commit message, so that comes for free).
import ( import (
"fmt" "fmt"
@@ -18,15 +17,18 @@ import (
"strings" "strings"
"sync" "sync"
"time" "time"
"unicode/utf8"
) )
// projStatus is the collected state of one project for the overview. // projStatus is the collected state of one project for the overview.
type projStatus struct { type projStatus struct {
name string name string
branch string branch string
isRepo bool // has a .git of its own
dirty bool dirty bool
ahead, behind int ahead, behind int
hasUpstream bool hasUpstream bool
notOnServer bool // known to be missing from the git server
lastHost string // machine that made the last commit, from "[user@host]" lastHost string // machine that made the last commit, from "[user@host]"
lastWhen time.Time // when that was lastWhen time.Time // when that was
mirrors []string // configured mirror remotes present in this repo mirrors []string // configured mirror remotes present in this repo
@@ -60,33 +62,41 @@ func overviewAll() {
srvCh <- serverList{names, err} srvCh <- serverList{names, err}
}() }()
var local, repos []string // every directory gets a row, repository or not: one that is not a
// repository yet is exactly what `init` is for, and putting it in the table
// beats a separate list underneath
var local []string
for _, e := range entries { for _, e := range entries {
if !e.IsDir() || strings.HasPrefix(e.Name(), ".") { if e.IsDir() && !strings.HasPrefix(e.Name(), ".") {
continue local = append(local, e.Name())
}
local = append(local, e.Name())
if isDir(BASE + "/" + e.Name() + "/.git") {
repos = append(repos, e.Name())
} }
} }
rows := scanProjects(repos) rows := scanProjects(local)
srv := <-srvCh srv := <-srvCh
markUnpublished(rows, srv.names, srv.err)
width := 0 // what needs doing first, alphabetical within each group
for _, n := range repos { sort.SliceStable(rows, func(i, j int) bool {
if len(n) > width { return attentionRank(rows[i]) < attentionRank(rows[j])
width = len(n) })
}
}
if len(rows) == 0 { if len(rows) == 0 {
fmt.Println(col(cGray, "no git projects under "+BASE)) fmt.Println(col(cGray, "nothing under "+BASE))
return
} }
dirtyN, syncN := 0, 0
w := measureOverview(rows)
repoN, dirtyN, syncN, initN := 0, 0, 0, 0
for _, r := range rows { for _, r := range rows {
fmt.Println(formatProjStatus(r, width)) fmt.Println(formatProjStatus(r, w))
if r.notOnServer {
initN++
}
if !r.isRepo {
continue
}
repoN++
if r.dirty { if r.dirty {
dirtyN++ dirtyN++
} }
@@ -94,12 +104,15 @@ func overviewAll() {
syncN++ syncN++
} }
} }
if len(rows) > 0 {
fmt.Println(col(cGray, fmt.Sprintf("%d projects · %d dirty · %d in sync",
len(rows), dirtyN, syncN)))
}
reportInventory(local, srv.names, srv.err) summary := fmt.Sprintf("%d projects · %d dirty · %d in sync", repoN, dirtyN, syncN)
if initN > 0 {
summary += fmt.Sprintf(" · %d to init", initN)
}
fmt.Println(col(cGray, summary))
if srv.err != nil {
fmt.Println(col(cGray, " git server not reachable — local view only"))
}
} }
// scanProjects collects the state of every project concurrently. Each project // scanProjects collects the state of every project concurrently. Each project
@@ -121,50 +134,31 @@ func scanProjects(names []string) []projStatus {
return rows return rows
} }
// reportInventory names the projects that live on only one side: local ones the // markUnpublished flags the rows the git server has never seen — the ones
// server has never seen (candidates for `init`) and server repositories missing // `init` is for. A listing that failed leaves every row unmarked: not knowing
// here (candidates for `clone`). // is not the same as knowing they are missing, and marking all of them would
func reportInventory(local, server []string, err error) { // tell the user to re-init their whole base directory.
func markUnpublished(rows []projStatus, server []string, err error) {
if err != nil { if err != nil {
fmt.Println(col(cGray, " git server not reachable — local view only"))
return return
} }
have := map[string]bool{}
for _, n := range local {
have[n] = true
}
onServer := map[string]bool{} onServer := map[string]bool{}
for _, n := range server { for _, n := range server {
onServer[n] = true onServer[n] = true
} }
for i := range rows {
var missingRemote, missingLocal []string rows[i].notOnServer = !onServer[rows[i].name]
for _, n := range local {
if !onServer[n] {
missingRemote = append(missingRemote, n)
}
}
for _, n := range server {
if !have[n] {
missingLocal = append(missingLocal, n)
}
}
sort.Strings(missingRemote)
sort.Strings(missingLocal)
if len(missingRemote) > 0 {
fmt.Printf("%s %s\n", col(cYellow, " not on the git server:"),
strings.Join(missingRemote, ", ")+col(cGray, " (init)"))
}
if len(missingLocal) > 0 {
fmt.Printf("%s %s\n", col(cCyan, " not cloned here: "),
strings.Join(missingLocal, ", ")+col(cGray, " (clone)"))
} }
} }
// projectStatus gathers the git state of a single project directory. // projectStatus gathers the git state of a single project directory. A
// directory without a repository is reported as it is, and costs no
// subprocesses at all.
func projectStatus(name, dir string) projStatus { func projectStatus(name, dir string) projStatus {
s := projStatus{name: name, branch: "-"} s := projStatus{name: name, branch: "-"}
if s.isRepo = isDir(dir + "/.git"); !s.isRepo {
return s
}
readStatus(&s, dir) readStatus(&s, dir)
readLastCommit(&s, dir) readLastCommit(&s, dir)
s.mirrors = configuredMirrors(dir) s.mirrors = configuredMirrors(dir)
@@ -244,50 +238,121 @@ func configuredMirrors(dir string) []string {
return found return found
} }
// formatProjStatus renders one aligned overview row. // overviewWidths are the column widths of the overview table, measured from the
func formatProjStatus(s projStatus, width int) string { // rows so every field starts at the same place. Ragged columns were what made
var marks []string // the old one-line-per-project output hard to read.
type overviewWidths struct {
label, sync, host int
hint bool // any row carries an action hint
}
// hintWidth is the width of the action column, sized for its only word.
const hintWidth = 4
// measureOverview sizes the columns for a set of rows.
func measureOverview(rows []projStatus) overviewWidths {
var w overviewWidths
for _, r := range rows {
w.label = max(w.label, utf8.RuneCountInString(projLabel(r)))
w.sync = max(w.sync, utf8.RuneCountInString(syncState(r)))
w.host = max(w.host, utf8.RuneCountInString(r.lastHost))
w.hint = w.hint || r.notOnServer
}
return w
}
// projLabel is the first column: the project, with its branch appended when it
// is not the usual one. Keeping the branch attached to the name costs no extra
// column and keeps the table narrow.
func projLabel(s projStatus) string {
if s.branch != "" && s.branch != "-" && s.branch != "master" && s.branch != "main" {
return s.name + " (" + s.branch + ")"
}
return s.name
}
// syncState renders the relation to the upstream as one short field: ahead,
// behind, both, in sync, or "" for a branch that tracks nothing. The old
// spelled-out "(no upstream)" was fifteen columns wide and pushed every
// following field out of line.
func syncState(s projStatus) string {
switch {
case !s.isRepo:
return "" // nothing to compare: there is no repository here yet
case s.ahead > 0 && s.behind > 0:
return fmt.Sprintf("↑%d↓%d", s.ahead, s.behind)
case s.ahead > 0:
return fmt.Sprintf("↑%d", s.ahead)
case s.behind > 0:
return fmt.Sprintf("↓%d", s.behind)
case s.hasUpstream:
return "✓"
default:
return ""
}
}
// syncColor weights a row visually: anything needing action is coloured, a
// project that is clean and in sync recedes into grey.
func syncColor(s projStatus) string {
switch {
case s.behind > 0:
return cRed
case s.ahead > 0:
return cGreen
default:
return cGray
}
}
// attentionRank groups the rows: work in progress at the top, then everything
// that is settled, and last the directories the server does not have yet. With
// many projects, scanning the whole list for the two dirty ones is the actual
// work — and an un-inited directory is a different kind of task, not something
// to push past the daily ones.
func attentionRank(s projStatus) int {
switch {
case s.notOnServer:
return 2
case s.dirty || s.ahead > 0 || s.behind > 0:
return 0
default:
return 1
}
}
// formatProjStatus renders one row of the overview table.
func formatProjStatus(s projStatus, w overviewWidths) string {
dirty := " "
if s.dirty { if s.dirty {
marks = append(marks, col(cYellow, "*")) dirty = "*"
}
if s.ahead > 0 {
marks = append(marks, col(cGreen, fmt.Sprintf("↑%d", s.ahead)))
}
if s.behind > 0 {
marks = append(marks, col(cRed, fmt.Sprintf("↓%d", s.behind)))
}
state := strings.Join(marks, " ")
if state == "" {
if s.hasUpstream {
state = col(cGreen, "✓")
} else {
state = col(cGray, "✓ (no upstream)")
}
} }
line := " " + col(cGreen, padRight(s.name, width+2)) + state var b strings.Builder
if s.branch != "master" && s.branch != "main" && s.branch != "-" { b.WriteString(" ")
line += col(cGray, " ("+s.branch+")") b.WriteString(col(cGreen, padRight(projLabel(s), w.label)))
} b.WriteString(" " + col(cYellow, dirty) + " ")
b.WriteString(col(syncColor(s), padRight(syncState(s), w.sync)))
var tail []string if w.host > 0 {
if s.lastHost != "" || !s.lastWhen.IsZero() { age := ""
t := s.lastHost
if !s.lastWhen.IsZero() { if !s.lastWhen.IsZero() {
if t != "" { age = shortAge(time.Since(s.lastWhen))
t += " "
}
t += shortAge(time.Since(s.lastWhen))
} }
tail = append(tail, t) b.WriteString(" " + col(cGray, padRight(s.lastHost, w.host)))
b.WriteString(" " + col(cGray, fmt.Sprintf("%4s", age)))
}
if w.hint {
hint := ""
if s.notOnServer {
hint = "init"
}
b.WriteString(" " + col(cYellow, padRight(hint, hintWidth)))
} }
if len(s.mirrors) > 0 { if len(s.mirrors) > 0 {
tail = append(tail, "→ "+strings.Join(s.mirrors, ",")) b.WriteString(col(cGray, " → "+strings.Join(s.mirrors, " ")))
} }
if len(tail) > 0 { return strings.TrimRight(b.String(), " ")
line += col(cGray, " · "+strings.Join(tail, " · "))
}
return line
} }
// shortAge renders a duration compactly: 90s -> "1m", 36h -> "1d". // shortAge renders a duration compactly: 90s -> "1m", 36h -> "1d".
+52 -22
View File
@@ -82,33 +82,63 @@ func TestCommitHostRe(t *testing.T) {
} }
} }
// TestReportInventoryUnreachableServer: when the server cannot be listed, the // TestMarkUnpublishedUnreachableServer: a listing that failed must leave every
// overview must say so rather than claim every project is missing there. // row unmarked. Not knowing is not the same as knowing they are missing
func TestReportInventoryUnreachableServer(t *testing.T) { // marking all of them would tell the user to re-init their whole base.
out := captureStdout(t, func() { func TestMarkUnpublishedUnreachableServer(t *testing.T) {
reportInventory([]string{"a", "b"}, nil, errors.New("network is unreachable")) rows := []projStatus{{name: "a"}, {name: "b"}}
}) markUnpublished(rows, nil, errors.New("network is unreachable"))
if strings.Contains(out, "not on the git server") { for _, r := range rows {
t.Errorf("an unreachable server was reported as missing repositories: %q", out) if r.notOnServer {
} t.Errorf("%s marked as missing although the server could not be listed", r.name)
if !strings.Contains(out, "not reachable") { }
t.Errorf("no hint that the server was unreachable: %q", out)
} }
} }
// TestReportInventorySplitsSides is the join that plain git cannot do. // TestMarkUnpublished flags only what the server really does not have.
func TestReportInventorySplitsSides(t *testing.T) { func TestMarkUnpublished(t *testing.T) {
out := captureStdout(t, func() { rows := []projStatus{{name: "both"}, {name: "onlyhere"}}
reportInventory([]string{"both", "onlyhere"}, []string{"both", "onlythere"}, nil) markUnpublished(rows, []string{"both", "onlythere"}, nil)
}) if rows[0].notOnServer {
if !strings.Contains(out, "not on the git server:") || !strings.Contains(out, "onlyhere") { t.Error("a project present on both sides was marked")
t.Errorf("local-only project not reported: %q", out)
} }
if !strings.Contains(out, "not cloned here:") || !strings.Contains(out, "onlythere") { if !rows[1].notOnServer {
t.Errorf("server-only project not reported: %q", out) t.Error("a local-only project was not marked")
} }
if strings.Contains(out, "both") { }
t.Errorf("a project present on both sides should not be listed: %q", out)
// TestUnpublishedRowsCarryTheHint: the entries live in the table now, with the
// action in their own column, rather than in a list underneath it.
func TestUnpublishedRowsCarryTheHint(t *testing.T) {
useColor = false
rows := []projStatus{
{name: "published", isRepo: true, hasUpstream: true},
{name: "fresh", isRepo: true, notOnServer: true},
{name: "notarepo", notOnServer: true},
}
w := measureOverview(rows)
if !w.hint {
t.Fatal("hint column not reserved although rows need it")
}
got := []string{}
for _, r := range rows {
got = append(got, formatProjStatus(r, w))
}
if strings.Contains(got[0], "init") {
t.Errorf("a published project was hinted: %q", got[0])
}
for _, i := range []int{1, 2} {
if !strings.Contains(got[i], "init") {
t.Errorf("row %d missing the init hint: %q", i, got[i])
}
}
// a directory that is not a repository has no sync state to report
if strings.ContainsAny(got[2], "✓–↑↓") {
t.Errorf("non-repository row claims a git state: %q", got[2])
}
// with nothing to hint the column disappears entirely
if measureOverview(rows[:1]).hint {
t.Error("hint column reserved although no row needs it")
} }
} }
+11 -16
View File
@@ -4,23 +4,18 @@ package main
// git hosting server (Gitea, GitHub or GitLab), creating the repository via the // git hosting server (Gitea, GitHub or GitLab), creating the repository via the
// server's REST API when it does not exist yet. // server's REST API when it does not exist yet.
// //
// Configuration (in ~/.mgshrc, a project .mgshrc, or MGSH_* env) — either a // Configuration (in ~/.mgshrc, a project .mgshrc, or MGSH_* env): one
// single flat target: // remote.<name>.<field> block per server, which `pushremote` mirrors to in turn.
// //
// remoteurl = https://git.example.com base URL of the server // remote.gitlab.url = https://gitlab.example.com
// remotekey = <api-token> personal access token // remote.gitlab.key = <api-token>
// remotetype = gitea|github|gitlab optional; auto-detected from the URL // remote.gitlab.type = gitlab optional; detected from the url
// remote.gitlab.visibility = public or private (the default)
// remotes = gitlab optional: restrict/order the set
// //
// or any number of named ones, which `pushremote` mirrors to in turn: // Each target owns a git remote of its own name in the repository. There is no
// // second spelling: the pre-4.1 flat remoteurl/remotekey pair is migrated to
// remote.gitea.url = https://git.example.com // remote.public.* on load.
// remote.gitea.key = <api-token>
// remote.hub.url = https://github.com
// remote.hub.key = <api-token>
// remote.hub.visibility = public
// remotes = gitea, hub optional: restrict/order the set
//
// Each target owns a git remote of the same name in the repository.
// //
// The token is used for the API calls and, via an HTTP Basic auth header, for // The token is used for the API calls and, via an HTTP Basic auth header, for
// the git push. It is never written into the repository's git config, and it is // the git push. It is never written into the repository's git config, and it is
@@ -287,7 +282,7 @@ func handlePushRemote(args string) {
targets = pickRemotes(targets, names) targets = pickRemotes(targets, names)
if len(targets) == 0 { if len(targets) == 0 {
if len(names) == 0 { // an unknown @name already reported itself if len(names) == 0 { // an unknown @name already reported itself
errorln("pushremote needs 'remoteurl'/'remotekey' or a 'remote.<name>.*' block in " + configFile()) errorln("pushremote needs a 'remote.<name>.url' and 'remote.<name>.key' in " + configFile())
} }
return return
} }
+62
View File
@@ -329,3 +329,65 @@ func mustGit(t *testing.T, dir string, args ...string) {
t.Fatalf("git %v: %v\n%s", args, err, out) t.Fatalf("git %v: %v\n%s", args, err, out)
} }
} }
// TestListSurvivesFailingDu: `list` chains the listing and `du` into one remote
// command, and the exit status is the *last* command's. A server whose du fails
// — a shell that mis-parses the arguments, a du that is not there, a permission
// problem — must still get its repositories listed.
func TestListSurvivesFailingDu(t *testing.T) {
useProject(t, "x")
fakeServer(t, func(cmd string) (string, error) {
return "total 4\n" +
"drwxr-xr-x 7 git git 4096 Jan 3 14:32 notes.git\n" +
"drwxr-xr-x 7 git git 4096 Sep 28 2016 website.git\n" +
listMarker + "\n",
errors.New("exit status 1") // du blew up, ls did not
})
out := captureStdout(t, func() { runCommand("list") })
if strings.Contains(out, "could not list") {
t.Errorf("a failing du discarded a good listing:\n%s", out)
}
for _, want := range []string{"notes", "website", "2 repositories"} {
if !strings.Contains(out, want) {
t.Errorf("listing missing %q:\n%s", want, out)
}
}
// without sizes there must be no size column, not a column of zeroes
if strings.Contains(out, "0B") {
t.Errorf("zero sizes shown when du produced none:\n%s", out)
}
}
// TestListReportsATrulyFailedListing: when nothing usable came back, the error
// still has to surface.
func TestListReportsATrulyFailedListing(t *testing.T) {
useProject(t, "x")
fakeServer(t, func(cmd string) (string, error) {
return "", errors.New("ssh: connect failed")
})
out := captureStdout(t, func() { runCommand("list") })
if !strings.Contains(out, "could not list") {
t.Errorf("a failed listing was not reported:\n%s", out)
}
}
// TestListSendsNoShellSpecificSyntax guards the bug this replaced: the remote
// command is run by the git user's login shell, which may be csh, where
// "2>/dev/null" is an argument followed by a redirection rather than a
// redirection of stderr.
func TestListSendsNoShellSpecificSyntax(t *testing.T) {
useProject(t, "x")
sent := fakeServer(t, func(cmd string) (string, error) { return "", nil })
captureStdout(t, func() { runCommand("list") })
if len(*sent) == 0 {
t.Fatal("list sent nothing")
}
for _, c := range *sent {
if strings.Contains(c, "2>") || strings.Contains(c, "&>") {
t.Errorf("remote command uses sh-only redirection: %q", c)
}
}
}
-1
View File
@@ -147,7 +147,6 @@ func configKeys() []string {
keys := []string{ keys := []string{
"base", "githost", "gitport", "gituser", "gitpath", "gitkey", "base", "githost", "gitport", "gituser", "gitpath", "gitkey",
"gitname", "gitemail", "pushdefault", "editor", "gitname", "gitemail", "pushdefault", "editor",
"remoteurl", "remotekey", "remotetype", "remotevisibility",
"remotes", "mirror", "secretscan", "remotes", "mirror", "secretscan",
} }
sort.Strings(keys) sort.Strings(keys)
+1 -1
View File
@@ -1 +1 @@
4.0.29 4.0.42