6 Commits
Author SHA1 Message Date
mike 55f42ec50d [mike@mwxm4] 2026-08-11 16:14:55 +02:00
mike 34be410a97 [mike@mwxm4] 2026-08-11 15:12:28 +02:00
mike 682ac2a4a4 [mike@mwxm4] 2026-08-11 15:07:21 +02:00
mike 39f5b48d94 [mike@mwxm4] 2026-08-11 11:40:32 +02:00
mike 113527f220 [mike@mwxm4] 2026-07-28 15:25:54 +02:00
mike 1e3ae9a4c7 [mike@maginot] 2026-07-28 06:55:46 +02:00
23 changed files with 1773 additions and 126 deletions
+139 -8
View File
@@ -9,7 +9,9 @@ directory. Go port of the original Perl `mgsh` (`mgsh.perl`).
- [Build](#build) · [Usage](#usage) · [Commands](#commands) · [Aliases](#aliases) - [Build](#build) · [Usage](#usage) · [Commands](#commands) · [Aliases](#aliases)
- [Overview](#overview) · [Credential check](#credential-check) - [Overview](#overview) · [Credential check](#credential-check)
- [Public mirror (`pushremote`)](#public-mirror-pushremote) · - [Public mirror (`pushremote`)](#public-mirror-pushremote) ·
[Deleting a mirror (`deleteremote`)](#deleting-a-mirror-deleteremote) ·
[Releases](#releases) [Releases](#releases)
- [Updating itself](#updating-itself)
- [Configuration](#configuration) · [Settings reference](#settings-reference) · - [Configuration](#configuration) · [Settings reference](#settings-reference) ·
[Per-project configuration](#per-project-configuration) [Per-project configuration](#per-project-configuration)
- [Git server layout](#git-server-layout) - [Git server layout](#git-server-layout)
@@ -62,11 +64,11 @@ stand in. Outside `base` no project is selected. `mgsh <project>` starts the
interactive shell with that project preselected. interactive shell with that project preselected.
The commands available directly from the shell are `clone`, `init`, `log`, The commands available directly from the shell are `clone`, `init`, `log`,
`push`, `pushremote`, `release`, `list`, `tag`, `archive`, `show`, `push`, `pushremote`, `deleteremote`, `release`, `list`, `tag`, `archive`,
`pull`, `fetch`, `status`, `diff`, `overview`, `config`, `count`, `login` and `show`, `pull`, `fetch`, `status`, `diff`, `overview`, `config`, `count`,
`cloneall`; every other command is interactive-only. `login` and `cloneall`; every other command is interactive-only.
The interactive prompt is colored (Catppuccin Mocha) and shows the active The interactive prompt is colored (Catppuccin-flavored) and shows the active
project, its git branch and a `*` dirty marker: project, its git branch and a `*` dirty marker:
``` ```
@@ -130,6 +132,7 @@ Run `help` for the full list. Highlights:
| `cd [project]` | change project (no argument: back to the base) | | `cd [project]` | change project (no argument: back to the base) |
| `push [comment]` | commit everything and push to the server | | `push [comment]` | commit everything and push to the server |
| `pushremote [desc]` | mirror the repo to a public server (gitea/github/gitlab) | | `pushremote [desc]` | mirror the repo to a public server (gitea/github/gitlab) |
| `deleteremote <@name\|host>` | delete the repo on that public server (asks first) |
| `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` | inventory of all projects, local and on the server | | `overview` | inventory of all projects, local and on the server |
@@ -146,6 +149,7 @@ Run `help` for the full list. Highlights:
| `unalias <name>` | remove a command alias | | `unalias <name>` | remove a command alias |
| `config [-k]` | show the effective configuration and its sources | | `config [-k]` | show the effective configuration and its sources |
| `rescan` | reload the config, refresh the cached repo list | | `rescan` | reload the config, refresh the cached repo list |
| `update [-c]` | update mgsh to the newest release (`-c`: only look) |
| `!<command>` | run `<command>` in the shell | | `!<command>` | run `<command>` in the shell |
Commands that touch a repository (`push`, `pull`, `log`, `diff`, `tag`, `dist`, Commands that touch a repository (`push`, `pull`, `log`, `diff`, `tag`, `dist`,
@@ -321,6 +325,7 @@ remote.gitlab.url = https://gitlab.example.com
remote.gitlab.key = <personal-access-token> remote.gitlab.key = <personal-access-token>
remote.gitlab.type = gitlab remote.gitlab.type = gitlab
remote.gitlab.visibility = public remote.gitlab.visibility = public
remote.gitlab.active = false # only used when named (default true)
# remotes = gitea, gitlab # optional: restrict and order the set # remotes = gitea, gitlab # optional: restrict and order the set
# mirror = true # `push` also mirrors via pushremote # mirror = true # `push` also mirrors via pushremote
@@ -333,10 +338,29 @@ name those versions used.
| command | pushes to | | command | pushes to |
|------------------------|-----------------------------------------------| |------------------------|-----------------------------------------------|
| `pushremote` | every configured target, in order | | `pushremote` | every **active** target, in order |
| `pushremote @gitea` | only `gitea` | | `pushremote @gitea` | only `gitea`, active or not |
| `pushremote @gitea @gitlab` | those two | | `pushremote @gitea @gitlab` | those two |
| `pushremote a fix` | every target, description "a fix" | | `pushremote a fix` | every active target, description "a fix" |
#### Active and inactive targets
`remote.<name>.active = false` takes a target out of the default set: it is
skipped by a bare `pushremote`, by `release` without a `@name`, and by the
automatic mirror of `mirror = true`. Naming it uses it exactly as before —
`pushremote @gitea`, `release @gitea v1.2`, or by host, `pushremote @git.example.com`.
That is the difference to the alternatives: removing the block loses the token,
and leaving the target in `remotes = …` keeps it on the automatic path. An
inactive target stays configured, keeps its credentials, is listed by `config`
(a shade dimmer, marked `inactive`) and completes with Tab — it just no longer
goes along by default. Useful for a server that is behind a VPN, rate-limited,
or only occasionally interesting.
Unset means active, so a configuration written before this flag existed keeps
mirroring where it always did. When *every* configured target is inactive, a
bare `pushremote` says so and names one instead of claiming nothing is
configured.
Each target owns a git remote of the same name in the repository, so Each target owns a git remote of the same name in the repository, so
`git push gitlab` keeps working outside mgsh. A `git push gitlab` keeps working outside mgsh. A
@@ -358,6 +382,52 @@ existing file is readable by others. The provider is auto-detected from the url
with `remote.<name>.type`. Set `mirror = true` to have every `push` mirror with `remote.<name>.type`. Set `mirror = true` to have every `push` mirror
automatically. automatically.
### Deleting a mirror (`deleteremote`)
`deleteremote` is the counterpart: it deletes the active project's repository on
a public server, through the same API and with the same token.
```
deleteremote <@name|host> [...]
```
```
< src/myproject > deleteremote git.example.com
remote gitea https://git.example.com (as mike)
this deletes https://git.example.com/mike/myproject.git with its issues,
releases and history, and cannot be undone
delete mike/myproject on gitea? y/N ? y
deleted mike/myproject on gitea
removed git remote gitea
```
A target can be named either way: `@gitea` (its configured name) or
`git.example.com` (the host of its url) select the same server, and the `@` is
optional here. Both spellings work for `pushremote` and `release` too.
`active = false` changes nothing for this command — it always needs the server
named anyway.
The guard rails, because nothing on the other end can be undone from here:
- **the server must be named.** Unlike `pushremote`, a bare `deleteremote` does
*not* mean "every configured target" — it lists them and stops.
- the repository is looked up first, so a typo is reported as "nothing to
delete" instead of becoming a request nobody meant to send.
- each deletion is confirmed on its own, naming owner/repo and server, and the
default answer is **no**.
- the **local clone is never touched**. Only the git remote of that name is
dropped, and only while it still points at the repository that was deleted.
Only the mirror is affected — the repository on the internal ssh git server, and
everything `list`/`archive` deal with, stays where it is.
Deletion needs more from the token than pushing does: the `delete_repo` scope on
GitHub, `write:repository` on Gitea, and the `api` scope plus the Owner role on
GitLab. When it is missing the server answers 403, and mgsh names the scope
rather than passing the bare status on. GitLab may only *schedule* the deletion
(HTTP 202): depending on the plan the project stays visible until its retention
period is over.
### Releases ### Releases
`release` turns a commit into a published release on the mirror servers, in one `release` turns a commit into a published release on the mirror servers, in one
@@ -373,7 +443,7 @@ release [@name ...] <tag> [notes]
remote hub released https://github.com/mike/mgsh.git remote hub released https://github.com/mike/mgsh.git
``` ```
Without `@name` it releases to every configured mirror target, exactly like Without `@name` it releases to every active mirror target, exactly like
`pushremote`. Everything after the tag becomes the release notes *and* the tag's `pushremote`. Everything after the tag becomes the release notes *and* the tag's
annotation. annotation.
@@ -430,6 +500,66 @@ This is where the providers stop resembling each other, and mgsh papers over it:
The GitLab route needs the package registry enabled on the project — it is on by The GitLab route needs the package registry enabled on the project — it is on by
default, but a self-hosted instance can turn it off. default, but a self-hosted instance can turn it off.
## Updating itself
```
update -c # only look
update # fetch and replace
```
and from outside the shell, `mgsh update` / `mgsh --update` — the dashed
spelling is the one that also works before mgsh is configured, where every
other command exits with "not configured".
mgsh fetches the newest release from
[git.micw.org/mike/mgsh](https://git.micw.org/mike/mgsh) — the URL sits fixed in
the program, there is nothing to configure. What it needs is one release per
version, whose tag is the bare number (`4.1.0`), with the files from `./bin` as
its assets; the one looked for is the one matching `GOOS`/`GOARCH` of this
machine. That is exactly what `./build.sh` produces and `release` publishes, so
`./build.sh && release 4.1.0` is the whole publishing side.
What gets replaced is the running file itself. If the `mgsh` that was called is
a symlink — say `~/bin/mgsh` pointing at `~/src/mgsh/bin/mgsh-darwin-arm64`
the target behind it is renewed, not the link. Before the swap, what was freshly
fetched is called once with `--version`; if it does not report the expected
number, everything stays as it was. The swap itself is a `rename` within the
same directory, hence atomic: either the old file or the new one, never half of
one. If the binary lies somewhere you may not write to (`/usr/local/bin`),
`update` says so and does nothing — then `sudo`.
### Once a day, by itself
Without being asked, mgsh looks once a day and says so on stderr — at the start
of an interactive session, and after the output of a one-shot command:
```
mgsh 4.1.0 is available, run 'mgsh --update'
```
The run in the foreground never touches the network for this. It only reads a
note — `~/Library/Caches/mgsh/update.json`, on Linux `~/.cache/mgsh/update.json`
— and when that one is older than a day, it starts `mgsh --update-refresh` on
the side: the same binary once more, detached, without output, only to ask.
Nobody waits for its answer; it will be in the note at the next call. mgsh
thereby stays exactly as fast as before, even when the server happens to be
silent.
The timestamp moves on *before* the asking. Two simultaneous runs therefore
start one query, not two, and a server that does not answer is asked again
tomorrow rather than on every call. If the note cannot be written, the question
is dropped entirely — otherwise a write-protected cache directory would mean one
process per call.
Asking and speaking happen only when stderr hangs on a terminal. In a pipe, in a
script and under cron there is quiet, and `MGSH_NO_UPDATE_CHECK=1` turns it off
altogether.
`selfupdate.go` is a copy from [dx](https://git.micw.org/mike/dx) and hangs on
nothing in the rest of mgsh: standard library only, every name it brings starts
with `selfUpdate` or `update`, and the block at the top of the file is all there
is to adjust when it moves on to the next program.
## Configuration ## Configuration
mgsh has **no built-in defaults**. Settings are resolved in three steps, each mgsh has **no built-in defaults**. Settings are resolved in three steps, each
@@ -491,6 +621,7 @@ setting.
| `remote.<name>.key` | project | API token for that target | | `remote.<name>.key` | project | API token for that target |
| `remote.<name>.type` | project | `gitea`\|`github`\|`gitlab`; auto-detected from the url when unset | | `remote.<name>.type` | project | `gitea`\|`github`\|`gitlab`; auto-detected from the url when unset |
| `remote.<name>.visibility` | project | `private` (default) or `public` for repositories `pushremote` creates | | `remote.<name>.visibility` | project | `private` (default) or `public` for repositories `pushremote` creates |
| `remote.<name>.active` | project | `false` keeps the target out of the default set; it is then only used when named (`pushremote @<name>`). Default `true` |
| `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) |
+4 -4
View File
@@ -44,10 +44,10 @@ var builtinCmds = map[string]bool{
"": true, "quit": true, "exit": true, "help": true, "rescan": true, "": true, "quit": true, "exit": true, "help": true, "rescan": true,
"dist": true, "list": true, "show": true, "log": true, "status": true, "dist": true, "list": true, "show": true, "log": true, "status": true,
"diff": true, "pull": true, "fetch": true, "push": true, "edit": true, "diff": true, "pull": true, "fetch": true, "push": true, "edit": true,
"pushremote": true, "overview": true, "archive": true, "init": true, "pushremote": true, "deleteremote": true, "overview": true, "archive": true, "init": true,
"login": true, "cd": true, "checkout": true, "clone": true, "cloneall": true, "login": true, "cd": true, "checkout": true, "clone": true, "cloneall": true,
"count": true, "tag": true, "alias": true, "count": true, "tag": true, "alias": true,
"unalias": true, "config": true, "release": true, "unalias": true, "config": true, "release": true, "update": true,
} }
func isBuiltin(name string) bool { return builtinCmds[name] } func isBuiltin(name string) bool { return builtinCmds[name] }
@@ -241,7 +241,7 @@ func handleUnalias(name string) {
func listAliases() { func listAliases() {
if len(aliases) == 0 { if len(aliases) == 0 {
fmt.Println(col(cGray, "no aliases defined")) fmt.Println(col(cDark, "no aliases defined"))
return return
} }
for _, n := range aliasNames() { for _, n := range aliasNames() {
@@ -251,7 +251,7 @@ func listAliases() {
// formatAlias renders `name = 'body'` with color. // formatAlias renders `name = 'body'` with color.
func formatAlias(name, body string) string { func formatAlias(name, body string) string {
return col(cGreen, name) + col(cGray, " = ") + col(cYellow, "'"+body+"'") return col(cGreen, name) + col(cDark, " = ") + col(cYellow, "'"+body+"'")
} }
// expandAlias substitutes positional parameters in an alias body. With no // expandAlias substitutes positional parameters in an alias body. With no
+2 -2
View File
@@ -114,8 +114,8 @@ func (r *remoteAPI) doUpload(method, endpoint, ctype string, body io.Reader, siz
func (r *remoteAPI) uploadAssets(owner, repo string, ref releaseRef, assets []releaseAsset) error { func (r *remoteAPI) uploadAssets(owner, repo string, ref releaseRef, assets []releaseAsset) error {
failed := 0 failed := 0
for _, a := range assets { for _, a := range assets {
fmt.Printf(" %s %s %s\n", col(cGray, "uploading"), fmt.Printf(" %s %s %s\n", col(cDark, "uploading"),
col(cGreen, padRight(a.name, 28)), col(cGray, humanSize(a.size))) col(cGreen, padRight(a.name, 28)), col(cDark, humanSize(a.size)))
if err := r.uploadAsset(owner, repo, ref, a); err != nil { if err := r.uploadAsset(owner, repo, ref, a); err != nil {
errorln(" " + a.name + ": " + err.Error()) errorln(" " + a.name + ": " + err.Error())
failed++ failed++
+14 -11
View File
@@ -7,21 +7,24 @@ import (
"unicode/utf8" "unicode/utf8"
) )
// Colors for the prompt, banner and output, using the Catppuccin Mocha palette // Colors for the prompt, banner and output, as 24-bit truecolor escapes: the
// as 24-bit truecolor escapes // Catppuccin Mocha accents with a warm white and three greys of its own.
// (https://terminalcolors.com/themes/catppuccin/mocha/).
const ( const (
cReset = "\033[0m" cReset = "\033[0m"
cBold = "\033[1m" cBold = "\033[1m"
cDim = "\033[2m" cDim = "\033[2m"
cGreen = "\033[38;2;166;227;161m" // Green #a6e3a1 cYellow = "\033[38;2;249;226;175m" // #f9e2af
cYellow = "\033[38;2;249;226;175m" // Yellow #f9e2af cOrange = "\033[38;2;250;179;135m" // #fab387
cRed = "\033[38;2;243;139;168m" // Red #f38ba8 cRed = "\033[38;2;243;139;168m" // #f38ba8
cCyan = "\033[38;2;148;226;213m" // Teal #94e2d5 cGreen = "\033[38;2;148;226;213m" // #94e2d5
cPurple = "\033[38;2;203;166;247m" // Mauve #cba6f7 cBlue = "\033[38;2;180;190;254m" // #b4befe
cWhite = "\033[38;2;205;214;244m" // Text #cdd6f4 cPink = "\033[38;2;245;178;247m" // #f5b2f7
cGray = "\033[38;2;108;112;134m" // Overlay0 #6c7086 cViolet = "\033[38;2;203;166;247m" // #cba6f7
cWhite = "\033[38;2;240;240;234m" // #f0f0ea
cGrey = "\033[38;2;170;170;187m" // #aaaabb
cDark = "\033[38;2;119;119;136m" // #777788
cDarker = "\033[38;2;68;68;85m" // #444455
) )
// col wraps s in color c, but only when color output is enabled. // col wraps s in color c, but only when color output is enabled.
@@ -66,7 +69,7 @@ func formatRepoList(entries []lsEntry, withSize bool) string {
for _, e := range entries { for _, e := range entries {
fmt.Fprintf(&b, " %s %s", col(cGreen, padRight(e.name, width)), col(cYellow, e.date)) fmt.Fprintf(&b, " %s %s", col(cGreen, padRight(e.name, width)), col(cYellow, e.date))
if withSize { if withSize {
fmt.Fprintf(&b, " %s", col(cGray, fmt.Sprintf("%7s", humanSize(e.size)))) fmt.Fprintf(&b, " %s", col(cDark, fmt.Sprintf("%7s", humanSize(e.size))))
} }
b.WriteByte('\n') b.WriteByte('\n')
} }
+69 -25
View File
@@ -20,7 +20,10 @@ var (
// literally named "git". // literally named "git".
lsEntryRe = regexp.MustCompile(`^\S+\s+\d+\s+\S+\s+\S+\s+(\d+)\s+(\S+)\s+(\S+)\s+(\S+)\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(`[,;:\\/='"|?><-]+`) // '!' is in here for the server's sake: csh expands history even inside
// single quotes, so a comment like "fix!now" would reach it as something
// else entirely (see remoteRejected).
sanRe = regexp.MustCompile(`[!,;:\\/='"|?><-]+`)
wsRe = regexp.MustCompile(`\s+`) wsRe = regexp.MustCompile(`\s+`)
) )
@@ -58,7 +61,13 @@ func parseDuSizes(lines []string) map[string]int64 {
if err != nil { if err != nil {
continue continue
} }
out[strings.TrimPrefix(strings.TrimSpace(m[2]), "./")] = kb * 1024 // du echoes the path it was given, and find gives it an absolute one:
// key on the last element, which is what the listing calls the entry
name := strings.TrimSpace(m[2])
if i := strings.LastIndexByte(name, '/'); i >= 0 {
name = name[i+1:]
}
out[name] = kb * 1024
} }
return out return out
} }
@@ -191,11 +200,20 @@ func runCommandDepth(line string, depth int) bool {
case "config": // show the effective configuration and where it came from case "config": // show the effective configuration and where it came from
if opt["k"] { if opt["k"] {
fmt.Println(strings.Join(configKeys(), "\n")) fmt.Println(strings.Join(configKeys(), "\n"))
fmt.Println("remote.<name>.url|key|type|visibility") fmt.Println("remote.<name>.url|key|type|visibility|active")
break break
} }
showConfig() showConfig()
case "update": // replace this binary with the newest release (-c: only look)
run := selfUpdate.install
if opt["c"] {
run = selfUpdate.check
}
if err := run(os.Stdout); err != nil {
errorln("update: " + err.Error())
}
case "rescan": // reload the configuration and the cached server repo list case "rescan": // reload the configuration and the cached server repo list
reloadConfig() reloadConfig()
rescanServer() rescanServer()
@@ -242,14 +260,22 @@ 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))
remote := "/bin/ls -ltr " + shq(path) remote := "/bin/ls -ltr " + shq(serverPath(path))
if !opt["a"] { if !opt["a"] {
// archives are files and carry a real size; repositories are // archives are files and carry a real size; repositories are
// directories, whose listed size is the inode's, so ask du in the // directories, whose listed size is the inode's, so ask du in the
// same round trip. Nothing shell-specific here on purpose: the // same round trip. Nothing shell-specific here on purpose: the
// login shell may be csh, where "2>/dev/null" is not a redirection // login shell may be csh, where "2>/dev/null" is not a redirection
// but an argument followed by one. // but an argument followed by one.
remote += "; echo " + shq(listMarker) + "; du -sk *.git" //
// The pattern goes to find, quoted, and never to the shell. A glob
// that matches nothing is a fatal error in a non-interactive zsh —
// "no matches found: *.git" — which aborts the command and turned an
// empty server into "could not list". sh would have handed the
// literal "*.git" to du instead, which is not much better.
remote += "; echo " + shq(listMarker) +
"; find " + shq(serverPath(path)) + " -maxdepth 1 -name " +
shq("*"+suffix) + " -exec du -sk {} +"
} }
lines, err := sshOut(remote) lines, err := sshOut(remote)
lsLines, duLines := splitAtMarker(lines, listMarker) lsLines, duLines := splitAtMarker(lines, listMarker)
@@ -275,14 +301,20 @@ func runCommandDepth(line string, depth int) bool {
// complain when nothing usable came back at all. // complain when nothing usable came back at all.
if len(entries) == 0 { if len(entries) == 0 {
if err != nil { if err != nil {
errorln("could not list " + many + " on the git server") what := "could not list " + many + " on the git server"
if opt["a"] {
// by far the likeliest reason, and one the wording used to
// hide behind something that sounded like a dead connection
what += " — is there an 'archive' directory in " + cfg.GitPath + "?"
}
errorln(what)
break 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) + "'"
} }
fmt.Println(col(cGray, what)) fmt.Println(col(cDark, what))
break break
} }
// no size column when the server gave no usable sizes, rather than a // no size column when the server gave no usable sizes, rather than a
@@ -296,7 +328,7 @@ func runCommandDepth(line string, depth int) bool {
if total > 0 { if total > 0 {
summary += " · " + humanSize(total) summary += " · " + humanSize(total)
} }
fmt.Println(col(cGray, summary)) fmt.Println(col(cDark, 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
@@ -316,8 +348,8 @@ func runCommandDepth(line string, depth int) bool {
errorln("repository not found") errorln("repository not found")
break break
} }
logLines, _ := sshOut("cd " + shq(cfg.GitPath+"/"+prj+".git") + logLines, _ := sshOut("git --git-dir=" + shq(serverPath(prj+".git")) +
" && git log --reverse --format='%h %ct %s'") " log --reverse --format='%h %ct %s'")
repolog(logLines) repolog(logLines)
case "log": case "log":
@@ -383,14 +415,17 @@ func runCommandDepth(line string, depth int) bool {
if !gitOK(DIR, "push") { if !gitOK(DIR, "push") {
break break
} }
sshOK("touch " + shq(cfg.GitPath+"/"+PRJ+".git")) sshOK("touch " + shq(serverPath(PRJ+".git")))
if targets, _ := cfg.mirrorTargets(); truthy(cfg.Mirror) && len(targets) > 0 { if targets, _ := cfg.mirrorTargets(); truthy(cfg.Mirror) && len(activeRemotes(targets)) > 0 {
handlePushRemote("") // auto-mirror to every configured server handlePushRemote("") // auto-mirror to every active server
} }
case "pushremote": // mirror the repo to a public git server via its API case "pushremote": // mirror the repo to a public git server via its API
handlePushRemote(strings.Join(fields[1:], " ")) handlePushRemote(strings.Join(fields[1:], " "))
case "deleteremote": // delete the repo on a public git server via its API
handleDeleteRemote(strings.Join(fields[1:], " "))
case "release": // tag a commit and publish it as a release on the mirrors case "release": // tag a commit and publish it as a release on the mirrors
handleRelease(strings.Join(fields[1:], " ")) handleRelease(strings.Join(fields[1:], " "))
@@ -430,13 +465,17 @@ func runCommandDepth(line string, depth int) bool {
if comment != "" { if comment != "" {
name = PRJ + "_" + z + "_" + comment name = PRJ + "_" + z + "_" + comment
} }
if !sshOK("cp -r " + shq(PRJ+".git") + " " + shq("archive/"+name+".git")) { // the archive directory is mgsh's own convention, so make it rather than
// fail on a server where nobody has created it yet
if !sshOK("mkdir -p " + shq(serverPath("archive")) +
" && cp -r " + shq(serverPath(PRJ+".git")) + " " + shq(serverPath("archive/"+name+".git"))) {
break break
} }
if !sshOK("cd archive && tar cvzf " + shq(name+".git.tar.gz") + " " + shq(name+".git")) { if !sshOK("tar cvzf " + shq(serverPath("archive/"+name+".git.tar.gz")) +
" -C " + shq(serverPath("archive")) + " " + shq(name+".git")) {
break break
} }
sshOK("rm -rf " + shq("archive/"+name+".git")) sshOK("rm -rf " + shq(serverPath("archive/"+name+".git")))
case "init": // create a new repository from the current directory case "init": // create a new repository from the current directory
if !requireProject() { if !requireProject() {
@@ -456,11 +495,13 @@ func runCommandDepth(line string, depth int) bool {
if exists && !yesno("overwrite existing repository "+PRJ+" on the server?", false) { if exists && !yesno("overwrite existing repository "+PRJ+" on the server?", false) {
break break
} }
remote := shq(cfg.GitPath + "/" + PRJ + ".git") remote := shq(serverPath(PRJ + ".git"))
if !sshOK("rm -rf " + remote) { if !sshOK("rm -rf " + remote) {
break break
} }
if !sshOK("mkdir " + remote + " && cd " + remote + " && git --bare init") { // `git init --bare <path>` makes the directory itself: one command, and
// none of it depends on a `cd` having worked
if !sshOK("git init --bare " + remote) {
break break
} }
gi := DIR + "/.gitignore" gi := DIR + "/.gitignore"
@@ -543,13 +584,14 @@ func runCommandDepth(line string, depth int) bool {
break break
} }
} else { } else {
if !sshOK("cd archive && tar xvzf " + shq(prj+".git.tar.gz")) { if !sshOK("tar xvzf " + shq(serverPath("archive/"+prj+".git.tar.gz")) +
" -C " + shq(serverPath("archive"))) {
break break
} }
if !gitOK(BASE, "clone", URL+"/archive/"+prj+".git") { if !gitOK(BASE, "clone", URL+"/archive/"+prj+".git") {
break break
} }
sshOK("rm -rf " + shq("archive/"+prj+".git")) sshOK("rm -rf " + shq(serverPath("archive/"+prj+".git")))
} }
if isDir(BASE + "/" + prj) { if isDir(BASE + "/" + prj) {
PRJ = prj PRJ = prj
@@ -560,7 +602,7 @@ func runCommandDepth(line string, depth int) bool {
if opt["a"] { if opt["a"] {
path = "./archive" path = "./archive"
} }
lines, err := sshOut("/bin/ls " + shq(path)) lines, err := sshOut("/bin/ls " + shq(serverPath(path)))
if err != nil { if err != nil {
errorln("could not list repositories on the git server") errorln("could not list repositories on the git server")
break break
@@ -610,7 +652,7 @@ func runCommandDepth(line string, depth int) bool {
default: // unknown command — no longer forwarded to the shell default: // unknown command — no longer forwarded to the shell
fmt.Println(col(cRed, "unknown command: "+words[0]) + fmt.Println(col(cRed, "unknown command: "+words[0]) +
col(cGray, " (prefix with '!' to run a shell command)")) col(cDark, " (prefix with '!' to run a shell command)"))
} }
return true return true
@@ -671,9 +713,9 @@ func formatLog(lines []string, now time.Time) string {
n++ n++
} }
fmt.Fprintf(&full, "%s %s %s\n", col(cPurple, hash), col(cYellow, z), subj) fmt.Fprintf(&full, "%s %s %s\n", col(cViolet, hash), col(cYellow, z), subj)
fmt.Fprintf(&short, "%s %s %s\n", col(cPurple, hash), col(cYellow, zs), subj) fmt.Fprintf(&short, "%s %s %s\n", col(cViolet, hash), col(cYellow, zs), subj)
fmt.Fprintf(&tiny, "%s %s %s\n", col(cPurple, hash), col(cYellow, zss), subj) fmt.Fprintf(&tiny, "%s %s %s\n", col(cViolet, hash), col(cYellow, zss), subj)
} }
switch { switch {
@@ -773,6 +815,7 @@ var helpItems = []struct{ cmd, desc string }{
{"init", "make new repository from current directory"}, {"init", "make new repository from current directory"},
{"push [comment]", "push changes to git server"}, {"push [comment]", "push changes to git server"},
{"pushremote [@name] [desc]", "mirror repo to the public server(s) (gitea/github/gitlab)"}, {"pushremote [@name] [desc]", "mirror repo to the public server(s) (gitea/github/gitlab)"},
{"deleteremote <@name|host>", "delete the repo on that public server (asks first)"},
{"release <tag> [notes]", "tag and publish a release on the public server(s)"}, {"release <tag> [notes]", "tag and publish a release on the public server(s)"},
{"pull", "pull changes from git server"}, {"pull", "pull changes from git server"},
{"fetch", "fetch changes from git server"}, {"fetch", "fetch changes from git server"},
@@ -798,6 +841,7 @@ var helpItems = []struct{ cmd, desc string }{
{"unalias <name>", "remove an alias"}, {"unalias <name>", "remove an alias"},
{"config [-k]", "show effective configuration (-k: list all setting names)"}, {"config [-k]", "show effective configuration (-k: list all setting names)"},
{"rescan", "reload config and refresh cached server repository list"}, {"rescan", "reload config and refresh cached server repository list"},
{"update [-c]", "update mgsh to the newest release (-c: only look)"},
{"!<command>", "run <command> in the shell"}, {"!<command>", "run <command> in the shell"},
{"quit", "exit mgsh"}, {"quit", "exit mgsh"},
} }
+3 -1
View File
@@ -61,6 +61,7 @@ func builtinCompleter() *readline.PrefixCompleter {
readline.PcItem("list", readline.PcItem("-a")), readline.PcItem("list", readline.PcItem("-a")),
readline.PcItem("push"), readline.PcItem("push"),
readline.PcItem("pushremote", readline.PcItemDynamic(dynRemoteNames)), readline.PcItem("pushremote", readline.PcItemDynamic(dynRemoteNames)),
readline.PcItem("deleteremote", readline.PcItemDynamic(dynRemoteNames)),
readline.PcItem("release", readline.PcItemDynamic(dynRemoteNames)), readline.PcItem("release", readline.PcItemDynamic(dynRemoteNames)),
readline.PcItem("pull"), readline.PcItem("pull"),
readline.PcItem("fetch"), readline.PcItem("fetch"),
@@ -84,6 +85,7 @@ func builtinCompleter() *readline.PrefixCompleter {
readline.PcItem("unalias", readline.PcItemDynamic(dynAliasNames)), readline.PcItem("unalias", readline.PcItemDynamic(dynAliasNames)),
readline.PcItem("config", readline.PcItem("-k")), readline.PcItem("config", readline.PcItem("-k")),
readline.PcItem("rescan"), readline.PcItem("rescan"),
readline.PcItem("update", readline.PcItem("-c")),
readline.PcItem("help"), readline.PcItem("help"),
readline.PcItem("quit"), readline.PcItem("quit"),
readline.PcItem("exit"), readline.PcItem("exit"),
@@ -125,7 +127,7 @@ func fetchServerRepos() {
} }
// 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 " + shq(serverPath("archive"))); err == nil {
for _, ln := range lines { for _, ln := range lines {
t := strings.TrimSpace(ln) t := strings.TrimSpace(ln)
if strings.HasSuffix(t, ".git.tar.gz") { if strings.HasSuffix(t, ".git.tar.gz") {
+18 -4
View File
@@ -48,8 +48,18 @@ type RemoteTarget struct {
Key string Key string
Type string // "gitea"|"github"|"gitlab" (auto-detected when empty) Type string // "gitea"|"github"|"gitlab" (auto-detected when empty)
Vis string // "private" (default) | "public" Vis string // "private" (default) | "public"
Active string // falsy -> taken along only when named (default: on)
} }
// isActive reports whether the target takes part in a command that was given no
// target of its own — `pushremote`, `release`, an auto-mirroring `push`. An
// inactive one is not disabled, it is merely off the automatic path: naming it
// (`pushremote @gitea`) uses it as it always did.
//
// Unset means active. The setting exists to take a server *out* of the default
// set, so a configuration written before it existed must keep behaving.
func (t RemoteTarget) isActive() bool { return !falsy(t.Active) }
// legacyRemoteName is the target the pre-4.1 flat remoteurl/remotekey settings // legacyRemoteName is the target the pre-4.1 flat remoteurl/remotekey settings
// are migrated to. It matches the git remote those versions created, so a // are migrated to. It matches the git remote those versions created, so a
// converted configuration keeps pushing to the same place. // converted configuration keeps pushing to the same place.
@@ -188,9 +198,9 @@ func migrateRemoteKeys(path, data string) {
errorln("could not update " + path + ": " + err.Error()) errorln("could not update " + path + ": " + err.Error())
return return
} }
fmt.Println(col(cGray, path+": mirror settings renamed to the remote.<name>.* form")) fmt.Println(col(cDark, path+": mirror settings renamed to the remote.<name>.* form"))
for _, r := range renamed { for _, r := range renamed {
fmt.Println(col(cGray, " "+r)) fmt.Println(col(cDark, " "+r))
} }
} }
@@ -441,7 +451,7 @@ func foldLegacyRemoteKeys(m map[string]string) map[string]string {
} }
// remoteFieldRe matches a named mirror target setting: remote.<name>.<field>. // remoteFieldRe matches a named mirror target setting: remote.<name>.<field>.
var remoteFieldRe = regexp.MustCompile(`^remote\.([a-z0-9_.-]+)\.(url|key|type|visibility)$`) var remoteFieldRe = regexp.MustCompile(`^remote\.([a-z0-9_.-]+)\.(url|key|type|visibility|active)$`)
// applyRemoteTargets merges `remote.<name>.<field>` settings into c.Remotes. // applyRemoteTargets merges `remote.<name>.<field>` settings into c.Remotes.
// An already known target is updated field by field, so a project .mgshrc can // An already known target is updated field by field, so a project .mgshrc can
@@ -472,6 +482,8 @@ func applyRemoteTargets(c *Config, m map[string]string) {
t.Type = v t.Type = v
case "visibility": case "visibility":
t.Vis = v t.Vis = v
case "active":
t.Active = v
} }
} }
} }
@@ -510,7 +522,7 @@ func applyEnv(c *Config) {
} }
// remoteFields are the settings a mirror target is made of. // remoteFields are the settings a mirror target is made of.
var remoteFields = []string{"url", "key", "type", "visibility"} var remoteFields = []string{"url", "key", "type", "visibility", "active"}
// applyRemoteEnv reads MGSH_REMOTE_<NAME>_<FIELD>, the environment spelling of // applyRemoteEnv reads MGSH_REMOTE_<NAME>_<FIELD>, the environment spelling of
// a remote.<name>.<field> setting — MGSH_REMOTE_GITLAB_KEY for // a remote.<name>.<field> setting — MGSH_REMOTE_GITLAB_KEY for
@@ -552,6 +564,8 @@ func applyRemoteEnv(c *Config) {
t.Type = value t.Type = value
case "visibility": case "visibility":
t.Vis = value t.Vis = value
case "active":
t.Active = value
} }
} }
} }
+184
View File
@@ -0,0 +1,184 @@
package main
// deleteremote.go — the `deleteremote` command: remove the active project's
// repository from a public mirror server via that server's REST API.
//
// This is the only mgsh command that destroys something on a server it does not
// own, and nothing here can undo it. Four things keep it in check:
//
// - the server has to be named. `pushremote` without one means "every
// configured target", which is convenient there and catastrophic here.
// - the repository is looked up first, so a typo comes back as "not on that
// server" instead of as a DELETE nobody meant to send.
// - every deletion is confirmed on its own, spelling out owner/repo and the
// server it lives on, and the default answer is no.
// - the local clone is never touched. Only the git remote `pushremote`
// created is dropped, and only while it still points at what was deleted.
import (
"fmt"
"strings"
)
// deleteRepo removes owner/repo from the server.
//
// The providers disagree on what success looks like: Gitea and GitHub answer
// 204, GitLab 202 — there the deletion is merely scheduled, and depending on
// the plan the project stays visible until its retention period is over.
func (r *remoteAPI) deleteRepo(owner, repo string) error {
code, data, err := r.do("DELETE", r.repoPath(owner, repo), nil)
if err != nil {
return err
}
switch code {
case 200, 202, 204:
return nil
case 403:
// the common failure by far: the token can push and create, but was
// never given the separate permission a deletion needs
return fmt.Errorf("not allowed to delete %s/%s — the token needs %s (HTTP 403): %s",
owner, repo, r.deletePermission(), firstLine(data))
default:
return fmt.Errorf("deleting repository failed (HTTP %d): %s", code, firstLine(data))
}
}
// deletePermission names what a provider wants from a token before it will
// delete a repository, because a bare "403" sends people to the wrong setting.
func (r *remoteAPI) deletePermission() string {
switch r.kind {
case kindGitHub:
return "the 'delete_repo' scope"
case kindGitLab:
return "the 'api' scope and the Owner role"
default: // Gitea
return "the 'write:repository' scope"
}
}
// parseRemoteSelectors splits `deleteremote <name|host> ...` into the targets
// it names. The '@' that `pushremote` requires is accepted but optional here:
// the command takes nothing but selectors, so there is no description a bare
// word could be mistaken for.
func parseRemoteSelectors(args string) []string {
var out []string
for _, f := range strings.Fields(args) {
if s := strings.TrimPrefix(f, "@"); s != "" {
out = append(out, s)
}
}
return out
}
// handleDeleteRemote implements `deleteremote <@name|host> ...`: it deletes the
// active project's repository on each named mirror server.
func handleDeleteRemote(args string) {
if !requireProject() {
return
}
targets, incomplete := cfg.mirrorTargets()
for _, n := range incomplete {
errorln("remote " + n + ": url or key missing — skipped")
}
if len(targets) == 0 {
errorln("deleteremote needs a 'remote.<name>.url' and 'remote.<name>.key' in " + configFile())
return
}
// no selector is never "all of them" — that is the whole point of the
// command taking one
sels := parseRemoteSelectors(args)
if len(sels) == 0 {
errorln("deleteremote needs the server to delete from: " + remoteChoices(targets))
return
}
targets = pickRemotes(targets, sels)
if len(targets) == 0 {
return // pickRemotes already named the selectors it did not recognise
}
repo := PRJ // like pushremote: the project names the repository
done := 0
for _, t := range targets {
if deleteOnRemote(t, repo) {
done++
}
}
if len(targets) > 1 {
fmt.Printf("%s %d/%d repositories deleted\n", col(cDark, "deleteremote:"), done, len(targets))
}
}
// remoteChoices lists the configured targets the way they may be selected, for
// the message a `deleteremote` without a target earns.
func remoteChoices(targets []RemoteTarget) string {
out := make([]string, 0, len(targets))
for _, t := range targets {
if h := remoteHost(t.URL); h != "" && !strings.EqualFold(h, t.Name) {
out = append(out, "@"+t.Name+" ("+h+")")
continue
}
out = append(out, "@"+t.Name)
}
return strings.Join(out, ", ")
}
// deleteOnRemote deletes repo on one target, after asking. It reports whether
// something was actually deleted — a repository that is not there, and a
// question answered with no, are not failures, and neither stops the remaining
// targets.
func deleteOnRemote(t RemoteTarget, repo string) bool {
api := newRemoteAPI(t.URL, t.Key, t.Type)
owner, err := api.authUser()
if err != nil {
errorln(t.Name + ": " + err.Error())
return false
}
fmt.Printf("%s %s %s (as %s)\n",
col(cDark, "remote"), col(cYellow, t.Name), col(cBlue, api.url), col(cGreen, owner))
exists, err := api.repoExists(owner, repo)
if err != nil {
errorln(t.Name + ": " + err.Error())
return false
}
if !exists {
fmt.Println(col(cDark, "no repository "+owner+"/"+repo+" there — nothing to delete"))
return false
}
fmt.Println(col(cOrange, "this deletes "+api.repoWebURL(owner, repo)+
" with its issues, releases and history, and cannot be undone"))
if !yesno("delete "+owner+"/"+repo+" on "+t.Name+"?", false) {
fmt.Println(col(cDark, "kept"))
return false
}
if err := api.deleteRepo(owner, repo); err != nil {
errorln(t.Name + ": " + err.Error())
return false
}
fmt.Println(col(cRed, "deleted ") + col(cWhite, owner+"/"+repo) + col(cDark, " on "+t.Name))
dropGitRemote(t.Name, api.repoWebURL(owner, repo))
return true
}
// dropGitRemote removes the local git remote named name, but only while it
// still points at web — the repository just deleted. A remote the user has
// since re-aimed somewhere else is theirs, not ours, and a `git push` failing
// against a repository that no longer exists is worse than no remote at all.
func dropGitRemote(name, web string) {
if !isDir(DIR + "/.git") {
return
}
url, err := gitCapture(DIR, "remote", "get-url", name)
if err != nil || strings.TrimSpace(url) != web {
return
}
if gitOK(DIR, "remote", "remove", name) {
fmt.Println(col(cDark, "removed git remote "+name))
}
}
+266
View File
@@ -0,0 +1,266 @@
package main
// deleteremote_test.go — the command that deletes a repository on a server mgsh
// does not own.
//
// Nothing it does can be undone from here, so the tests are less about the happy
// path than about the ways it must refuse: no target named, an unknown target, a
// question answered with no, a repository that is not there. Each of those has
// to leave the server untouched, which is asserted on the requests the fake
// provider received — the DELETE either went out or it did not.
import (
"strings"
"testing"
)
// mirrorServer is a fake Gitea holding one repository owned by "mike".
func mirrorServer(t *testing.T, project string) *fakeProvider {
t.Helper()
f := newFakeProvider(t)
f.route("GET /api/v1/user", 200, `{"login":"mike"}`)
f.route("GET /api/v1/repos/mike/"+project, 200, `{}`)
f.route("DELETE /api/v1/repos/mike/"+project, 204, "")
return f
}
// mirrorProject points the globals at a project with f as its single mirror
// target, and answers every y/n question with answer. It returns the project
// directory and the recorded questions.
func mirrorProject(t *testing.T, f *fakeProvider, name string, answer bool) (string, *[]string) {
t.Helper()
dir := useProject(t, name)
cfg.Remotes = []RemoteTarget{{Name: "gitea", URL: f.URL, Key: "tok", Type: "gitea"}}
return dir, fakeAnswers(t, answer)
}
// deleted reports whether the DELETE for project actually went to the server.
func deleted(f *fakeProvider, project string) bool {
return f.find("DELETE /api/v1/repos/mike/"+project) != nil
}
// TestDeleteRepoPerProvider pins the request each provider expects. GitLab
// answers 202 — the deletion is only scheduled — which still counts as success.
func TestDeleteRepoPerProvider(t *testing.T) {
for _, c := range []struct {
typ, path string
code int
}{
{"gitea", "/api/v1/repos/mike/mgsh", 204},
{"github", "/api/v3/repos/mike/mgsh", 204},
{"gitlab", "/api/v4/projects/mike%2Fmgsh", 202},
} {
f := newFakeProvider(t)
f.route("DELETE "+c.path, c.code, "")
if err := newRemoteAPI(f.URL, "tok", c.typ).deleteRepo("mike", "mgsh"); err != nil {
t.Fatalf("%s: deleteRepo: %v (requests: %v)", c.typ, err, f.paths())
}
if f.find("DELETE "+c.path) == nil {
t.Errorf("%s: expected DELETE %s, got %v", c.typ, c.path, f.paths())
}
}
}
// TestDeleteRepoNamesTheMissingPermission: deleting needs more of a token than
// pushing does, so a 403 here is the likeliest failure of all. Passing the bare
// status on would send people to the wrong setting.
func TestDeleteRepoNamesTheMissingPermission(t *testing.T) {
f := newFakeProvider(t)
f.route("DELETE /api/v3/repos/mike/mgsh", 403, `{"message":"Must have admin rights"}`)
err := newRemoteAPI(f.URL, "tok", "github").deleteRepo("mike", "mgsh")
if err == nil || !strings.Contains(err.Error(), "delete_repo") {
t.Errorf("403 error = %v, want it to name the delete_repo scope", err)
}
}
// TestDeleteRepoTreatsUnroutedAsFailure: anything the server did not accept must
// surface as an error, or a repository that is still there looks deleted.
func TestDeleteRepoReportsFailure(t *testing.T) {
f := newFakeProvider(t) // everything 404s
if err := newRemoteAPI(f.URL, "tok", "gitea").deleteRepo("mike", "mgsh"); err == nil {
t.Error("deleteRepo accepted a 404 as success")
}
}
// TestDeleteRemoteNeedsATarget is the one that matters most: `pushremote` with
// no @name means "every configured server", and inheriting that here would wipe
// the project off all of them at once.
func TestDeleteRemoteNeedsATarget(t *testing.T) {
f := mirrorServer(t, "notes")
_, asked := mirrorProject(t, f, "notes", true) // even a standing "yes"
out := captureStdout(t, func() { runCommand("deleteremote") })
if len(f.got) != 0 {
t.Fatalf("deleteremote talked to the server without being told where: %v", f.paths())
}
if len(*asked) != 0 {
t.Errorf("asked %q although no target was named", *asked)
}
if !strings.Contains(out, "@gitea") {
t.Errorf("output does not say which targets exist: %q", out)
}
}
// TestDeleteRemoteRejectsUnknownTarget: a mistyped server name must not fall
// back to some other target.
func TestDeleteRemoteRejectsUnknownTarget(t *testing.T) {
f := mirrorServer(t, "notes")
_, asked := mirrorProject(t, f, "notes", true)
out := captureStdout(t, func() { runCommand("deleteremote @gitae") })
if len(f.got) != 0 {
t.Fatalf("an unknown target still reached a server: %v", f.paths())
}
if len(*asked) != 0 {
t.Errorf("asked %q for an unknown target", *asked)
}
if !strings.Contains(out, "gitae") {
t.Errorf("unknown target not reported: %q", out)
}
}
// TestDeleteRemoteKeepsRepoWhenDeclined: the question is the last guard, so a
// "no" has to stop the DELETE, not just the message about it.
func TestDeleteRemoteKeepsRepoWhenDeclined(t *testing.T) {
f := mirrorServer(t, "notes")
_, asked := mirrorProject(t, f, "notes", false)
captureStdout(t, func() { runCommand("deleteremote @gitea") })
if len(*asked) != 1 {
t.Fatalf("questions asked = %q, want exactly one", *asked)
}
if !strings.Contains((*asked)[0], "mike/notes") {
t.Errorf("question %q does not name the repository being deleted", (*asked)[0])
}
if deleted(f, "notes") {
t.Fatalf("deleted the repository after the user declined: %v", f.paths())
}
}
// TestDeleteRemoteWithoutRepositoryAsksNothing: a project that was never
// mirrored (or a typo in the project name) is a no-op, not a question.
func TestDeleteRemoteWithoutRepositoryAsksNothing(t *testing.T) {
f := newFakeProvider(t)
f.route("GET /api/v1/user", 200, `{"login":"mike"}`) // the repo lookup 404s
_, asked := mirrorProject(t, f, "notes", true)
out := captureStdout(t, func() { runCommand("deleteremote @gitea") })
if len(*asked) != 0 {
t.Errorf("asked %q about a repository that is not there", *asked)
}
if deleted(f, "notes") {
t.Error("sent a DELETE for a repository the server does not have")
}
if !strings.Contains(out, "nothing to delete") {
t.Errorf("output = %q, want it to say there is nothing to delete", out)
}
}
// TestDeleteRemoteByHostAndGitRemoteCleanup covers the whole accepted path: the
// server picked by its host rather than its configured name, the DELETE sent,
// and the now-dangling git remote dropped — while an unrelated remote stays.
func TestDeleteRemoteByHostAndGitRemoteCleanup(t *testing.T) {
f := mirrorServer(t, "notes")
dir, asked := mirrorProject(t, f, "notes", true)
mustGit(t, dir, "init", "-q")
mustGit(t, dir, "remote", "add", "gitea", f.URL+"/mike/notes.git")
mustGit(t, dir, "remote", "add", "origin", "git@git.example:notes.git")
captureStdout(t, func() { runCommand("deleteremote " + remoteHost(f.URL)) })
if len(*asked) != 1 {
t.Fatalf("questions asked = %q, want exactly one", *asked)
}
if !deleted(f, "notes") {
t.Fatalf("no DELETE sent, requests: %v", f.paths())
}
remotes, err := gitCapture(dir, "remote")
if err != nil {
t.Fatal(err)
}
names := splitLines(strings.TrimSpace(remotes))
for _, n := range names {
if n == "gitea" {
t.Errorf("git remote gitea survived the deletion: %q", names)
}
}
if len(names) != 1 || names[0] != "origin" {
t.Errorf("git remotes = %q, want origin left alone", names)
}
}
// TestDeleteRemoteKeepsARepointedGitRemote: the local remote is only dropped
// while it still points at what was deleted. One the user has since aimed
// somewhere else is theirs.
func TestDeleteRemoteKeepsARepointedGitRemote(t *testing.T) {
f := mirrorServer(t, "notes")
dir, _ := mirrorProject(t, f, "notes", true)
mustGit(t, dir, "init", "-q")
mustGit(t, dir, "remote", "add", "gitea", "https://elsewhere.example/mike/notes.git")
captureStdout(t, func() { runCommand("deleteremote @gitea") })
if !deleted(f, "notes") {
t.Fatalf("no DELETE sent, requests: %v", f.paths())
}
url, err := gitCapture(dir, "remote", "get-url", "gitea")
if err != nil {
t.Fatalf("git remote gitea was removed although it pointed elsewhere: %v", err)
}
if strings.TrimSpace(url) != "https://elsewhere.example/mike/notes.git" {
t.Errorf("git remote url = %q, want it untouched", strings.TrimSpace(url))
}
}
// TestRemoteMatches: a target answers to its configured name and to the host of
// its url, in any case and with or without the '@'.
func TestRemoteMatches(t *testing.T) {
tgt := RemoteTarget{Name: "gitea", URL: "https://git.example.com:3000/root"}
for _, c := range []struct {
sel string
want bool
}{
{"gitea", true},
{"GITEA", true},
{"@gitea", true},
{"git.example.com", true},
{"https://git.example.com/mike/x", true},
{"git@git.example.com:mike/x.git", true},
{"example.com", false}, // a suffix is not the host
{"other", false},
{"@", false},
{"", false},
} {
if got := remoteMatches(tgt, c.sel); got != c.want {
t.Errorf("remoteMatches(%q) = %v, want %v", c.sel, got, c.want)
}
}
}
// TestParseRemoteSelectors: everything on the line selects a server, '@' or not
// — the command takes nothing else that a bare word could be confused with.
func TestParseRemoteSelectors(t *testing.T) {
for _, c := range []struct {
in string
want []string
}{
{"", nil},
{"@gitea", []string{"gitea"}},
{"git.example.com", []string{"git.example.com"}},
{"@gitea git.example.com", []string{"gitea", "git.example.com"}},
{"@", nil},
} {
got := parseRemoteSelectors(c.in)
if strings.Join(got, ",") != strings.Join(c.want, ",") {
t.Errorf("parseRemoteSelectors(%q) = %v, want %v", c.in, got, c.want)
}
}
}
+48 -4
View File
@@ -1,6 +1,7 @@
package main package main
import ( import (
"errors"
"os" "os"
"os/exec" "os/exec"
"path/filepath" "path/filepath"
@@ -127,8 +128,30 @@ var sshExec = func(remote string, capture bool) ([]byte, error) {
return out, err return out, err
} }
// errRemoteBang is what a command carrying a '!' comes back with, so a caller
// sees a failure rather than a command that quietly did something else.
var errRemoteBang = errors.New("remote command contains '!'")
// remoteRejected reports whether a command must not be sent at all, and says
// why. '!' is the one character shq cannot protect: csh expands history *before*
// it looks at quotes, and it does so non-interactively too — `echo 'fix!now'`
// answers "Event not found" on the tcsh server. No spelling survives both csh
// and sh, so the only safe move is not to send one. This is the place every
// remote command passes.
func remoteRejected(remote string) bool {
if !strings.ContainsRune(remote, '!') {
return false
}
errorln("not sending a command with '!' in it — the server's login shell " +
"would expand it instead of passing it on: " + remote)
return true
}
// ssh runs a single remote command over ssh with inherited stdio. // ssh runs a single remote command over ssh with inherited stdio.
func ssh(remote string) error { func ssh(remote string) error {
if remoteRejected(remote) {
return errRemoteBang
}
_, err := sshExec(remote, false) _, err := sshExec(remote, false)
return err return err
} }
@@ -144,6 +167,9 @@ func sshOK(remote string) bool {
// sshOut runs a remote command and returns its stdout split into lines. // sshOut runs a remote command and returns its stdout split into lines.
func sshOut(remote string) ([]string, error) { func sshOut(remote string) ([]string, error) {
if remoteRejected(remote) {
return nil, errRemoteBang
}
out, err := sshExec(remote, true) out, err := sshExec(remote, true)
lines := strings.Split(string(out), "\n") lines := strings.Split(string(out), "\n")
for len(lines) > 0 && lines[len(lines)-1] == "" { for len(lines) > 0 && lines[len(lines)-1] == "" {
@@ -152,10 +178,27 @@ func sshOut(remote string) ([]string, error) {
return lines, err return lines, err
} }
// serverPath anchors a path on the git server at the configured gitpath.
//
// Every remote command has to name its target outright, because the login
// directory of the git user is not necessarily the directory holding the bare
// repositories. With `gituser = git` and `gitpath = /home/git` the two are the
// same place and a bare "." worked by luck; with `gituser = root` and
// `gitpath = /root/mgsh` it lists the home directory, where there is nothing to
// find.
func serverPath(rel string) string {
base := strings.TrimRight(cfg.GitPath, "/")
rel = strings.TrimPrefix(strings.TrimSpace(rel), "./")
if rel == "" || rel == "." {
return base
}
return base + "/" + rel
}
// serverRepoNames lists the bare repositories on the git server, without the // serverRepoNames lists the bare repositories on the git server, without the
// ".git" suffix. // ".git" suffix.
func serverRepoNames() ([]string, error) { func serverRepoNames() ([]string, error) {
lines, err := sshOut("/bin/ls .") lines, err := sshOut("/bin/ls " + shq(serverPath(".")))
if err != nil { if err != nil {
return nil, err return nil, err
} }
@@ -169,10 +212,11 @@ func serverRepoNames() ([]string, error) {
} }
// 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 gitpath, which serverPath resolves). The error is returned
// folded into the bool so a failed lookup is never mistaken for "not there". // rather than folded into the bool so a failed lookup is never mistaken for
// "not there".
func serverEntryExists(path, entry string) (bool, error) { func serverEntryExists(path, entry string) (bool, error) {
lines, err := sshOut("/bin/ls " + shq(path)) lines, err := sshOut("/bin/ls " + shq(serverPath(path)))
if err != nil { if err != nil {
return false, err return false, err
} }
+49 -1
View File
@@ -45,6 +45,13 @@ var (
) )
func main() { func main() {
// Answered before setup(), which exits when nothing is configured yet: an
// update has to work on a machine that has never run mgsh, and `--version`
// is what the freshly downloaded binary is probed with, right there.
if updateFlags() {
return
}
setup() setup()
useColor = readline.IsTerminal(int(os.Stdout.Fd())) useColor = readline.IsTerminal(int(os.Stdout.Fd()))
@@ -68,12 +75,52 @@ func main() {
} }
updateDirState() updateDirState()
runCommand(cmdline) runCommand(cmdline)
updateNote() // after the output: a footer, not a headline
return return
} }
updateNote() // before the prompt: a session starts here, not when it ends
runInteractive() runInteractive()
} }
// updateFlags answers the self-update options and reports whether it did. They
// are deliberately spelled with dashes and kept out of parseArgs: `mgsh update`
// is the command for everyday use, and these are what works when there is no
// configuration to read yet.
func updateFlags() bool {
if len(os.Args) < 2 {
return false
}
switch os.Args[1] {
case "--version":
fmt.Printf("mgsh %s\n", VERSION)
case "--update":
if err := selfUpdate.install(os.Stdout); err != nil {
fmt.Fprintf(os.Stderr, "mgsh: %v\n", err)
os.Exit(1)
}
case "--check-update":
if err := selfUpdate.check(os.Stdout); err != nil {
fmt.Fprintf(os.Stderr, "mgsh: %v\n", err)
os.Exit(1)
}
case updateRefreshFlag: // the background run, not in the help
selfUpdate.refresh()
default:
return false
}
return true
}
// updateNote prints the once-a-day hint, when there is one. It costs nothing:
// the line comes from the note in the cache directory, and the asking behind it
// happens in the background, at most once a day.
func updateNote() {
if hint := selfUpdate.daily(); hint != "" {
fmt.Fprintln(os.Stderr, col(cDark, hint))
}
}
// runInteractive drives the colored, history- and completion-enabled REPL. // runInteractive drives the colored, history- and completion-enabled REPL.
func runInteractive() { func runInteractive() {
home, _ := os.UserHomeDir() home, _ := os.UserHomeDir()
@@ -145,9 +192,10 @@ func parseArgs() (int, string, bool) {
} }
cls := map[string]int{ cls := map[string]int{
"clone": 2, "init": 2, "log": 2, "clone": 2, "init": 2, "log": 2,
"push": 1, "pushremote": 1, "list": 1, "tag": 1, "archive": 1, "show": 1, "push": 1, "pushremote": 1, "deleteremote": 1, "list": 1, "tag": 1, "archive": 1, "show": 1,
"pull": 1, "fetch": 1, "status": 1, "diff": 1, "overview": 1, "pull": 1, "fetch": 1, "status": 1, "diff": 1, "overview": 1,
"config": 1, "count": 1, "login": 1, "cloneall": 1, "release": 1, "config": 1, "count": 1, "login": 1, "cloneall": 1, "release": 1,
"update": 1,
} }
if c, ok := cls[a0]; ok { if c, ok := cls[a0]; ok {
return c, strings.Join(os.Args[1:], " "), false return c, strings.Join(os.Args[1:], " "), false
+72 -1
View File
@@ -109,7 +109,7 @@ func TestFormatRepoList(t *testing.T) {
colored := formatRepoList(entries, false) colored := formatRepoList(entries, false)
useColor = false useColor = false
strip := func(s string) string { strip := func(s string) string {
for _, c := range []string{cReset, cGreen, cYellow, cGray} { for _, c := range []string{cReset, cGreen, cYellow, cDark} {
s = strings.ReplaceAll(s, c, "") s = strings.ReplaceAll(s, c, "")
} }
return s return s
@@ -499,6 +499,77 @@ remotes = hub, public
} }
} }
// TestInactiveRemoteIsOnlyUsedWhenNamed: `active = false` takes a target off the
// automatic path — not used when no name is given — while naming it, by name or
// by host, still reaches it. It is not the same as removing the target, and not
// the same as an unusable one: it stays in the configuration, listed and ready.
func TestInactiveRemoteIsOnlyUsedWhenNamed(t *testing.T) {
rc := `
remote.gitea.url = https://git.example.com
remote.gitea.key = tok
remote.gitea.active = false
remote.hub.url = https://github.com
remote.hub.key = tok2
`
var c Config
applyConfig(&c, parseConfig(rc))
all, incomplete := c.mirrorTargets()
if len(all) != 2 || len(incomplete) != 0 {
t.Fatalf("mirrorTargets = %+v (incomplete %v), want both targets", all, incomplete)
}
if got := pickRemotes(all, nil); len(got) != 1 || got[0].Name != "hub" {
t.Errorf("selection without a name = %+v, want only the active hub", got)
}
for _, sel := range []string{"gitea", "@gitea", "git.example.com"} {
if got := pickRemotes(all, []string{sel}); len(got) != 1 || got[0].Name != "gitea" {
t.Errorf("pickRemotes(%q) = %+v, want the inactive gitea", sel, got)
}
}
// MGSH_REMOTE_<NAME>_ACTIVE is the environment spelling
t.Setenv("MGSH_REMOTE_HUB_ACTIVE", "false")
var c2 Config
applyConfig(&c2, parseConfig(rc))
applyEnv(&c2)
all2, _ := c2.mirrorTargets()
if got := pickRemotes(all2, nil); len(got) != 0 {
t.Errorf("MGSH_REMOTE_HUB_ACTIVE=false left %+v in the default set", got)
}
}
// TestRemoteActiveDefaultsToOn: the setting exists to take a server *out* of the
// default set, so anything but a deliberate "off" — unset, "true", a typo — has
// to leave it in. A configuration written before the flag existed must not
// suddenly stop mirroring.
func TestRemoteActiveDefaultsToOn(t *testing.T) {
for _, v := range []string{"", "true", "yes", "1", "on", "sometimes"} {
if !(RemoteTarget{Active: v}).isActive() {
t.Errorf("active = %q switched the target off", v)
}
}
for _, v := range []string{"false", "no", "0", "off", " FALSE "} {
if (RemoteTarget{Active: v}).isActive() {
t.Errorf("active = %q did not switch the target off", v)
}
}
}
// TestPushRemoteWithOnlyInactiveTargets: the message has to name the reason.
// "needs a remote.<name>.url" would send the user looking for a setting that is
// sitting right there in the file.
func TestPushRemoteWithOnlyInactiveTargets(t *testing.T) {
dir := useProject(t, "notes")
mustGit(t, dir, "init", "-q")
cfg.Remotes = []RemoteTarget{{Name: "gitea", URL: "https://git.example.com", Key: "tok", Active: "false"}}
out := captureStdout(t, func() { runCommand("pushremote") })
if !strings.Contains(out, "inactive") || !strings.Contains(out, "@gitea") {
t.Errorf("output = %q, want it to report the inactive target and how to name it", out)
}
}
func TestParsePushRemoteArgs(t *testing.T) { func TestParsePushRemoteArgs(t *testing.T) {
cases := []struct { cases := []struct {
in string in string
+9 -2
View File
@@ -26,8 +26,13 @@ gitpath = /home/git
# and visibility. <name> is yours to pick and becomes the git remote created in # and visibility. <name> is yours to pick and becomes the git remote created in
# the repository, so `git push gitlab` keeps working outside mgsh. # the repository, so `git push gitlab` keeps working outside mgsh.
# #
# `pushremote` pushes to every configured server in the order given, # `pushremote` pushes to every active server in the order given,
# `pushremote @gitlab` to a single one. # `pushremote @gitlab` to a single one (active or not).
#
# `deleteremote @gitlab` (or `deleteremote gitlab.example.com`) deletes the
# project's repository there again, after asking. It always needs the server
# named, and the token needs permission to delete: delete_repo on GitHub,
# write:repository on Gitea, api + Owner on GitLab.
# #
# remote.gitea.url = https://git.example.com # remote.gitea.url = https://git.example.com
# remote.gitea.key = <personal-access-token> # remote.gitea.key = <personal-access-token>
@@ -38,6 +43,8 @@ gitpath = /home/git
# remote.gitlab.key = <personal-access-token> # remote.gitlab.key = <personal-access-token>
# remote.gitlab.type = gitlab # remote.gitlab.type = gitlab
# remote.gitlab.visibility = public # remote.gitlab.visibility = public
# remote.gitlab.active = false # default true; false = only used when
# # named, e.g. `pushremote @gitlab`
# #
# remotes = gitea, gitlab # optional: restrict and order the set # remotes = gitea, gitlab # optional: restrict and order the set
# mirror = true # `push` also mirrors via pushremote # mirror = true # `push` also mirrors via pushremote
+7 -7
View File
@@ -82,7 +82,7 @@ func overviewAll() {
}) })
if len(rows) == 0 { if len(rows) == 0 {
fmt.Println(col(cGray, "nothing under "+BASE)) fmt.Println(col(cDark, "nothing under "+BASE))
return return
} }
@@ -109,9 +109,9 @@ func overviewAll() {
if initN > 0 { if initN > 0 {
summary += fmt.Sprintf(" · %d to init", initN) summary += fmt.Sprintf(" · %d to init", initN)
} }
fmt.Println(col(cGray, summary)) fmt.Println(col(cDark, summary))
if srv.err != nil { if srv.err != nil {
fmt.Println(col(cGray, " git server not reachable — local view only")) fmt.Println(col(cDark, " git server not reachable — local view only"))
} }
} }
@@ -301,7 +301,7 @@ func syncColor(s projStatus) string {
case s.ahead > 0: case s.ahead > 0:
return cGreen return cGreen
default: default:
return cGray return cDark
} }
} }
@@ -339,8 +339,8 @@ func formatProjStatus(s projStatus, w overviewWidths) string {
if !s.lastWhen.IsZero() { if !s.lastWhen.IsZero() {
age = shortAge(time.Since(s.lastWhen)) age = shortAge(time.Since(s.lastWhen))
} }
b.WriteString(" " + col(cGray, padRight(s.lastHost, w.host))) b.WriteString(" " + col(cDark, padRight(s.lastHost, w.host)))
b.WriteString(" " + col(cGray, fmt.Sprintf("%4s", age))) b.WriteString(" " + col(cDark, fmt.Sprintf("%4s", age)))
} }
if w.hint { if w.hint {
hint := "" hint := ""
@@ -350,7 +350,7 @@ func formatProjStatus(s projStatus, w overviewWidths) string {
b.WriteString(" " + col(cYellow, padRight(hint, hintWidth))) b.WriteString(" " + col(cYellow, padRight(hint, hintWidth)))
} }
if len(s.mirrors) > 0 { if len(s.mirrors) > 0 {
b.WriteString(col(cGray, " → "+strings.Join(s.mirrors, " "))) b.WriteString(col(cDark, " → "+strings.Join(s.mirrors, " ")))
} }
return strings.TrimRight(b.String(), " ") return strings.TrimRight(b.String(), " ")
} }
+5 -5
View File
@@ -45,21 +45,21 @@ func plainPrompt() string {
func coloredPrompt() string { func coloredPrompt() string {
var b strings.Builder var b strings.Builder
b.WriteString(cBold + cPurple + "< " + cReset) b.WriteString(cBold + cViolet + "< " + cReset)
b.WriteString(cCyan + filepath.Base(BASE) + cReset) b.WriteString(cBlue + filepath.Base(BASE) + cReset)
if PRJ != "" && isDir(BASE+"/"+PRJ) { if PRJ != "" && isDir(BASE+"/"+PRJ) {
b.WriteString(cBold + cWhite + "/" + cReset + cBold + cGreen + PRJ + cReset) b.WriteString(cBold + cWhite + "/" + cReset + cBold + cGreen + PRJ + cReset)
if BPLSTATE != "" { if BPLSTATE != "" {
b.WriteString(cRed + BPLSTATE + cReset) b.WriteString(cRed + BPLSTATE + cReset)
} }
if BRANCH != "" { if BRANCH != "" {
b.WriteString(cGray + " (" + cReset + cCyan + BRANCH + cReset) b.WriteString(cDark + " (" + cReset + cBlue + BRANCH + cReset)
if DIRTY { if DIRTY {
b.WriteString(cRed + "*" + cReset) b.WriteString(cRed + "*" + cReset)
} }
b.WriteString(cGray + ")" + cReset) b.WriteString(cDark + ")" + cReset)
} }
} }
b.WriteString(" " + cBold + cPurple + ">" + cReset + " ") b.WriteString(" " + cBold + cViolet + ">" + cReset + " ")
return b.String() return b.String()
} }
+8 -10
View File
@@ -169,15 +169,13 @@ func handleRelease(args string) {
return return
} }
targets, incomplete := cfg.mirrorTargets() configured, incomplete := cfg.mirrorTargets()
for _, n := range incomplete { for _, n := range incomplete {
errorln("remote " + n + ": url or key missing — skipped") errorln("remote " + n + ": url or key missing — skipped")
} }
targets = pickRemotes(targets, names) targets := pickRemotes(configured, names)
if len(targets) == 0 { if len(targets) == 0 {
if len(names) == 0 { // an unknown @name already reported itself reportNoTargets("release", configured, names)
errorln("release needs a mirror target — see 'config'")
}
return return
} }
@@ -193,8 +191,8 @@ func handleRelease(args string) {
errorln("skipping " + s + ": another directory already contributes that name") errorln("skipping " + s + ": another directory already contributes that name")
} }
if len(assets) > 0 { if len(assets) > 0 {
fmt.Printf("%s %s %s\n", col(cGray, "attaching"), col(cYellow, assetSummary(assets)), fmt.Printf("%s %s %s\n", col(cDark, "attaching"), col(cYellow, assetSummary(assets)),
col(cGray, "from "+strings.Join(assetDirList(DIR), " and "))) col(cDark, "from "+strings.Join(assetDirList(DIR), " and ")))
} }
done := 0 done := 0
@@ -204,7 +202,7 @@ func handleRelease(args string) {
} }
} }
if len(targets) > 1 { if len(targets) > 1 {
fmt.Printf("%s %d/%d remotes released\n", col(cGray, "release:"), done, len(targets)) fmt.Printf("%s %d/%d remotes released\n", col(cDark, "release:"), done, len(targets))
} }
} }
@@ -303,8 +301,8 @@ func publishRelease(t RemoteTarget, repo, tag, body string, assets []releaseAsse
if rel.Prerelease { if rel.Prerelease {
what += " (pre-release)" what += " (pre-release)"
} }
fmt.Printf("%s %s %s %s\n", col(cGray, "remote"), col(cYellow, t.Name), fmt.Printf("%s %s %s %s\n", col(cDark, "remote"), col(cYellow, t.Name),
col(cGreen, what), col(cCyan, api.repoWebURL(owner, repo))) col(cGreen, what), col(cBlue, api.repoWebURL(owner, repo)))
if len(assets) > 0 { if len(assets) > 0 {
if err := api.uploadAssets(owner, repo, ref, assets); err != nil { if err := api.uploadAssets(owner, repo, ref, assets); err != nil {
+72 -12
View File
@@ -238,18 +238,64 @@ func parsePushRemoteArgs(args string) (names []string, description string) {
return names, strings.Join(fields[i:], " ") return names, strings.Join(fields[i:], " ")
} }
// remoteHost reduces a configured url — or whatever a user typed in its place —
// to its bare host: scheme, credentials, port and path removed.
func remoteHost(s string) string {
s = strings.TrimSpace(s)
if i := strings.Index(s, "://"); i >= 0 {
s = s[i+3:]
}
if i := strings.IndexByte(s, '@'); i >= 0 { // user[:pass]@host
s = s[i+1:]
}
if i := strings.IndexAny(s, "/:"); i >= 0 {
s = s[:i]
}
return s
}
// remoteMatches reports whether sel picks target t. A target answers both to its
// configured name and to the host of its url: the name is what the config calls
// the server, the host is what the user sees in the browser, and `@gitea` should
// not be the only way to say `git.example.com`.
func remoteMatches(t RemoteTarget, sel string) bool {
sel = strings.TrimPrefix(strings.TrimSpace(sel), "@")
if sel == "" {
return false
}
if strings.EqualFold(t.Name, sel) {
return true
}
h := remoteHost(t.URL)
return h != "" && strings.EqualFold(h, remoteHost(sel))
}
// activeRemotes keeps the targets that take part when a command was given no
// target of its own (see RemoteTarget.isActive).
func activeRemotes(all []RemoteTarget) []RemoteTarget {
out := make([]RemoteTarget, 0, len(all))
for _, t := range all {
if t.isActive() {
out = append(out, t)
}
}
return out
}
// pickRemotes narrows all to the explicitly requested names, complaining about // pickRemotes narrows all to the explicitly requested names, complaining about
// any that are not configured. With no names given, all targets are used. // any that are not configured. With no names given the active targets are used
// — `active = false` takes a server off that automatic path, and naming it is
// what puts it back on.
func pickRemotes(all []RemoteTarget, names []string) []RemoteTarget { func pickRemotes(all []RemoteTarget, names []string) []RemoteTarget {
if len(names) == 0 { if len(names) == 0 {
return all return activeRemotes(all)
} }
var out []RemoteTarget var out []RemoteTarget
seen := map[string]bool{} seen := map[string]bool{}
for _, n := range names { for _, n := range names {
found := false found := false
for _, t := range all { for _, t := range all {
if !strings.EqualFold(t.Name, n) { if !remoteMatches(t, n) {
continue continue
} }
if !seen[t.Name] { // `@hub @hub` must not push twice if !seen[t.Name] { // `@hub @hub` must not push twice
@@ -266,8 +312,24 @@ func pickRemotes(all []RemoteTarget, names []string) []RemoteTarget {
return out return out
} }
// reportNoTargets explains an empty selection. There are three ways to end up
// with nothing to push to, and they need different answers: nothing configured
// at all, everything configured switched to `active = false`, or a name nobody
// recognised — which pickRemotes has already complained about by itself.
func reportNoTargets(cmd string, configured []RemoteTarget, names []string) {
switch {
case len(names) > 0:
return
case len(configured) > 0:
errorln("every configured remote is inactive — name one, e.g. '" +
cmd + " @" + configured[0].Name + "'")
default:
errorln(cmd + " needs a 'remote.<name>.url' and 'remote.<name>.key' in " + configFile())
}
}
// handlePushRemote implements `pushremote [@name ...] [description]`. Without // handlePushRemote implements `pushremote [@name ...] [description]`. Without
// a @name it mirrors to every configured target; the description, if given, is // a @name it mirrors to every active target; the description, if given, is
// set on the repository when it is created. // set on the repository when it is created.
func handlePushRemote(args string) { func handlePushRemote(args string) {
if !requireRepo() { if !requireRepo() {
@@ -275,15 +337,13 @@ func handlePushRemote(args string) {
} }
names, description := parsePushRemoteArgs(args) names, description := parsePushRemoteArgs(args)
targets, incomplete := cfg.mirrorTargets() configured, incomplete := cfg.mirrorTargets()
for _, n := range incomplete { for _, n := range incomplete {
errorln("remote " + n + ": url or key missing — skipped") errorln("remote " + n + ": url or key missing — skipped")
} }
targets = pickRemotes(targets, names) targets := pickRemotes(configured, names)
if len(targets) == 0 { if len(targets) == 0 {
if len(names) == 0 { // an unknown @name already reported itself reportNoTargets("pushremote", configured, names)
errorln("pushremote needs a 'remote.<name>.url' and 'remote.<name>.key' in " + configFile())
}
return return
} }
@@ -296,7 +356,7 @@ func handlePushRemote(args string) {
} }
} }
if len(targets) > 1 { if len(targets) > 1 {
fmt.Printf("%s %d/%d remotes updated\n", col(cGray, "pushremote:"), done, len(targets)) fmt.Printf("%s %d/%d remotes updated\n", col(cDark, "pushremote:"), done, len(targets))
} }
} }
@@ -313,7 +373,7 @@ func pushToRemote(t RemoteTarget, repo, description string) bool {
return false return false
} }
fmt.Printf("%s %s %s (as %s)\n", fmt.Printf("%s %s %s (as %s)\n",
col(cGray, "remote"), col(cYellow, t.Name), col(cCyan, api.url), col(cGreen, owner)) col(cDark, "remote"), col(cYellow, t.Name), col(cBlue, api.url), col(cGreen, owner))
exists, err := api.repoExists(owner, repo) exists, err := api.repoExists(owner, repo)
if err != nil { if err != nil {
@@ -343,7 +403,7 @@ func pushToRemote(t RemoteTarget, repo, description string) bool {
return false return false
} }
gitPushHeader(DIR, t.Name, header, "--tags") gitPushHeader(DIR, t.Name, header, "--tags")
fmt.Println(col(cGreen, "pushed to ") + col(cCyan, web)) fmt.Println(col(cGreen, "pushed to ") + col(cBlue, web))
return true return true
} }
+104 -9
View File
@@ -101,7 +101,7 @@ func TestInitKeepsServerRepoWhenDeclined(t *testing.T) {
if c := findCmd(*sent, "rm -rf"); c != "" { if c := findCmd(*sent, "rm -rf"); c != "" {
t.Fatalf("init destroyed the server repository after the user declined: %q", c) t.Fatalf("init destroyed the server repository after the user declined: %q", c)
} }
if c := findCmd(*sent, "git --bare init"); c != "" { if c := findCmd(*sent, "git init --bare"); c != "" {
t.Fatalf("init re-created the repository after the user declined: %q", c) t.Fatalf("init re-created the repository after the user declined: %q", c)
} }
} }
@@ -147,7 +147,7 @@ func TestInitOnFreshProjectCreatesRepo(t *testing.T) {
if findCmd(*sent, "rm -rf "+shq("/home/git/notes.git")) == "" { if findCmd(*sent, "rm -rf "+shq("/home/git/notes.git")) == "" {
t.Errorf("init did not clear the target path, sent: %q", *sent) t.Errorf("init did not clear the target path, sent: %q", *sent)
} }
if c := findCmd(*sent, "git --bare init"); !strings.Contains(c, shq("/home/git/notes.git")) { if c := findCmd(*sent, "git init --bare"); !strings.Contains(c, shq("/home/git/notes.git")) {
t.Errorf("init did not create the bare repository at the configured path: %q", c) t.Errorf("init did not create the bare repository at the configured path: %q", c)
} }
} }
@@ -224,25 +224,110 @@ func TestArchiveNamesAndQuotesSnapshot(t *testing.T) {
} }
cp, tar, rm := (*sent)[1], (*sent)[2], (*sent)[3] cp, tar, rm := (*sent)[1], (*sent)[2], (*sent)[3]
// name is <project>_<stamp>_<sanitised comment> // name is <project>_<stamp>_<sanitised comment>, and every path is spelled
// out from gitpath — the login directory is not necessarily the same place
stamp := archiveStamp() stamp := archiveStamp()
name := "notes_" + stamp + "_before_rewrite" name := "notes_" + stamp + "_before_rewrite"
if !strings.HasPrefix(cp, "cp -r "+shq("notes.git")+" ") { // the archive directory is made first: on a server where nobody created it,
// the copy used to fail with a raw cp error
if !strings.HasPrefix(cp, "mkdir -p "+shq("/home/git/archive")+" && cp -r "+shq("/home/git/notes.git")+" ") {
t.Errorf("cp command = %q", cp) t.Errorf("cp command = %q", cp)
} }
if !strings.Contains(cp, shq("archive/"+name+".git")) { if !strings.Contains(cp, shq("/home/git/archive/"+name+".git")) {
t.Errorf("cp target = %q, want it to contain %q", cp, name) t.Errorf("cp target = %q, want it to contain %q", cp, name)
} }
// `cd archive && tar`, not `cd archive;tar`: a failed cd must not let tar // `tar -C <dir>`, not `cd <dir> && tar`: on the tcsh server `cd` is aliased
// run in the login directory // to `cd !*;echo $cwd`, which turns the guard into `cd X; echo && tar` —
if !strings.HasPrefix(tar, "cd archive && tar ") || !strings.Contains(tar, shq(name+".git.tar.gz")) { // tar then runs in the login directory even when the cd failed, and the
// whole command still reports success
if !strings.HasPrefix(tar, "tar cvzf "+shq("/home/git/archive/"+name+".git.tar.gz")+" -C "+shq("/home/git/archive")+" ") ||
!strings.Contains(tar, shq(name+".git")) {
t.Errorf("tar command = %q", tar) t.Errorf("tar command = %q", tar)
} }
if rm != "rm -rf "+shq("archive/"+name+".git") { if rm != "rm -rf "+shq("/home/git/archive/"+name+".git") {
t.Errorf("cleanup command = %q", rm) t.Errorf("cleanup command = %q", rm)
} }
} }
// TestServerCommandsAreAnchoredAtGitPath: mgsh used to address the server
// through the login directory, which only worked because `gituser = git` and
// `gitpath = /home/git` happen to be the same place. With `gituser = root` and
// `gitpath = /root/mgsh` every command went to /root instead — `list` came back
// empty and `archive` had nothing to copy.
func TestServerCommandsAreAnchoredAtGitPath(t *testing.T) {
useProject(t, "notes")
cfg.GitPath = "/root/mgsh" // the repositories are NOT in the login directory
sent := fakeServer(t, func(cmd string) (string, error) {
if strings.HasPrefix(cmd, "/bin/ls") {
return "notes.git\n", nil
}
return "", nil
})
captureStdout(t, func() {
runCommand("list")
runCommand("list -a")
runCommand("show notes")
runCommand("archive")
})
if len(*sent) == 0 {
t.Fatal("no remote commands recorded")
}
for _, c := range *sent {
if !strings.Contains(c, "/root/mgsh") {
t.Errorf("remote command not anchored at gitpath: %q", c)
}
// No `cd` either. On the tcsh server it is aliased to `cd !*;echo $cwd`,
// which splits `cd X && Y` into `cd X; echo $cwd && Y`: Y runs even when
// the cd failed, and the command still exits 0. Every tool mgsh uses can
// be told its directory instead — tar -C, git --git-dir, find <path>.
if strings.HasPrefix(c, "cd ") || strings.Contains(c, " cd ") {
t.Errorf("remote command relies on cd: %q", c)
}
}
}
// TestRemoteCommandsRefuseExclamationMark: '!' is the one character shq cannot
// protect. csh expands history before it looks at quotes, and does so
// non-interactively too, so `cp -r '/home/git/wei!rd.git' …` arrives as
// something else. Such a command must not be sent at all.
func TestRemoteCommandsRefuseExclamationMark(t *testing.T) {
useProject(t, "wei!rd")
sent := fakeServer(t, func(cmd string) (string, error) {
if strings.HasPrefix(cmd, "/bin/ls") {
return "wei!rd.git\n", nil // the repository is there, so both proceed
}
return "", nil
})
out := captureStdout(t, func() {
runCommand("show wei!rd")
runCommand("archive")
})
for _, c := range *sent {
if strings.Contains(c, "!") {
t.Errorf("sent a command containing '!': %q", c)
}
}
if c := findCmd(*sent, "--git-dir"); c != "" {
t.Errorf("show sent %q despite the '!' in the name", c)
}
if c := findCmd(*sent, "cp -r"); c != "" {
t.Errorf("archive sent %q despite the '!' in the name", c)
}
if !strings.Contains(out, "not sending") {
t.Errorf("output = %q, want the refusal to say what it did not do", out)
}
// the other way in is an archive comment, which is why sanitizeComment
// drops the character before it ever becomes part of a name
if got := sanitizeComment("fix!now"); strings.ContainsRune(got, '!') {
t.Errorf("sanitizeComment(%q) = %q, want the '!' gone", "fix!now", got)
}
}
// TestCloneRefusesUnknownRepository: `clone` must not start a git clone for a // TestCloneRefusesUnknownRepository: `clone` must not start a git clone for a
// repository the server does not list. // repository the server does not list.
func TestCloneRefusesUnknownRepository(t *testing.T) { func TestCloneRefusesUnknownRepository(t *testing.T) {
@@ -389,5 +474,15 @@ func TestListSendsNoShellSpecificSyntax(t *testing.T) {
if strings.Contains(c, "2>") || strings.Contains(c, "&>") { if strings.Contains(c, "2>") || strings.Contains(c, "&>") {
t.Errorf("remote command uses sh-only redirection: %q", c) t.Errorf("remote command uses sh-only redirection: %q", c)
} }
// A glob is expanded by that same login shell, and a non-interactive
// zsh that finds nothing to match does not pass the pattern on like sh
// does — it fails the command outright ("no matches found: *.git"),
// which is how an empty server came to be reported as unreachable.
for _, idx := range indexesOf(c, "*") {
if idx == 0 || c[idx-1] != '\'' {
t.Errorf("unquoted glob in remote command: %q", c)
break
}
}
} }
} }
+2 -2
View File
@@ -191,9 +191,9 @@ func secretsApproved(dir string) bool {
where += ":" + strconv.Itoa(h.lineNo) where += ":" + strconv.Itoa(h.lineNo)
} }
fmt.Printf(" %s %s\n %s\n", fmt.Printf(" %s %s\n %s\n",
col(cYellow, where), col(cGray, h.kind), col(cRed, ellipsis(strings.TrimSpace(h.text), 100))) col(cYellow, where), col(cDark, h.kind), col(cRed, ellipsis(strings.TrimSpace(h.text), 100)))
} }
fmt.Println(col(cGray, " (set 'secretscan = off' to skip this check)")) fmt.Println(col(cDark, " (set 'secretscan = off' to skip this check)"))
return yesno("push anyway?", false) return yesno("push anyway?", false)
} }
+484
View File
@@ -0,0 +1,484 @@
// selfupdate.go — updating oneself from the releases of a Gitea instance.
//
// The file is meant to be copied: take it into another program, adjust the
// configuration block below, hang `--update` and `--check-update` into the
// options — done. It needs nothing but the standard library, and apart from
// that block it brings no names that do not begin with "selfUpdate" or
// "update".
//
// It assumes the layout build.sh produces: one release per version, whose tag
// is the bare number (4.0.64, a leading "v" is allowed), holding one asset
// "<name>-<goos>-<goarch>" each — that is, exactly the files from ./bin. Under
// /api/v1/repos/<owner>/<repo>/releases/latest Gitea hands out the newest
// release that is neither a draft nor a prerelease; GitHub speaks the same
// route with different field names and is therefore not covered.
package main
import (
"context"
"encoding/json"
"errors"
"fmt"
"io"
"net/http"
"net/url"
"os"
"os/exec"
"path/filepath"
"runtime"
"strconv"
"strings"
"time"
)
// ------------------------------------------------------------ Configuration
var selfUpdate = selfUpdater{
repo: "https://git.micw.org/mike/mgsh",
asset: "mgsh",
current: VERSION, // from main.go, set by -ldflags
verify: []string{"--version"},
every: 24 * time.Hour,
quietEnv: "MGSH_NO_UPDATE_CHECK",
}
type selfUpdater struct {
repo string // repo URL as in the browser: https://host/owner/repo
asset string // base name of the assets, "-<goos>-<goarch>" is added
current string // the running version
verify []string // trial run of the download; empty skips it
every time.Duration // how often to look on its own; 0 turns that off
quietEnv string // this environment variable set: keep quiet as well
}
// updateRefreshFlag is the option the program calls itself with, in the
// background. It is deliberately absent from the help.
const updateRefreshFlag = "--update-refresh"
// ------------------------------------------------------------ Looking by itself
// daily is the hook for the ordinary run of the program. It costs nothing: in
// the foreground the network is never touched. What comes back is the line
// pointing at a new version — or "", when there is nothing to say; what it
// looks like is up to the caller. Should the note be older than `every`, daily
// starts a background run on the side, whose answer the next call will find
// waiting.
func (u selfUpdater) daily() string {
if u.every <= 0 || os.Getenv(u.quietEnv) != "" || !updateOnTerminal() {
return ""
}
st := u.loadState() // no file: the zero value, hence due at once
if time.Since(st.Checked) >= u.every {
// The timestamp moves on before the asking, not after: otherwise two
// simultaneous runs start two queries, and a server that is not in the
// mood would get a new one on every call. If the note does not stay
// put, nothing is asked either — else an unwritable cache directory
// would mean one process per call.
st.Checked = time.Now()
if u.saveState(st) == nil {
u.spawnRefresh()
}
}
if st.Latest == "" || updateCompare(st.Latest, u.current) <= 0 {
return ""
}
return fmt.Sprintf("%s %s is available, run '%s --update'", u.asset, st.Latest, u.asset)
}
// refresh is the background run: ask, write it down, stay quiet. The writing
// down is done by latest; if the query fails, the old state remains.
func (u selfUpdater) refresh() {
_, _ = u.latest()
}
// spawnRefresh calls this program once more, only to ask, and does not wait.
// Without a Wait the child is adopted by init when this process ends — it thus
// outlives the call, and the call's output stays untouched by it.
func (u selfUpdater) spawnRefresh() {
exe, err := os.Executable()
if err != nil {
return
}
cmd := exec.Command(exe, updateRefreshFlag)
cmd.Stdin, cmd.Stdout, cmd.Stderr = nil, nil, nil // everything to /dev/null
if cmd.Start() == nil {
cmd.Process.Release()
}
}
// The hint is meant for the person sitting there. Running in a pipe, in a
// script or under cron, the program neither asks nor says anything.
func updateOnTerminal() bool {
st, err := os.Stderr.Stat()
return err == nil && st.Mode()&os.ModeCharDevice != 0
}
// --------------------------------------------------------------------- Note
// updateState is what is left between two calls: when the last question was
// asked and what came of it.
type updateState struct {
Checked time.Time `json:"checked"`
Latest string `json:"latest"`
}
// The note lives in the cache directory, not in the configuration: if it gets
// lost, the only cost is asking once too early.
func (u selfUpdater) statePath() (string, error) {
dir, err := os.UserCacheDir()
if err != nil {
return "", err
}
return filepath.Join(dir, u.asset, "update.json"), nil
}
func (u selfUpdater) loadState() updateState {
var st updateState
path, err := u.statePath()
if err != nil {
return st
}
b, err := os.ReadFile(path)
if err != nil {
return st
}
json.Unmarshal(b, &st) // a broken file counts as none
return st
}
func (u selfUpdater) saveState(st updateState) error {
path, err := u.statePath()
if err != nil {
return err
}
if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
return err
}
b, err := json.Marshal(st)
if err != nil {
return err
}
// By way of a file alongside, so that a simultaneous run never comes upon
// half a JSON.
tmp := path + ".new"
if err := os.WriteFile(tmp, b, 0o644); err != nil {
return err
}
if err := os.Rename(tmp, path); err != nil {
os.Remove(tmp)
return err
}
return nil
}
// ------------------------------------------------------------------ The work
// check only looks and touches nothing.
func (u selfUpdater) check(w io.Writer) error {
rel, err := u.latest()
if err != nil {
return err
}
if updateCompare(rel.TagName, u.current) <= 0 {
fmt.Fprintf(w, "%s %s is up to date\n", u.asset, u.current)
return nil
}
fmt.Fprintf(w, "%s %s is available, running %s\n %s\n run '%s --update' to install it\n",
u.asset, rel.TagName, u.current, rel.HTMLURL, u.asset)
return nil
}
// install fetches the newest release and replaces the running file with it.
func (u selfUpdater) install(w io.Writer) error {
rel, err := u.latest()
if err != nil {
return err
}
if updateCompare(rel.TagName, u.current) <= 0 {
fmt.Fprintf(w, "%s %s is up to date\n", u.asset, u.current)
return nil
}
want := fmt.Sprintf("%s-%s-%s", u.asset, runtime.GOOS, runtime.GOARCH)
var src *updateAsset
for i := range rel.Assets {
if rel.Assets[i].Name == want {
src = &rel.Assets[i]
break
}
}
if src == nil {
names := make([]string, len(rel.Assets))
for i, a := range rel.Assets {
names[i] = a.Name
}
return fmt.Errorf("release %s has no %q (only %s)", rel.TagName, want, strings.Join(names, ", "))
}
exe, err := os.Executable()
if err != nil {
return fmt.Errorf("cannot locate the running binary: %w", err)
}
// An installed mgsh is often a symlink into ./bin. What should be replaced
// is the file behind it, not the link.
if real, err := filepath.EvalSymlinks(exe); err == nil {
exe = real
}
mode := os.FileMode(0o755)
if st, err := os.Stat(exe); err == nil {
mode = st.Mode().Perm()
}
fmt.Fprintf(w, "downloading %s %s (%s)\n", want, rel.TagName, updateSize(src.Size))
tmp, err := u.download(src, exe, mode)
if err != nil {
return err
}
defer os.Remove(tmp) // only bites when the renaming below falls through
if err := u.probe(tmp, rel.TagName); err != nil {
return err
}
if err := updateReplace(tmp, exe); err != nil {
return err
}
fmt.Fprintf(w, "%s %s → %s, at %s\n", u.asset, u.current, rel.TagName, exe)
return nil
}
func (u selfUpdater) download(a *updateAsset, exe string, mode os.FileMode) (string, error) {
// The new file comes into being next to the old one: same filesystem, so
// the renaming at the end is one atomic step and not half a copy. It also
// comes into being before the first byte — a missing write permission ought
// to show up before a few megabytes have gone down the wire.
dir := filepath.Dir(exe)
f, err := os.CreateTemp(dir, "."+filepath.Base(exe)+".new")
if err != nil {
var pe *os.PathError // the path is in the message already
if errors.As(err, &pe) {
err = pe.Err
}
return "", fmt.Errorf("cannot write to %s: %w", dir, err)
}
tmp := f.Name()
resp, err := updateGet(context.Background(), a.URL)
if err != nil {
f.Close()
os.Remove(tmp)
return "", err
}
defer resp.Body.Close()
n, err := io.Copy(f, resp.Body)
if cerr := f.Close(); err == nil {
err = cerr
}
if err == nil && a.Size > 0 && n != a.Size {
err = fmt.Errorf("got %d of %d bytes from %s", n, a.Size, a.URL)
}
if err == nil {
err = os.Chmod(tmp, mode)
}
if err != nil {
os.Remove(tmp)
return "", err
}
return tmp, nil
}
// probe calls the freshly fetched runner once. That catches a file that is
// truncated, built for the wrong platform, or not executable in the first
// place, before it replaces the running one.
func (u selfUpdater) probe(path, tag string) error {
if len(u.verify) == 0 {
return nil
}
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
out, err := exec.CommandContext(ctx, path, u.verify...).CombinedOutput()
if err != nil {
return fmt.Errorf("the downloaded binary does not run: %w", err)
}
if !strings.Contains(string(out), strings.TrimPrefix(tag, "v")) {
return fmt.Errorf("the downloaded binary reports %q, expected %s",
strings.TrimSpace(string(out)), tag)
}
return nil
}
// updateReplace swaps the running file for the new one.
func updateReplace(tmp, exe string) error {
if err := os.Rename(tmp, exe); err == nil {
return nil
}
// Unix overwrites the file of a running program without complaint, Windows
// does not: there the old one has to be got out of the way first. Deleting
// it becomes possible when this process ends at the earliest — so the
// tidying up is allowed to fail.
old := exe + ".old"
os.Remove(old)
if err := os.Rename(exe, old); err != nil {
return fmt.Errorf("cannot replace %s: %w", exe, err)
}
if err := os.Rename(tmp, exe); err != nil {
os.Rename(old, exe) // back to how it was
return fmt.Errorf("cannot replace %s: %w", exe, err)
}
os.Remove(old)
return nil
}
// -------------------------------------------------------------------- Gitea
type updateRelease struct {
TagName string `json:"tag_name"`
HTMLURL string `json:"html_url"`
Assets []updateAsset `json:"assets"`
}
type updateAsset struct {
Name string `json:"name"`
Size int64 `json:"size"`
URL string `json:"browser_download_url"`
}
func (u selfUpdater) latest() (updateRelease, error) {
base, err := u.apiBase()
if err != nil {
return updateRelease{}, err
}
// The question is a small one; if it hangs, it does not hang for long. The
// generous time limit of updateClient is meant for the download.
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
defer cancel()
resp, err := updateGet(ctx, base+"/releases/latest")
if err != nil {
return updateRelease{}, err
}
defer resp.Body.Close()
var rel updateRelease
if err := json.NewDecoder(resp.Body).Decode(&rel); err != nil {
return updateRelease{}, fmt.Errorf("unexpected answer from %s: %w", base, err)
}
if rel.TagName == "" {
return updateRelease{}, fmt.Errorf("%s has no releases", u.repo)
}
// Every question that succeeds fills the note — no matter whether it came
// from --update, from --check-update or from the background run.
u.saveState(updateState{Checked: time.Now(), Latest: rel.TagName})
return rel, nil
}
// apiBase turns https://host/owner/repo into the API root of the repo.
func (u selfUpdater) apiBase() (string, error) {
bad := fmt.Errorf("repo %q: expected https://host/owner/repo", u.repo)
ref, err := url.Parse(strings.TrimSuffix(strings.TrimSuffix(u.repo, "/"), ".git"))
if err != nil || ref.Host == "" {
return "", bad
}
parts := strings.Split(strings.Trim(ref.Path, "/"), "/")
if len(parts) != 2 || parts[0] == "" || parts[1] == "" {
return "", bad
}
return fmt.Sprintf("%s://%s/api/v1/repos/%s/%s", ref.Scheme, ref.Host, parts[0], parts[1]), nil
}
// One time limit for all of it: the look costs a few hundred milliseconds, the
// download a few megabytes — both may hang, but not forever.
var updateClient = &http.Client{Timeout: 5 * time.Minute}
func updateGet(ctx context.Context, target string) (*http.Response, error) {
req, err := http.NewRequestWithContext(ctx, http.MethodGet, target, nil)
if err != nil {
return nil, err
}
req.Header.Set("User-Agent", "selfupdate.go (+"+runtime.GOOS+"/"+runtime.GOARCH+")")
resp, err := updateClient.Do(req)
if err != nil {
return nil, err
}
if resp.StatusCode != http.StatusOK {
resp.Body.Close()
return nil, fmt.Errorf("GET %s: %s", target, resp.Status)
}
return resp, nil
}
// ------------------------------------------------------------------ Numbers
// updateCompare compares two versions component by component, numerically, so
// that 2.1.10 lands behind 2.1.9 and not in front of it. A leading "v" does not
// count, missing places count as 0 (2.1 == 2.1.0), and a suffix on the number
// makes the version older, not newer (2.1.6-rc1 < 2.1.6). The result is the one
// of strings.Compare: -1, 0, 1.
func updateCompare(a, b string) int {
as := strings.Split(strings.TrimPrefix(a, "v"), ".")
bs := strings.Split(strings.TrimPrefix(b, "v"), ".")
for i := 0; i < len(as) || i < len(bs); i++ {
x, y := "0", "0"
if i < len(as) {
x = as[i]
}
if i < len(bs) {
y = bs[i]
}
if c := updateComparePart(x, y); c != 0 {
return c
}
}
return 0
}
func updateComparePart(a, b string) int {
na, ra := updateSplitNum(a)
nb, rb := updateSplitNum(b)
switch {
case na != nb:
if na < nb {
return -1
}
return 1
case ra == rb:
return 0
case ra == "": // 2.1.6 is finished, 2.1.6-rc1 is not yet
return 1
case rb == "":
return -1
}
return strings.Compare(ra, rb)
}
// updateSplitNum separates "10-rc1" into 10 and "-rc1".
func updateSplitNum(s string) (int, string) {
i := 0
for i < len(s) && s[i] >= '0' && s[i] <= '9' {
i++
}
n, _ := strconv.Atoi(s[:i])
return n, s[i:]
}
// updateSize is deliberately a small formatting of its own and not humanSize
// from colors.go — the file is meant to stand on its own.
func updateSize(b int64) string {
const k = 1024
switch {
case b > k*k:
return fmt.Sprintf("%.1f MB", float64(b)/k/k)
case b > k:
return fmt.Sprintf("%.1f KB", float64(b)/k)
default:
return fmt.Sprintf("%d B", b)
}
}
+190
View File
@@ -0,0 +1,190 @@
package main
// selfupdate_test.go — the parts of selfupdate.go that can be checked without
// replacing the running binary: the version arithmetic, the URL it derives, and
// what it makes of a Gitea release.
//
// selfupdate.go itself is a file copied between programs and stays as it is;
// the tests live here so the copy keeps working when it lands in the next one.
import (
"net/http"
"net/http/httptest"
"os"
"path/filepath"
"strings"
"testing"
)
// cacheInTemp points os.UserCacheDir at a temporary directory, so the note
// selfupdate writes never touches the real one.
func cacheInTemp(t *testing.T) {
t.Helper()
dir := t.TempDir()
t.Setenv("HOME", dir) // darwin: ~/Library/Caches
t.Setenv("XDG_CACHE_HOME", filepath.Join(dir, "xdg")) // linux
}
// fakeGitea serves one /releases/latest answer, the way Gitea does.
func fakeGitea(t *testing.T, body string) *httptest.Server {
t.Helper()
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path != "/api/v1/repos/mike/mgsh/releases/latest" {
http.NotFound(w, r)
return
}
w.Header().Set("Content-Type", "application/json")
w.Write([]byte(body))
}))
t.Cleanup(srv.Close)
return srv
}
func testUpdater(srv *httptest.Server, current string) selfUpdater {
return selfUpdater{
repo: srv.URL + "/mike/mgsh",
asset: "mgsh",
current: current,
every: 0, // no background run from a test
quietEnv: "MGSH_NO_UPDATE_CHECK",
}
}
// TestUpdateCompare is the one piece of arithmetic in the file, and the reason
// it exists: a plain string comparison puts 4.0.9 after 4.0.10 and would offer
// an update backwards forever.
func TestUpdateCompare(t *testing.T) {
for _, c := range []struct {
a, b string
want int
}{
{"4.0.10", "4.0.9", 1}, // numerically, not alphabetically
{"4.0.9", "4.0.10", -1},
{"4.0.64", "4.0.64", 0},
{"v4.1.0", "4.0.64", 1}, // a leading v does not count
{"4.1", "4.1.0", 0}, // missing places are zeroes
{"4.2", "4.1.9", 1},
{"4.0.64-rc1", "4.0.64", -1}, // a suffix is not yet the release
{"4.0.64", "4.0.64-rc1", 1},
{"4.0.64-rc2", "4.0.64-rc1", 1},
{"5.0.0", "4.99.99", 1},
} {
if got := updateCompare(c.a, c.b); got != c.want {
t.Errorf("updateCompare(%q, %q) = %d, want %d", c.a, c.b, got, c.want)
}
}
}
// TestUpdateAPIBase: the browser URL of the repo is all the file is configured
// with, so everything depends on it becoming the right API root.
func TestUpdateAPIBase(t *testing.T) {
for _, c := range []struct{ repo, want string }{
{"https://git.micw.org/mike/mgsh", "https://git.micw.org/api/v1/repos/mike/mgsh"},
{"https://git.micw.org/mike/mgsh/", "https://git.micw.org/api/v1/repos/mike/mgsh"},
{"https://git.micw.org/mike/mgsh.git", "https://git.micw.org/api/v1/repos/mike/mgsh"},
{"http://localhost:3000/mike/mgsh", "http://localhost:3000/api/v1/repos/mike/mgsh"},
} {
got, err := selfUpdater{repo: c.repo}.apiBase()
if err != nil || got != c.want {
t.Errorf("apiBase(%q) = %q, %v, want %q", c.repo, got, err, c.want)
}
}
// anything that is not host/owner/repo has to say so rather than build a
// URL that 404s later
for _, bad := range []string{"", "git.micw.org/mike/mgsh", "https://git.micw.org/mike", "https://git.micw.org/a/b/c"} {
if _, err := (selfUpdater{repo: bad}).apiBase(); err == nil {
t.Errorf("apiBase(%q) accepted a malformed repo URL", bad)
}
}
}
// TestSelfUpdateCheck covers both answers of `update -c`, and that a successful
// look leaves the note behind that the daily hint later reads.
func TestSelfUpdateCheck(t *testing.T) {
cacheInTemp(t)
srv := fakeGitea(t, `{"tag_name":"4.1.0","html_url":"https://git.micw.org/mike/mgsh/releases/tag/4.1.0",
"assets":[{"name":"mgsh-darwin-arm64","size":9,"browser_download_url":"x"}]}`)
var out strings.Builder
if err := testUpdater(srv, "4.0.64").check(&out); err != nil {
t.Fatalf("check: %v", err)
}
if !strings.Contains(out.String(), "4.1.0 is available") {
t.Errorf("check output = %q, want the new version offered", out.String())
}
out.Reset()
if err := testUpdater(srv, "4.1.0").check(&out); err != nil {
t.Fatalf("check: %v", err)
}
if !strings.Contains(out.String(), "up to date") {
t.Errorf("check output = %q, want 'up to date'", out.String())
}
// the same version must not be offered as an update to itself
out.Reset()
if err := testUpdater(srv, "4.2.0").check(&out); err != nil {
t.Fatalf("check: %v", err)
}
if strings.Contains(out.String(), "available") {
t.Errorf("a newer running version was offered an update: %q", out.String())
}
cache, err := os.UserCacheDir()
if err != nil {
t.Fatal(err)
}
note := filepath.Join(cache, "mgsh", "update.json")
b, err := os.ReadFile(note)
if err != nil {
t.Fatalf("no note written to %s: %v", note, err)
}
if !strings.Contains(string(b), `"latest":"4.1.0"`) {
t.Errorf("note = %s, want the looked-up version in it", b)
}
}
// TestSelfUpdateNoReleases: an empty repository must produce a plain message,
// not a nil release that gets compared against the running version.
func TestSelfUpdateNoReleases(t *testing.T) {
cacheInTemp(t)
srv := fakeGitea(t, `{}`)
err := testUpdater(srv, "4.0.64").check(&strings.Builder{})
if err == nil || !strings.Contains(err.Error(), "no releases") {
t.Errorf("check against a release-less repo = %v, want a 'no releases' error", err)
}
}
// TestSelfUpdateWithoutMatchingAsset: a release built for other platforms must
// not be installed, and the message has to name what was looked for — that is
// what tells you the release is incomplete rather than the machine unsupported.
func TestSelfUpdateWithoutMatchingAsset(t *testing.T) {
cacheInTemp(t)
srv := fakeGitea(t, `{"tag_name":"4.1.0","assets":[{"name":"mgsh-plan9-mips","size":1,"browser_download_url":"x"}]}`)
err := testUpdater(srv, "4.0.64").install(&strings.Builder{})
if err == nil {
t.Fatal("install accepted a release without a binary for this platform")
}
if !strings.Contains(err.Error(), "mgsh-") || !strings.Contains(err.Error(), "mgsh-plan9-mips") {
t.Errorf("error = %v, want the wanted and the available asset names", err)
}
}
// TestUpdateDailyStaysQuiet: the automatic look is for the person sitting
// there. Under MGSH_NO_UPDATE_CHECK, and with `every` at zero, it says nothing
// and starts nothing.
func TestUpdateDailyStaysQuiet(t *testing.T) {
cacheInTemp(t)
u := selfUpdater{asset: "mgsh", current: "4.0.0", every: 0, quietEnv: "MGSH_NO_UPDATE_CHECK"}
if hint := u.daily(); hint != "" {
t.Errorf("daily with every=0 = %q, want silence", hint)
}
t.Setenv("MGSH_NO_UPDATE_CHECK", "1")
u.every = 1
if hint := u.daily(); hint != "" {
t.Errorf("daily under MGSH_NO_UPDATE_CHECK = %q, want silence", hint)
}
}
+17 -11
View File
@@ -24,11 +24,11 @@ func showConfig() {
project = DIR + "/" + projectRC project = DIR + "/" + projectRC
} }
fmt.Printf("%s %s\n", col(cGray, "global "), col(cCyan, global)) fmt.Printf("%s %s\n", col(cDark, "global "), col(cBlue, global))
if project != "" { if project != "" {
fmt.Printf("%s %s\n", col(cGray, "project"), col(cCyan, project)) fmt.Printf("%s %s\n", col(cDark, "project"), col(cBlue, project))
} else if PRJ != "" { } else if PRJ != "" {
fmt.Printf("%s %s\n", col(cGray, "project"), col(cGray, "no "+projectRC+" in "+PRJ)) fmt.Printf("%s %s\n", col(cDark, "project"), col(cDark, "no "+projectRC+" in "+PRJ))
} }
fmt.Println() fmt.Println()
@@ -72,13 +72,13 @@ func showConfig() {
} }
if k := sshKeyPath(); k != "" { if k := sshKeyPath(); k != "" {
fmt.Printf(" %s%s\n", col(cGray, padRight("ssh identity", 14)), col(cGray, k)) fmt.Printf(" %s%s\n", col(cDark, padRight("ssh identity", 14)), col(cDark, k))
} }
fmt.Printf(" %s%s\n", col(cGray, padRight("clone url", 14)), col(cGray, URL)) fmt.Printf(" %s%s\n", col(cDark, padRight("clone url", 14)), col(cDark, URL))
// the project's real origin: it can differ from what the current settings // the project's real origin: it can differ from what the current settings
// would produce, e.g. after moving the server or editing a project .mgshrc // would produce, e.g. after moving the server or editing a project .mgshrc
if o := originURL(); o != "" { if o := originURL(); o != "" {
fmt.Printf(" %s%s\n", col(cGray, padRight("origin", 14)), col(cGray, o)) fmt.Printf(" %s%s\n", col(cDark, padRight("origin", 14)), col(cDark, o))
} }
showRemotes() showRemotes()
@@ -90,11 +90,11 @@ func showRemotes() {
targets, incomplete := cfg.mirrorTargets() targets, incomplete := cfg.mirrorTargets()
fmt.Println() fmt.Println()
if len(targets) == 0 && len(incomplete) == 0 { if len(targets) == 0 && len(incomplete) == 0 {
fmt.Println(col(cGray, "no pushremote targets configured")) fmt.Println(col(cDark, "no pushremote targets configured"))
return return
} }
fmt.Println(col(cGray, "pushremote targets (in push order):")) fmt.Println(col(cDark, "pushremote targets (in push order):"))
for _, t := range targets { for _, t := range targets {
vis := "private" vis := "private"
if strings.EqualFold(strings.TrimSpace(t.Vis), "public") { if strings.EqualFold(strings.TrimSpace(t.Vis), "public") {
@@ -104,9 +104,15 @@ func showRemotes() {
if kind == "" { if kind == "" {
kind = remoteKindName(detectRemoteKind(t.URL, "")) + " (detected)" kind = remoteKindName(detectRemoteKind(t.URL, "")) + " (detected)"
} }
fmt.Printf(" %s%s %s\n", // an inactive target is still a target, just not an automatic one: it
col(cGreen, padRight("@"+t.Name, 14)), t.URL, // steps back a shade instead of dropping out of the listing
col(cGray, kind+", "+vis+", key "+maskSecret(t.Key))) name, state := col(cGreen, padRight("@"+t.Name, 14)), ""
if !t.isActive() {
name = col(cGrey, padRight("@"+t.Name, 14))
state = ", inactive (only when named)"
}
fmt.Printf(" %s%s %s\n", name, t.URL,
col(cDark, kind+", "+vis+", key "+maskSecret(t.Key)+state))
} }
for _, n := range incomplete { for _, n := range incomplete {
fmt.Printf(" %s%s\n", col(cRed, padRight("@"+n, 14)), col(cRed, "incomplete: url or key missing")) fmt.Printf(" %s%s\n", col(cRed, padRight("@"+n, 14)), col(cRed, "incomplete: url or key missing"))
+1 -1
View File
@@ -1 +1 @@
4.0.57 4.0.69