Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b4797b056e | ||
|
|
9bc17513ed | ||
|
|
8c4d8da4e9 | ||
|
|
f3d8cbe281 | ||
|
|
cd5ab1a2bd | ||
|
|
65342bcd7c |
@@ -8,4 +8,5 @@
|
|||||||
.TemporaryItems
|
.TemporaryItems
|
||||||
.Trashes
|
.Trashes
|
||||||
mgsh
|
mgsh
|
||||||
|
bin/
|
||||||
.mgshrc
|
.mgshrc
|
||||||
|
|||||||
@@ -17,14 +17,36 @@ directory. Go port of the original Perl `mgsh` (`mgsh.perl`).
|
|||||||
## Build
|
## Build
|
||||||
|
|
||||||
```sh
|
```sh
|
||||||
./build.sh # builds ./mgsh and bumps the patch version by 0.0.1
|
./build.sh # all platforms into ./bin, bumps the patch version
|
||||||
go build -o mgsh . # plain build, keeps the default version
|
PLATFORMS="linux/amd64" ./build.sh # just one
|
||||||
|
go build -o mgsh . # plain build, keeps the default version
|
||||||
```
|
```
|
||||||
|
|
||||||
`build.sh` reads `version.txt`, increments the patch component, injects it via
|
`build.sh` cross-compiles for `darwin/arm64`, `darwin/amd64`, `linux/amd64` and
|
||||||
`-ldflags -X main.VERSION`, and writes it back — so `version.txt` always holds
|
`linux/arm64` into `./bin`:
|
||||||
the version of the binary just built. Dependencies are fetched via Go modules
|
|
||||||
(`go.mod` / `go.sum`) on first build.
|
```
|
||||||
|
bin/mgsh -> mgsh-darwin-arm64 (this machine)
|
||||||
|
bin/mgsh-darwin-amd64
|
||||||
|
bin/mgsh-darwin-arm64
|
||||||
|
bin/mgsh-linux-amd64
|
||||||
|
bin/mgsh-linux-arm64
|
||||||
|
```
|
||||||
|
|
||||||
|
`bin/mgsh` is a symlink to the build for the host, so there is one stable path
|
||||||
|
to "the binary for this machine". `bin/` is git-ignored. Windows is deliberately
|
||||||
|
absent: mgsh shells out to `stty` and `/bin/sh`, so it would compile there and
|
||||||
|
then not work.
|
||||||
|
|
||||||
|
Everything is built with `CGO_ENABLED=0`, which makes the cross builds need no
|
||||||
|
toolchain per target and the binaries static; `os/user` resolves the current
|
||||||
|
user without cgo on both darwin and linux.
|
||||||
|
|
||||||
|
It reads `version.txt`, increments the patch component, injects it via
|
||||||
|
`-ldflags -X main.VERSION` into **all** platforms of that run, and writes it
|
||||||
|
back — so `version.txt` always holds the version of the binaries just built, and
|
||||||
|
they all carry the same one. Dependencies are fetched via Go modules (`go.mod` /
|
||||||
|
`go.sum`) on first build.
|
||||||
|
|
||||||
Run the tests with `go test ./...`.
|
Run the tests with `go test ./...`.
|
||||||
|
|
||||||
@@ -40,7 +62,7 @@ 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`, `open`,
|
`push`, `pushremote`, `release`, `list`, `tag`, `archive`, `show`,
|
||||||
`pull`, `fetch`, `status`, `diff`, `overview`, `config`, `count`, `login` and
|
`pull`, `fetch`, `status`, `diff`, `overview`, `config`, `count`, `login` and
|
||||||
`cloneall`; every other command is interactive-only.
|
`cloneall`; every other command is interactive-only.
|
||||||
|
|
||||||
@@ -52,7 +74,7 @@ project, its git branch and a `*` dirty marker:
|
|||||||
```
|
```
|
||||||
|
|
||||||
Features: command history (`~/.mgsh_history`), Tab completion (commands, local
|
Features: command history (`~/.mgsh_history`), Tab completion (commands, local
|
||||||
projects for `cd`/`open`, server repos for `clone`/`show`, branches/tags for
|
projects for `cd`, server repos for `clone`/`show`, branches/tags for
|
||||||
`checkout`/`tag`, mirror targets for `pushremote`/`release`, filesystem paths for
|
`checkout`/`tag`, mirror targets for `pushremote`/`release`, filesystem paths for
|
||||||
`dist`, and shell-style completion after `!` and for aliases that expand to
|
`dist`, and shell-style completion after `!` and for aliases that expand to
|
||||||
one), and colored `list`/`log`/error output.
|
one), and colored `list`/`log`/error output.
|
||||||
@@ -144,7 +166,6 @@ project /Users/me/src/myproject/.mgshrc
|
|||||||
gitport 22
|
gitport 22
|
||||||
gituser git
|
gituser git
|
||||||
gitpath /home/git
|
gitpath /home/git
|
||||||
editor code (.mgshrc)
|
|
||||||
remotes hub (.mgshrc)
|
remotes hub (.mgshrc)
|
||||||
clone url ssh://git@git.example.com:22/home/git
|
clone url ssh://git@git.example.com:22/home/git
|
||||||
|
|
||||||
@@ -371,11 +392,43 @@ on different servers. `release` also refuses when the repository is not on the
|
|||||||
mirror yet and tells you to run `pushremote` first, rather than creating it as a
|
mirror yet and tells you to run `pushremote` first, rather than creating it as a
|
||||||
side effect.
|
side effect.
|
||||||
|
|
||||||
**No binary assets.** The three providers handle uploads in three incompatible
|
#### Binaries and assets
|
||||||
ways — Gitea attaches them to the release, GitHub uses a separate upload host,
|
|
||||||
and GitLab does not host them at all but expects a link into its package
|
If the project has a `./bin` or `./assets` directory, every file in it is
|
||||||
registry. mgsh publishes source releases with notes; if you need binaries,
|
attached to the release — nothing to configure, and nothing happens for a
|
||||||
upload them with the provider's own tooling.
|
project that has neither:
|
||||||
|
|
||||||
|
```
|
||||||
|
< src/mgsh > release v4.1.0
|
||||||
|
attaching 5 assets, 38M from ./bin and ./assets
|
||||||
|
remote gitea released https://git.example.com/mike/mgsh.git
|
||||||
|
uploading logo.png 2.0K
|
||||||
|
uploading mgsh-darwin-amd64 9.8M
|
||||||
|
uploading mgsh-darwin-arm64 9.2M
|
||||||
|
uploading mgsh-linux-amd64 9.7M
|
||||||
|
uploading mgsh-linux-arm64 8.9M
|
||||||
|
```
|
||||||
|
|
||||||
|
Only regular files directly in those directories are taken: subdirectories are
|
||||||
|
not descended into, and symlinks are skipped — `bin/mgsh` points at one of its
|
||||||
|
own siblings, and uploading the same binary twice under two names helps nobody.
|
||||||
|
A name present in both directories is used from `bin` and reported for
|
||||||
|
`assets`, since one asset name can only mean one file.
|
||||||
|
|
||||||
|
Re-releasing the same tag replaces same-named assets instead of failing or
|
||||||
|
piling up duplicates, because rebuilding and publishing again is the normal
|
||||||
|
reason to do it. A file that fails to upload does not stop the rest.
|
||||||
|
|
||||||
|
This is where the providers stop resembling each other, and mgsh papers over it:
|
||||||
|
|
||||||
|
| | how the bytes get there |
|
||||||
|
|---|---|
|
||||||
|
| Gitea | multipart `POST` to `…/releases/<id>/assets?name=<name>` |
|
||||||
|
| GitHub | raw `POST` to the separate upload host named by the release's `upload_url` |
|
||||||
|
| GitLab | a release stores links, not files: the file goes into the project's generic **package registry** and the release gets a `package` link pointing at it |
|
||||||
|
|
||||||
|
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.
|
||||||
|
|
||||||
## Configuration
|
## Configuration
|
||||||
|
|
||||||
@@ -409,7 +462,6 @@ gitpath = /home/git
|
|||||||
gitname = Your Name
|
gitname = Your Name
|
||||||
gitemail = you@example.com
|
gitemail = you@example.com
|
||||||
pushdefault = matching
|
pushdefault = matching
|
||||||
editor = code # fallback opener for `open`
|
|
||||||
|
|
||||||
alias co 'checkout $1'
|
alias co 'checkout $1'
|
||||||
```
|
```
|
||||||
@@ -435,7 +487,6 @@ setting.
|
|||||||
| `gitname` | global | `user.name` written to the **global** git config at startup |
|
| `gitname` | global | `user.name` written to the **global** git config at startup |
|
||||||
| `gitemail` | global | `user.email` written to the global git config |
|
| `gitemail` | global | `user.email` written to the global git config |
|
||||||
| `pushdefault` | global | `push.default` written to the global git config |
|
| `pushdefault` | global | `push.default` written to the global git config |
|
||||||
| `editor` | project | opener used by `open`/`view` when the project has no Xcode workspace (default `coda`) |
|
|
||||||
| `remote.<name>.url` | project | base URL of the mirror target `<name>` |
|
| `remote.<name>.url` | project | base URL of the mirror target `<name>` |
|
||||||
| `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 |
|
||||||
@@ -452,7 +503,7 @@ only when they actually differ, so a plain `mgsh status` does not rewrite
|
|||||||
|
|
||||||
A project may carry its own `.mgshrc`, which overrides the global settings while
|
A project may carry its own `.mgshrc`, which overrides the global settings while
|
||||||
that project is active — a project on a different git server, with a different
|
that project is active — a project on a different git server, with a different
|
||||||
editor, or mirrored to a different place:
|
ssh identity, or mirrored to a different place:
|
||||||
|
|
||||||
```ini
|
```ini
|
||||||
# ~/src/myproject/.mgshrc
|
# ~/src/myproject/.mgshrc
|
||||||
|
|||||||
@@ -46,7 +46,7 @@ var builtinCmds = map[string]bool{
|
|||||||
"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, "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,
|
||||||
"open": true, "view": 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,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,323 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
// assets.go — attaching files to a release.
|
||||||
|
//
|
||||||
|
// `release` uploads everything in the project's ./bin and ./assets when those
|
||||||
|
// directories exist. This is where the three providers stop resembling each
|
||||||
|
// other, so each gets its own path:
|
||||||
|
//
|
||||||
|
// Gitea multipart POST to .../releases/<id>/assets?name=<name>
|
||||||
|
// GitHub raw POST to the upload host named by the release object's upload_url
|
||||||
|
// GitLab no asset hosting on a release at all: the file goes into the generic
|
||||||
|
// package registry, and the release gets a link pointing at it
|
||||||
|
//
|
||||||
|
// Re-releasing the same tag replaces same-named assets rather than failing or
|
||||||
|
// piling up duplicates, because rebuilding and publishing again is the normal
|
||||||
|
// reason to do it.
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"mime"
|
||||||
|
"mime/multipart"
|
||||||
|
"net/http"
|
||||||
|
"net/url"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"sort"
|
||||||
|
"strconv"
|
||||||
|
)
|
||||||
|
|
||||||
|
// assetDirs are the project directories whose files a release carries.
|
||||||
|
var assetDirs = []string{"bin", "assets"}
|
||||||
|
|
||||||
|
// releaseAsset is one file to attach to a release.
|
||||||
|
type releaseAsset struct {
|
||||||
|
name string
|
||||||
|
path string
|
||||||
|
size int64
|
||||||
|
}
|
||||||
|
|
||||||
|
// collectAssets gathers the files to attach: every regular file directly in
|
||||||
|
// the project's ./bin and ./assets. Subdirectories are not descended into, and
|
||||||
|
// symlinks are skipped — bin/mgsh points at one of its own siblings, and
|
||||||
|
// uploading the same binary twice under two names helps nobody.
|
||||||
|
func collectAssets(dir string) (assets []releaseAsset, skipped []string) {
|
||||||
|
seen := map[string]bool{}
|
||||||
|
for _, sub := range assetDirs {
|
||||||
|
path := filepath.Join(dir, sub)
|
||||||
|
entries, err := os.ReadDir(path)
|
||||||
|
if err != nil {
|
||||||
|
continue // the directory simply is not there
|
||||||
|
}
|
||||||
|
for _, e := range entries {
|
||||||
|
if e.IsDir() || e.Type()&os.ModeSymlink != 0 {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
fi, err := e.Info()
|
||||||
|
if err != nil || !fi.Mode().IsRegular() {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if seen[e.Name()] {
|
||||||
|
// bin/x and assets/x would fight over one asset name
|
||||||
|
skipped = append(skipped, filepath.Join(sub, e.Name()))
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
seen[e.Name()] = true
|
||||||
|
assets = append(assets, releaseAsset{
|
||||||
|
name: e.Name(),
|
||||||
|
path: filepath.Join(path, e.Name()),
|
||||||
|
size: fi.Size(),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
sort.Slice(assets, func(i, j int) bool { return assets[i].name < assets[j].name })
|
||||||
|
return assets, skipped
|
||||||
|
}
|
||||||
|
|
||||||
|
// contentType guesses a type from the file name, falling back to the one for
|
||||||
|
// "some bytes" — which is what a compiled binary is.
|
||||||
|
func contentType(name string) string {
|
||||||
|
if t := mime.TypeByExtension(filepath.Ext(name)); t != "" {
|
||||||
|
return t
|
||||||
|
}
|
||||||
|
return "application/octet-stream"
|
||||||
|
}
|
||||||
|
|
||||||
|
// doUpload sends a request with a raw body of a known size. The JSON helper
|
||||||
|
// cannot express these: an asset upload is bytes, not an object, and the
|
||||||
|
// providers insist on a Content-Length rather than a chunked body.
|
||||||
|
func (r *remoteAPI) doUpload(method, endpoint, ctype string, body io.Reader, size int64) (int, []byte, error) {
|
||||||
|
req, err := http.NewRequest(method, endpoint, body)
|
||||||
|
if err != nil {
|
||||||
|
return 0, nil, err
|
||||||
|
}
|
||||||
|
req.ContentLength = size
|
||||||
|
req.Header.Set("Content-Type", ctype)
|
||||||
|
hk, hv := r.authHeader()
|
||||||
|
req.Header.Set(hk, hv)
|
||||||
|
req.Header.Set("Accept", "application/json")
|
||||||
|
|
||||||
|
resp, err := r.http.Do(req)
|
||||||
|
if err != nil {
|
||||||
|
return 0, nil, err
|
||||||
|
}
|
||||||
|
defer resp.Body.Close()
|
||||||
|
data, _ := io.ReadAll(resp.Body)
|
||||||
|
return resp.StatusCode, data, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// uploadAssets attaches every file to the release, reporting each one as it
|
||||||
|
// goes: these are megabytes over a network, and silence for a minute looks like
|
||||||
|
// a hang. A file that fails does not stop the rest.
|
||||||
|
func (r *remoteAPI) uploadAssets(owner, repo string, ref releaseRef, assets []releaseAsset) error {
|
||||||
|
failed := 0
|
||||||
|
for _, a := range assets {
|
||||||
|
fmt.Printf(" %s %s %s\n", col(cGray, "uploading"),
|
||||||
|
col(cGreen, padRight(a.name, 28)), col(cGray, humanSize(a.size)))
|
||||||
|
if err := r.uploadAsset(owner, repo, ref, a); err != nil {
|
||||||
|
errorln(" " + a.name + ": " + err.Error())
|
||||||
|
failed++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if failed > 0 {
|
||||||
|
return fmt.Errorf("%d of %d assets failed", failed, len(assets))
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// uploadAsset attaches one file, replacing an asset of the same name that is
|
||||||
|
// already on the release.
|
||||||
|
func (r *remoteAPI) uploadAsset(owner, repo string, ref releaseRef, a releaseAsset) error {
|
||||||
|
if err := r.removeAsset(owner, repo, ref, a.name); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
switch r.kind {
|
||||||
|
case kindGitLab:
|
||||||
|
return r.uploadAssetGitLab(owner, repo, ref, a)
|
||||||
|
case kindGitHub:
|
||||||
|
return r.uploadAssetGitHub(ref, a)
|
||||||
|
default:
|
||||||
|
return r.uploadAssetGitea(owner, repo, ref, a)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// uploadAssetGitea posts the file as a multipart form, streamed from disk
|
||||||
|
// rather than buffered: these are whole binaries.
|
||||||
|
func (r *remoteAPI) uploadAssetGitea(owner, repo string, ref releaseRef, a releaseAsset) error {
|
||||||
|
f, err := os.Open(a.path)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
defer f.Close()
|
||||||
|
|
||||||
|
pr, pw := io.Pipe()
|
||||||
|
mw := multipart.NewWriter(pw)
|
||||||
|
go func() {
|
||||||
|
part, err := mw.CreateFormFile("attachment", a.name)
|
||||||
|
if err == nil {
|
||||||
|
_, err = io.Copy(part, f)
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
pw.CloseWithError(err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
pw.CloseWithError(mw.Close())
|
||||||
|
}()
|
||||||
|
|
||||||
|
ep := r.releasePath(owner, repo) + "/" + ref.id + "/assets?name=" + url.QueryEscape(a.name)
|
||||||
|
// the multipart length is not known up front, so this one is chunked
|
||||||
|
code, data, err := r.doUpload("POST", ep, mw.FormDataContentType(), pr, -1)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if code != 200 && code != 201 {
|
||||||
|
return fmt.Errorf("HTTP %d: %s", code, firstLine(data))
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// uploadAssetGitHub posts the raw bytes to the host the release object named.
|
||||||
|
func (r *remoteAPI) uploadAssetGitHub(ref releaseRef, a releaseAsset) error {
|
||||||
|
if ref.uploadURL == "" {
|
||||||
|
return fmt.Errorf("the release carries no upload_url")
|
||||||
|
}
|
||||||
|
f, err := os.Open(a.path)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
defer f.Close()
|
||||||
|
|
||||||
|
ep := ref.uploadURL + "?name=" + url.QueryEscape(a.name)
|
||||||
|
code, data, err := r.doUpload("POST", ep, contentType(a.name), f, a.size)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if code != 200 && code != 201 {
|
||||||
|
return fmt.Errorf("HTTP %d: %s", code, firstLine(data))
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// uploadAssetGitLab puts the file into the project's generic package registry
|
||||||
|
// and links the release to it, since a GitLab release stores links, not files.
|
||||||
|
func (r *remoteAPI) uploadAssetGitLab(owner, repo string, ref releaseRef, a releaseAsset) error {
|
||||||
|
f, err := os.Open(a.path)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
defer f.Close()
|
||||||
|
|
||||||
|
pkg := r.packagePath(owner, repo, ref.tag, a.name)
|
||||||
|
code, data, err := r.doUpload("PUT", pkg, contentType(a.name), f, a.size)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if code != 200 && code != 201 {
|
||||||
|
return fmt.Errorf("package upload failed (HTTP %d): %s", code, firstLine(data))
|
||||||
|
}
|
||||||
|
|
||||||
|
code, data, err = r.do("POST", r.releaseByTagPath(owner, repo, ref.tag)+"/assets/links",
|
||||||
|
map[string]any{"name": a.name, "url": pkg, "link_type": "package"})
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if code != 200 && code != 201 {
|
||||||
|
return fmt.Errorf("linking the package failed (HTTP %d): %s", code, firstLine(data))
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// packagePath is where a release asset lives in GitLab's generic registry: one
|
||||||
|
// package named after the repository, one version per tag.
|
||||||
|
func (r *remoteAPI) packagePath(owner, repo, tag, name string) string {
|
||||||
|
return r.repoPath(owner, repo) + "/packages/generic/" +
|
||||||
|
url.PathEscape(repo) + "/" + url.PathEscape(tag) + "/" + url.PathEscape(name)
|
||||||
|
}
|
||||||
|
|
||||||
|
// removeAsset deletes an asset of the given name from the release when one is
|
||||||
|
// there, so re-releasing a tag after a rebuild replaces the files instead of
|
||||||
|
// failing or leaving two of each.
|
||||||
|
func (r *remoteAPI) removeAsset(owner, repo string, ref releaseRef, name string) error {
|
||||||
|
listEP, delEP := r.assetEndpoints(owner, repo, ref)
|
||||||
|
code, data, err := r.do("GET", listEP, nil)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if code == 404 {
|
||||||
|
return nil // nothing uploaded yet
|
||||||
|
}
|
||||||
|
if code != 200 {
|
||||||
|
return fmt.Errorf("listing assets failed (HTTP %d): %s", code, firstLine(data))
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, id := range assetIDsNamed(data, name) {
|
||||||
|
if code, data, err := r.do("DELETE", delEP+"/"+id, nil); err != nil {
|
||||||
|
return err
|
||||||
|
} else if code != 200 && code != 202 && code != 204 {
|
||||||
|
return fmt.Errorf("deleting the previous %s failed (HTTP %d): %s", name, code, firstLine(data))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// assetEndpoints returns where a release's assets are listed and deleted.
|
||||||
|
// GitHub deletes an asset through the repository rather than the release.
|
||||||
|
func (r *remoteAPI) assetEndpoints(owner, repo string, ref releaseRef) (list, del string) {
|
||||||
|
switch r.kind {
|
||||||
|
case kindGitLab:
|
||||||
|
base := r.releaseByTagPath(owner, repo, ref.tag) + "/assets/links"
|
||||||
|
return base, base
|
||||||
|
case kindGitHub:
|
||||||
|
return r.releasePath(owner, repo) + "/" + ref.id + "/assets",
|
||||||
|
r.releasePath(owner, repo) + "/assets"
|
||||||
|
default: // Gitea
|
||||||
|
base := r.releasePath(owner, repo) + "/" + ref.id + "/assets"
|
||||||
|
return base, base
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// assetIDsNamed picks the ids of the listed assets called name. All three
|
||||||
|
// providers answer with an array of objects carrying "id" and "name".
|
||||||
|
func assetIDsNamed(data []byte, name string) []string {
|
||||||
|
var list []struct {
|
||||||
|
ID json.Number `json:"id"`
|
||||||
|
Name string `json:"name"`
|
||||||
|
}
|
||||||
|
if err := json.Unmarshal(data, &list); err != nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
var ids []string
|
||||||
|
for _, a := range list {
|
||||||
|
if a.Name == name && a.ID.String() != "" {
|
||||||
|
ids = append(ids, a.ID.String())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return ids
|
||||||
|
}
|
||||||
|
|
||||||
|
// assetSummary describes what will be uploaded, for the line `release` prints
|
||||||
|
// before it starts.
|
||||||
|
func assetSummary(assets []releaseAsset) string {
|
||||||
|
var total int64
|
||||||
|
for _, a := range assets {
|
||||||
|
total += a.size
|
||||||
|
}
|
||||||
|
unit := "assets"
|
||||||
|
if len(assets) == 1 {
|
||||||
|
unit = "asset"
|
||||||
|
}
|
||||||
|
return strconv.Itoa(len(assets)) + " " + unit + ", " + humanSize(total)
|
||||||
|
}
|
||||||
|
|
||||||
|
// assetDirList names the directories that were actually found, for the same line.
|
||||||
|
func assetDirList(dir string) []string {
|
||||||
|
var found []string
|
||||||
|
for _, sub := range assetDirs {
|
||||||
|
if isDir(filepath.Join(dir, sub)) {
|
||||||
|
found = append(found, "./"+sub)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return found
|
||||||
|
}
|
||||||
+284
@@ -0,0 +1,284 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
// assets_test.go — attaching files to a release.
|
||||||
|
//
|
||||||
|
// The three providers upload assets in three unrelated ways, and the difference
|
||||||
|
// is invisible until a real server rejects the request. The recording stand-in
|
||||||
|
// lets each one be pinned down: where the bytes go, how they are wrapped, and
|
||||||
|
// what else has to happen around them.
|
||||||
|
|
||||||
|
import (
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
// assetTree builds a project with ./bin and ./assets and returns its path.
|
||||||
|
func assetTree(t *testing.T, files map[string]string) string {
|
||||||
|
t.Helper()
|
||||||
|
dir := t.TempDir()
|
||||||
|
for rel, content := range files {
|
||||||
|
p := filepath.Join(dir, rel)
|
||||||
|
if err := os.MkdirAll(filepath.Dir(p), 0755); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if err := os.WriteFile(p, []byte(content), 0755); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return dir
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCollectAssets(t *testing.T) {
|
||||||
|
dir := assetTree(t, map[string]string{
|
||||||
|
"bin/mgsh-linux-amd64": "ELF...",
|
||||||
|
"bin/mgsh-darwin-arm64": "MACH...",
|
||||||
|
"assets/logo.png": "PNG",
|
||||||
|
"assets/notes.txt": "text",
|
||||||
|
"bin/sub/nested": "not descended into",
|
||||||
|
"src/main.go": "not an asset directory",
|
||||||
|
})
|
||||||
|
// bin/mgsh is a symlink to one of its siblings, as build.sh leaves it
|
||||||
|
if err := os.Symlink("mgsh-linux-amd64", filepath.Join(dir, "bin", "mgsh")); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
assets, skipped := collectAssets(dir)
|
||||||
|
var names []string
|
||||||
|
for _, a := range assets {
|
||||||
|
names = append(names, a.name)
|
||||||
|
}
|
||||||
|
want := "logo.png,mgsh-darwin-arm64,mgsh-linux-amd64,notes.txt"
|
||||||
|
if strings.Join(names, ",") != want {
|
||||||
|
t.Errorf("collected %v, want %s", names, want)
|
||||||
|
}
|
||||||
|
if len(skipped) != 0 {
|
||||||
|
t.Errorf("skipped = %v, want none", skipped)
|
||||||
|
}
|
||||||
|
for _, a := range assets {
|
||||||
|
if a.size == 0 {
|
||||||
|
t.Errorf("%s has no size", a.name)
|
||||||
|
}
|
||||||
|
if _, err := os.Stat(a.path); err != nil {
|
||||||
|
t.Errorf("%s: %v", a.name, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// a project with neither directory contributes nothing at all
|
||||||
|
if got, _ := collectAssets(t.TempDir()); len(got) != 0 {
|
||||||
|
t.Errorf("a project without bin/ or assets/ produced %v", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestCollectAssetsNameCollision: one asset name can only mean one file, so the
|
||||||
|
// second directory's copy is reported rather than silently overwriting.
|
||||||
|
func TestCollectAssetsNameCollision(t *testing.T) {
|
||||||
|
dir := assetTree(t, map[string]string{
|
||||||
|
"bin/tool": "the binary",
|
||||||
|
"assets/tool": "something else with the same name",
|
||||||
|
})
|
||||||
|
assets, skipped := collectAssets(dir)
|
||||||
|
if len(assets) != 1 || assets[0].name != "tool" ||
|
||||||
|
!strings.HasSuffix(assets[0].path, "bin/tool") {
|
||||||
|
t.Errorf("assets = %+v, want only bin/tool", assets)
|
||||||
|
}
|
||||||
|
if len(skipped) != 1 || !strings.Contains(skipped[0], "tool") {
|
||||||
|
t.Errorf("skipped = %v, want the assets/ copy", skipped)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestUploadAssetGitea: a multipart POST under the field name Gitea expects,
|
||||||
|
// with the asset name in the query.
|
||||||
|
func TestUploadAssetGitea(t *testing.T) {
|
||||||
|
dir := assetTree(t, map[string]string{"bin/mgsh-linux-amd64": "binary-bytes-here"})
|
||||||
|
assets, _ := collectAssets(dir)
|
||||||
|
|
||||||
|
f := newFakeProvider(t)
|
||||||
|
f.route("GET /api/v1/repos/mike/mgsh/releases/7/assets", 200, `[]`)
|
||||||
|
f.route("POST /api/v1/repos/mike/mgsh/releases/7/assets", 201, `{"id":1}`)
|
||||||
|
|
||||||
|
api := newRemoteAPI(f.URL, "tok", "gitea")
|
||||||
|
ref := releaseRef{tag: "v1.0", id: "7"}
|
||||||
|
if err := api.uploadAsset("mike", "mgsh", ref, assets[0]); err != nil {
|
||||||
|
t.Fatalf("uploadAsset: %v (requests %v)", err, f.paths())
|
||||||
|
}
|
||||||
|
|
||||||
|
req := f.find("POST /api/v1/repos/mike/mgsh/releases/7/assets")
|
||||||
|
if req == nil {
|
||||||
|
t.Fatalf("no upload, requests: %v", f.paths())
|
||||||
|
}
|
||||||
|
if !strings.Contains(req.query, "name=mgsh-linux-amd64") {
|
||||||
|
t.Errorf("query = %q, want the asset name", req.query)
|
||||||
|
}
|
||||||
|
if !strings.HasPrefix(req.ctype, "multipart/form-data") {
|
||||||
|
t.Errorf("content type = %q, want multipart", req.ctype)
|
||||||
|
}
|
||||||
|
body := string(req.raw)
|
||||||
|
if !strings.Contains(body, `name="attachment"`) {
|
||||||
|
t.Errorf("form field is not 'attachment': %q", body)
|
||||||
|
}
|
||||||
|
if !strings.Contains(body, "binary-bytes-here") {
|
||||||
|
t.Errorf("the file contents did not make it: %q", body)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestUploadAssetGitHub: the bytes go raw to the host the release object named,
|
||||||
|
// not to the API host.
|
||||||
|
func TestUploadAssetGitHub(t *testing.T) {
|
||||||
|
dir := assetTree(t, map[string]string{"bin/tool": "raw-bytes"})
|
||||||
|
assets, _ := collectAssets(dir)
|
||||||
|
|
||||||
|
f := newFakeProvider(t)
|
||||||
|
f.route("GET /api/v3/repos/mike/mgsh/releases/7/assets", 200, `[]`)
|
||||||
|
f.route("POST /uploads/repos/mike/mgsh/releases/7/assets", 201, `{"id":1}`)
|
||||||
|
|
||||||
|
api := newRemoteAPI(f.URL, "tok", "github")
|
||||||
|
// the template suffix from the release object has to be stripped
|
||||||
|
ref := api.parseReleaseRef("v1.0", []byte(`{"id":7,"upload_url":"`+
|
||||||
|
f.URL+`/uploads/repos/mike/mgsh/releases/7/assets{?name,label}"}`))
|
||||||
|
if ref.id != "7" || strings.Contains(ref.uploadURL, "{") {
|
||||||
|
t.Fatalf("release ref = %+v, want id 7 and a bare upload url", ref)
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := api.uploadAsset("mike", "mgsh", ref, assets[0]); err != nil {
|
||||||
|
t.Fatalf("uploadAsset: %v (requests %v)", err, f.paths())
|
||||||
|
}
|
||||||
|
req := f.find("POST /uploads/repos/mike/mgsh/releases/7/assets")
|
||||||
|
if req == nil {
|
||||||
|
t.Fatalf("no upload to the upload host, requests: %v", f.paths())
|
||||||
|
}
|
||||||
|
if string(req.raw) != "raw-bytes" {
|
||||||
|
t.Errorf("body = %q, want the file verbatim", req.raw)
|
||||||
|
}
|
||||||
|
if !strings.Contains(req.query, "name=tool") {
|
||||||
|
t.Errorf("query = %q, want the asset name", req.query)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestUploadAssetGitHubWithoutUploadURL: without one there is nowhere to put
|
||||||
|
// the bytes, and that has to be said rather than guessed at.
|
||||||
|
func TestUploadAssetGitHubWithoutUploadURL(t *testing.T) {
|
||||||
|
dir := assetTree(t, map[string]string{"bin/tool": "x"})
|
||||||
|
assets, _ := collectAssets(dir)
|
||||||
|
f := newFakeProvider(t)
|
||||||
|
f.route("GET /api/v3/repos/mike/mgsh/releases/7/assets", 200, `[]`)
|
||||||
|
|
||||||
|
api := newRemoteAPI(f.URL, "tok", "github")
|
||||||
|
err := api.uploadAsset("mike", "mgsh", releaseRef{tag: "v1", id: "7"}, assets[0])
|
||||||
|
if err == nil || !strings.Contains(err.Error(), "upload_url") {
|
||||||
|
t.Errorf("error = %v, want it to name the missing upload_url", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestUploadAssetGitLab: a GitLab release stores links, not files, so the file
|
||||||
|
// goes into the generic package registry first and the release then points at
|
||||||
|
// it.
|
||||||
|
func TestUploadAssetGitLab(t *testing.T) {
|
||||||
|
dir := assetTree(t, map[string]string{"bin/tool": "package-bytes"})
|
||||||
|
assets, _ := collectAssets(dir)
|
||||||
|
|
||||||
|
const pkg = "/api/v4/projects/mike%2Fmgsh/packages/generic/mgsh/v1.0/tool"
|
||||||
|
const links = "/api/v4/projects/mike%2Fmgsh/releases/v1.0/assets/links"
|
||||||
|
f := newFakeProvider(t)
|
||||||
|
f.route("GET "+links, 200, `[]`)
|
||||||
|
f.route("PUT "+pkg, 201, `{"message":"201 Created"}`)
|
||||||
|
f.route("POST "+links, 201, `{"id":1}`)
|
||||||
|
|
||||||
|
api := newRemoteAPI(f.URL, "tok", "gitlab")
|
||||||
|
if err := api.uploadAsset("mike", "mgsh", releaseRef{tag: "v1.0", id: "v1.0"}, assets[0]); err != nil {
|
||||||
|
t.Fatalf("uploadAsset: %v (requests %v)", err, f.paths())
|
||||||
|
}
|
||||||
|
|
||||||
|
up := f.find("PUT " + pkg)
|
||||||
|
if up == nil {
|
||||||
|
t.Fatalf("no package upload, requests: %v", f.paths())
|
||||||
|
}
|
||||||
|
if string(up.raw) != "package-bytes" {
|
||||||
|
t.Errorf("package body = %q, want the file verbatim", up.raw)
|
||||||
|
}
|
||||||
|
link := f.find("POST " + links)
|
||||||
|
if link == nil {
|
||||||
|
t.Fatalf("the release was not linked to the package, requests: %v", f.paths())
|
||||||
|
}
|
||||||
|
if link.body["name"] != "tool" || link.body["link_type"] != "package" {
|
||||||
|
t.Errorf("link body = %v", link.body)
|
||||||
|
}
|
||||||
|
if u, _ := link.body["url"].(string); !strings.HasSuffix(u, pkg) {
|
||||||
|
t.Errorf("link url = %q, want it to point at the package", u)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestUploadAssetReplacesPrevious: re-releasing after a rebuild has to replace
|
||||||
|
// the old file, not fail on it or leave two of each.
|
||||||
|
func TestUploadAssetReplacesPrevious(t *testing.T) {
|
||||||
|
dir := assetTree(t, map[string]string{"bin/tool": "v2"})
|
||||||
|
assets, _ := collectAssets(dir)
|
||||||
|
|
||||||
|
f := newFakeProvider(t)
|
||||||
|
f.route("GET /api/v1/repos/mike/mgsh/releases/7/assets", 200,
|
||||||
|
`[{"id":41,"name":"other"},{"id":42,"name":"tool"}]`)
|
||||||
|
f.route("DELETE /api/v1/repos/mike/mgsh/releases/7/assets/42", 204, ``)
|
||||||
|
f.route("POST /api/v1/repos/mike/mgsh/releases/7/assets", 201, `{"id":43}`)
|
||||||
|
|
||||||
|
api := newRemoteAPI(f.URL, "tok", "gitea")
|
||||||
|
if err := api.uploadAsset("mike", "mgsh", releaseRef{tag: "v1", id: "7"}, assets[0]); err != nil {
|
||||||
|
t.Fatalf("uploadAsset: %v (requests %v)", err, f.paths())
|
||||||
|
}
|
||||||
|
if f.find("DELETE /api/v1/repos/mike/mgsh/releases/7/assets/42") == nil {
|
||||||
|
t.Errorf("the previous asset was not removed: %v", f.paths())
|
||||||
|
}
|
||||||
|
// only the one with the matching name
|
||||||
|
if f.find("DELETE /api/v1/repos/mike/mgsh/releases/7/assets/41") != nil {
|
||||||
|
t.Errorf("an unrelated asset was deleted: %v", f.paths())
|
||||||
|
}
|
||||||
|
if f.find("POST /api/v1/repos/mike/mgsh/releases/7/assets") == nil {
|
||||||
|
t.Errorf("the replacement was not uploaded: %v", f.paths())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestUploadAssetsReportsFailures: one bad file must not stop the others, and
|
||||||
|
// the caller has to hear about it.
|
||||||
|
func TestUploadAssetsReportsFailures(t *testing.T) {
|
||||||
|
dir := assetTree(t, map[string]string{"bin/good": "ok", "bin/bad": "boom"})
|
||||||
|
assets, _ := collectAssets(dir)
|
||||||
|
|
||||||
|
f := newFakeProvider(t)
|
||||||
|
f.route("GET /api/v1/repos/mike/mgsh/releases/7/assets", 200, `[]`)
|
||||||
|
// the fake answers 404 for anything unrouted, so the POST fails
|
||||||
|
api := newRemoteAPI(f.URL, "tok", "gitea")
|
||||||
|
|
||||||
|
out := captureStdout(t, func() {
|
||||||
|
if err := api.uploadAssets("mike", "mgsh", releaseRef{tag: "v1", id: "7"}, assets); err == nil {
|
||||||
|
t.Error("failed uploads were not reported")
|
||||||
|
}
|
||||||
|
})
|
||||||
|
// both were attempted, and each was announced before it started
|
||||||
|
for _, name := range []string{"good", "bad"} {
|
||||||
|
if !strings.Contains(out, name) {
|
||||||
|
t.Errorf("%s was not attempted or not announced:\n%s", name, out)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAssetSummaryAndDirList(t *testing.T) {
|
||||||
|
dir := assetTree(t, map[string]string{
|
||||||
|
"bin/a": strings.Repeat("x", 1024),
|
||||||
|
"assets/b": strings.Repeat("y", 512),
|
||||||
|
"src/main.go": "not counted",
|
||||||
|
})
|
||||||
|
assets, _ := collectAssets(dir)
|
||||||
|
if got := assetSummary(assets); got != "2 assets, 1.5K" {
|
||||||
|
t.Errorf("assetSummary = %q, want \"2 assets, 1.5K\"", got)
|
||||||
|
}
|
||||||
|
if got := strings.Join(assetDirList(dir), ","); got != "./bin,./assets" {
|
||||||
|
t.Errorf("assetDirList = %q", got)
|
||||||
|
}
|
||||||
|
// singular reads properly, and a project without the directories lists none
|
||||||
|
if got := assetSummary(assets[:1]); !strings.HasPrefix(got, "1 asset,") {
|
||||||
|
t.Errorf("assetSummary(one) = %q", got)
|
||||||
|
}
|
||||||
|
if got := assetDirList(t.TempDir()); len(got) != 0 {
|
||||||
|
t.Errorf("assetDirList of a bare project = %v", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,12 +1,22 @@
|
|||||||
#!/bin/sh
|
#!/bin/sh
|
||||||
# Build mgsh, auto-incrementing the patch version by 0.0.1 on every build.
|
# Build mgsh for the usual platforms into ./bin, auto-incrementing the patch
|
||||||
|
# version by 0.0.1 on every build.
|
||||||
#
|
#
|
||||||
# version.txt holds the currently built version. Each run increments the patch
|
# version.txt holds the currently built version. Each run increments the patch
|
||||||
# component, then builds with that version injected via -ldflags, and writes it
|
# component, then builds every platform with that one version injected via
|
||||||
# back. So version.txt always reflects the version of the binary just built.
|
# -ldflags, and writes it back. So version.txt always reflects the version of
|
||||||
|
# the binaries just built, and all of them carry the same one.
|
||||||
|
#
|
||||||
|
# Override the platform list to build just one:
|
||||||
|
# PLATFORMS="linux/amd64" ./build.sh
|
||||||
|
#
|
||||||
|
# Windows is deliberately absent: mgsh shells out to stty and /bin/sh, so it
|
||||||
|
# would compile there and then not work.
|
||||||
set -e
|
set -e
|
||||||
cd "$(dirname "$0")"
|
cd "$(dirname "$0")"
|
||||||
|
|
||||||
|
PLATFORMS=${PLATFORMS:-"darwin/arm64 darwin/amd64 linux/amd64 linux/arm64"}
|
||||||
|
|
||||||
V=$(cat version.txt 2>/dev/null || echo 4.0.0)
|
V=$(cat version.txt 2>/dev/null || echo 4.0.0)
|
||||||
|
|
||||||
# split MAJOR.MINOR.PATCH and increment PATCH (no carry: 4.0.9 -> 4.0.10)
|
# split MAJOR.MINOR.PATCH and increment PATCH (no carry: 4.0.9 -> 4.0.10)
|
||||||
@@ -17,7 +27,34 @@ PATCH=${REST#*.}
|
|||||||
PATCH=$((PATCH + 1))
|
PATCH=$((PATCH + 1))
|
||||||
NV="$MAJOR.$MINOR.$PATCH"
|
NV="$MAJOR.$MINOR.$PATCH"
|
||||||
|
|
||||||
go build -ldflags "-X main.VERSION=$NV" -o mgsh .
|
# earlier versions built ./mgsh in the repo root; drop it so nothing keeps
|
||||||
|
# running a stale binary from a path that is no longer written
|
||||||
|
if [ -f mgsh ]; then
|
||||||
|
rm -f mgsh
|
||||||
|
echo "removed stale ./mgsh (the build now writes ./bin)"
|
||||||
|
fi
|
||||||
|
|
||||||
|
mkdir -p bin
|
||||||
|
HOST="$(go env GOOS)/$(go env GOARCH)"
|
||||||
|
|
||||||
|
for p in $PLATFORMS; do
|
||||||
|
os=${p%/*}
|
||||||
|
arch=${p#*/}
|
||||||
|
out="bin/mgsh-$os-$arch"
|
||||||
|
|
||||||
|
# CGO_ENABLED=0 throughout: it makes the cross builds work without a
|
||||||
|
# toolchain per target and the binaries static, and os/user still resolves
|
||||||
|
# the current user without cgo on both darwin and linux.
|
||||||
|
CGO_ENABLED=0 GOOS="$os" GOARCH="$arch" \
|
||||||
|
go build -ldflags "-X main.VERSION=$NV" -o "$out" .
|
||||||
|
|
||||||
|
if [ "$p" = "$HOST" ]; then
|
||||||
|
ln -sf "mgsh-$os-$arch" bin/mgsh # the one for this machine
|
||||||
|
echo " $out -> bin/mgsh"
|
||||||
|
else
|
||||||
|
echo " $out"
|
||||||
|
fi
|
||||||
|
done
|
||||||
|
|
||||||
echo "$NV" > version.txt
|
echo "$NV" > version.txt
|
||||||
echo "built mgsh v$NV"
|
echo "built mgsh v$NV"
|
||||||
|
|||||||
-40
@@ -573,45 +573,6 @@ func runCommandDepth(line string, depth int) bool {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
case "open", "view": // open project in Xcode / editor
|
|
||||||
prj := PRJ
|
|
||||||
if w := word(words, 1); w != "" {
|
|
||||||
prj = w
|
|
||||||
}
|
|
||||||
d := BASE + "/" + prj
|
|
||||||
if !validProject(prj) || !isDir(d) {
|
|
||||||
errorln("not found")
|
|
||||||
break
|
|
||||||
}
|
|
||||||
xws, xprj := "", ""
|
|
||||||
if entries, err := os.ReadDir(d); err == nil {
|
|
||||||
for _, e := range entries {
|
|
||||||
if strings.HasSuffix(e.Name(), ".xcworkspace") {
|
|
||||||
xws = e.Name()
|
|
||||||
}
|
|
||||||
if strings.HasSuffix(e.Name(), ".xcodeproj") {
|
|
||||||
xprj = e.Name()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
// prefer the workspace over the project; fall back to the editor unless
|
|
||||||
// one of them is really openable (a name match on a plain file is not).
|
|
||||||
switch {
|
|
||||||
case xws != "" && isDir(d+"/"+xws):
|
|
||||||
runInDir(d, "open", xws)
|
|
||||||
case xprj != "" && isDir(d+"/"+xprj):
|
|
||||||
runInDir(d, "open", xprj)
|
|
||||||
default:
|
|
||||||
editor := cfg.Editor
|
|
||||||
if editor == "" {
|
|
||||||
editor = "coda"
|
|
||||||
}
|
|
||||||
runInDir(d, editor, d)
|
|
||||||
}
|
|
||||||
if words[0] == "open" {
|
|
||||||
PRJ = prj
|
|
||||||
}
|
|
||||||
|
|
||||||
case "count": // count source lines in the project
|
case "count": // count source lines in the project
|
||||||
if !requireProject() {
|
if !requireProject() {
|
||||||
break
|
break
|
||||||
@@ -809,7 +770,6 @@ const gitignore = `.DS_Store
|
|||||||
|
|
||||||
var helpItems = []struct{ cmd, desc string }{
|
var helpItems = []struct{ cmd, desc string }{
|
||||||
{"cd [project]", "change project (no argument: back to the base)"},
|
{"cd [project]", "change project (no argument: back to the base)"},
|
||||||
{"open [project]", "open project"},
|
|
||||||
{"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)"},
|
||||||
|
|||||||
+1
-3
@@ -45,15 +45,13 @@ func runeSuffixes(cands []string, prefix string) ([][]rune, int) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// builtinCompleter is the command tree. Command names complete at the start of
|
// builtinCompleter is the command tree. Command names complete at the start of
|
||||||
// the line; cd/open/view complete local project names; clone/show complete
|
// the line; cd completes local project names; clone/show complete
|
||||||
// repository names cached from the git server; checkout/tag complete branch and
|
// repository names cached from the git server; checkout/tag complete branch and
|
||||||
// tag names; pushremote/release complete mirror targets; dist completes
|
// tag names; pushremote/release complete mirror targets; dist completes
|
||||||
// filesystem paths.
|
// filesystem paths.
|
||||||
func builtinCompleter() *readline.PrefixCompleter {
|
func builtinCompleter() *readline.PrefixCompleter {
|
||||||
return readline.NewPrefixCompleter(
|
return readline.NewPrefixCompleter(
|
||||||
readline.PcItem("cd", readline.PcItemDynamic(dynLocalProjects)),
|
readline.PcItem("cd", readline.PcItemDynamic(dynLocalProjects)),
|
||||||
readline.PcItem("open", readline.PcItemDynamic(dynLocalProjects)),
|
|
||||||
readline.PcItem("view", readline.PcItemDynamic(dynLocalProjects)),
|
|
||||||
readline.PcItem("clone",
|
readline.PcItem("clone",
|
||||||
readline.PcItem("-a", readline.PcItemDynamic(dynServerArchives)),
|
readline.PcItem("-a", readline.PcItemDynamic(dynServerArchives)),
|
||||||
readline.PcItemDynamic(dynServerRepos),
|
readline.PcItemDynamic(dynServerRepos),
|
||||||
|
|||||||
@@ -33,7 +33,6 @@ type Config struct {
|
|||||||
GitName string // git user.name to set globally ("" = leave alone)
|
GitName string // git user.name to set globally ("" = leave alone)
|
||||||
GitEmail string // git user.email to set globally ("" = leave alone)
|
GitEmail string // git user.email to set globally ("" = leave alone)
|
||||||
PushDefault string // git push.default to set globally ("" = leave alone)
|
PushDefault string // git push.default to set globally ("" = leave alone)
|
||||||
Editor string // editor/opener used as fallback by `open` ("" = coda)
|
|
||||||
Mirror string // truthy -> `push` also mirrors via `pushremote`
|
Mirror string // truthy -> `push` also mirrors via `pushremote`
|
||||||
SecretScan string // falsy -> `push` skips the credential scan
|
SecretScan string // falsy -> `push` skips the credential scan
|
||||||
Remotes []RemoteTarget
|
Remotes []RemoteTarget
|
||||||
@@ -320,7 +319,7 @@ func writeConfigTemplate(path string) {
|
|||||||
b.WriteString("# gitname = Your Name\n")
|
b.WriteString("# gitname = Your Name\n")
|
||||||
b.WriteString("# gitemail = you@example.com\n")
|
b.WriteString("# gitemail = you@example.com\n")
|
||||||
b.WriteString("# pushdefault = matching\n")
|
b.WriteString("# pushdefault = matching\n")
|
||||||
b.WriteString("# editor = code\n\n")
|
b.WriteString("\n")
|
||||||
b.WriteString("# --- public mirrors for `pushremote` ---\n")
|
b.WriteString("# --- public mirrors for `pushremote` ---\n")
|
||||||
b.WriteString("# One 'remote.<name>.*' block per server. `pushremote` pushes to all\n")
|
b.WriteString("# One 'remote.<name>.*' block per server. `pushremote` pushes to all\n")
|
||||||
b.WriteString("# of them, `pushremote @gitlab` to a single one. <name> is also the\n")
|
b.WriteString("# of them, `pushremote @gitlab` to a single one. <name> is also the\n")
|
||||||
@@ -409,7 +408,6 @@ func applyConfig(c *Config, m map[string]string) {
|
|||||||
set("gitname", &c.GitName)
|
set("gitname", &c.GitName)
|
||||||
set("gitemail", &c.GitEmail)
|
set("gitemail", &c.GitEmail)
|
||||||
set("pushdefault", &c.PushDefault)
|
set("pushdefault", &c.PushDefault)
|
||||||
set("editor", &c.Editor)
|
|
||||||
set("remotes", &c.RemoteNames)
|
set("remotes", &c.RemoteNames)
|
||||||
set("mirror", &c.Mirror)
|
set("mirror", &c.Mirror)
|
||||||
set("secretscan", &c.SecretScan)
|
set("secretscan", &c.SecretScan)
|
||||||
@@ -505,7 +503,6 @@ func applyEnv(c *Config) {
|
|||||||
env("MGSH_GITNAME", &c.GitName)
|
env("MGSH_GITNAME", &c.GitName)
|
||||||
env("MGSH_GITEMAIL", &c.GitEmail)
|
env("MGSH_GITEMAIL", &c.GitEmail)
|
||||||
env("MGSH_PUSHDEFAULT", &c.PushDefault)
|
env("MGSH_PUSHDEFAULT", &c.PushDefault)
|
||||||
env("MGSH_EDITOR", &c.Editor)
|
|
||||||
env("MGSH_REMOTES", &c.RemoteNames)
|
env("MGSH_REMOTES", &c.RemoteNames)
|
||||||
env("MGSH_MIRROR", &c.Mirror)
|
env("MGSH_MIRROR", &c.Mirror)
|
||||||
env("MGSH_SECRETSCAN", &c.SecretScan)
|
env("MGSH_SECRETSCAN", &c.SecretScan)
|
||||||
|
|||||||
@@ -1,5 +1,11 @@
|
|||||||
package main
|
package main
|
||||||
|
|
||||||
|
// input.go — the y/n prompts that guard the destructive commands.
|
||||||
|
//
|
||||||
|
// These questions are the only thing standing between `init` and a wiped
|
||||||
|
// server repository, so the terminal handling here has to be exactly right: a
|
||||||
|
// question that cannot be answered is worse than no question at all.
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"fmt"
|
"fmt"
|
||||||
"os"
|
"os"
|
||||||
@@ -13,50 +19,86 @@ import (
|
|||||||
// variable because it guards the destructive operations: tests replace it to
|
// variable because it guards the destructive operations: tests replace it to
|
||||||
// drive those paths without a terminal, and to assert that the question was
|
// drive those paths without a terminal, and to assert that the question was
|
||||||
// asked at all.
|
// asked at all.
|
||||||
|
//
|
||||||
|
// When the keypress cannot be read at all the answer is no, whatever the
|
||||||
|
// default says — a question nobody saw must never be taken as consent.
|
||||||
var yesno = func(prompt string, def bool) bool {
|
var yesno = func(prompt string, def bool) bool {
|
||||||
suffix := " y/N ? "
|
suffix := " y/N ? "
|
||||||
if def {
|
if def {
|
||||||
suffix = " Y/n ? "
|
suffix = " Y/n ? "
|
||||||
}
|
}
|
||||||
ans := strings.ToLower(strings.TrimSpace(getkey(prompt + suffix)))
|
key, ok := getkey(prompt + suffix)
|
||||||
|
if !ok {
|
||||||
|
errorln("could not read an answer from the terminal — assuming no")
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
ans := strings.ToLower(strings.TrimSpace(key))
|
||||||
if ans == "" {
|
if ans == "" {
|
||||||
return def
|
return def
|
||||||
}
|
}
|
||||||
return ans == "y"
|
return ans == "y"
|
||||||
}
|
}
|
||||||
|
|
||||||
// getkey reads a single keypress from the terminal without echo. It reads a
|
// keyModeArgs put the terminal into single-key input: one keypress is delivered
|
||||||
// single byte directly from stdin; readline is not reading at this point (we
|
// as it is typed, and it is not echoed.
|
||||||
// are inside command execution), so there is no reader to desync with.
|
//
|
||||||
|
// MIN and TIME are set explicitly, and that is not decoration. They are not
|
||||||
|
// part of the canonical/non-canonical switch — they live in their own slots of
|
||||||
|
// the control-character array and survive `stty icanon`. drainTTY leaves them
|
||||||
|
// at "min 0 time 0" ("return what is buffered, do not wait"), so without this
|
||||||
|
// the *second* question of a session read zero bytes, answered itself with its
|
||||||
|
// default, and left the keypress queued for the next prompt line.
|
||||||
|
var keyModeArgs = []string{"-icanon", "-echo", "min", "1", "time", "0"}
|
||||||
|
|
||||||
|
// getkey reads a single keypress from the terminal without echo, and reports
|
||||||
|
// whether it got one. It reads a single byte directly from stdin; readline only
|
||||||
|
// reads while it is inside Readline(), and we are inside command execution
|
||||||
|
// here, so there is no reader to desync with.
|
||||||
//
|
//
|
||||||
// Anything else already typed on the same line is discarded: answering "yes"
|
// Anything else already typed on the same line is discarded: answering "yes"
|
||||||
// to a y/n prompt must not leave "es\n" queued for the next readline call,
|
// to a y/n prompt must not leave "es\n" queued for the next readline call,
|
||||||
// where it would come back as a bogus command.
|
// where it would come back as a bogus command.
|
||||||
func getkey(prompt string) string {
|
func getkey(prompt string) (string, bool) {
|
||||||
fmt.Print(prompt)
|
fmt.Print(prompt)
|
||||||
tty := readline.IsTerminal(int(os.Stdin.Fd()))
|
|
||||||
if tty {
|
restore := func() {}
|
||||||
stty("-icanon", "-echo")
|
if stdinIsTTY() {
|
||||||
|
restore = singleKeyMode()
|
||||||
}
|
}
|
||||||
var buf [1]byte
|
var buf [1]byte
|
||||||
n, err := os.Stdin.Read(buf[:])
|
n, err := os.Stdin.Read(buf[:])
|
||||||
if tty {
|
restore()
|
||||||
drainTTY()
|
|
||||||
stty("icanon", "echo")
|
if err != nil || n == 0 {
|
||||||
}
|
fmt.Println()
|
||||||
key := ""
|
return "", false
|
||||||
if err == nil && n > 0 {
|
|
||||||
key = strings.Trim(string(buf[:n]), "\r\n\t")
|
|
||||||
}
|
}
|
||||||
|
key := strings.Trim(string(buf[:n]), "\r\n\t")
|
||||||
fmt.Println(key)
|
fmt.Println(key)
|
||||||
return key
|
return key, true
|
||||||
|
}
|
||||||
|
|
||||||
|
// singleKeyMode switches the terminal to single-key input and returns the
|
||||||
|
// function that puts it back. The previous settings are restored verbatim from
|
||||||
|
// `stty -g` rather than by naming the flags we changed: naming them is how the
|
||||||
|
// MIN/TIME above were left behind in the first place, and mgsh should hand the
|
||||||
|
// terminal back exactly as it found it.
|
||||||
|
func singleKeyMode() func() {
|
||||||
|
saved, err := sttyRun("-g")
|
||||||
|
saved = strings.TrimSpace(saved)
|
||||||
|
sttyRun(keyModeArgs...)
|
||||||
|
|
||||||
|
if err != nil || saved == "" {
|
||||||
|
return func() { drainTTY(); sttyRun("icanon", "echo") } // best effort
|
||||||
|
}
|
||||||
|
return func() { drainTTY(); sttyRun(strings.Fields(saved)...) }
|
||||||
}
|
}
|
||||||
|
|
||||||
// drainTTY discards input already queued on the terminal. `min 0 time 0` makes
|
// drainTTY discards input already queued on the terminal. `min 0 time 0` makes
|
||||||
// a read return whatever is buffered without waiting, so this cannot block when
|
// a read return whatever is buffered without waiting, so this cannot block when
|
||||||
// nothing is pending.
|
// nothing is pending. Its caller restores the terminal afterwards.
|
||||||
func drainTTY() {
|
func drainTTY() {
|
||||||
stty("-icanon", "-echo", "min", "0", "time", "0")
|
sttyRun("-icanon", "-echo", "min", "0", "time", "0")
|
||||||
buf := make([]byte, 256)
|
buf := make([]byte, 256)
|
||||||
for {
|
for {
|
||||||
n, err := os.Stdin.Read(buf)
|
n, err := os.Stdin.Read(buf)
|
||||||
@@ -66,10 +108,16 @@ func drainTTY() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func stty(args ...string) {
|
// stdinIsTTY reports whether keypresses come from a terminal. A variable so the
|
||||||
|
// tests can exercise the terminal path without one.
|
||||||
|
var stdinIsTTY = func() bool { return readline.IsTerminal(int(os.Stdin.Fd())) }
|
||||||
|
|
||||||
|
// sttyRun runs stty on the terminal and returns its output. Errors are silent:
|
||||||
|
// every caller has a fallback, and a stray "stty: ..." line in the middle of a
|
||||||
|
// half-printed question helps nobody.
|
||||||
|
var sttyRun = func(args ...string) (string, error) {
|
||||||
c := exec.Command("stty", args...)
|
c := exec.Command("stty", args...)
|
||||||
c.Stdin = os.Stdin
|
c.Stdin = os.Stdin
|
||||||
c.Stdout = os.Stdout
|
out, err := c.Output()
|
||||||
c.Stderr = os.Stderr
|
return string(out), err
|
||||||
c.Run()
|
|
||||||
}
|
}
|
||||||
|
|||||||
+226
@@ -0,0 +1,226 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
// input_test.go — the y/n prompt is the last thing between `init` and a wiped
|
||||||
|
// server repository, so the terminal handling around it is pinned here.
|
||||||
|
|
||||||
|
import (
|
||||||
|
"os"
|
||||||
|
"strings"
|
||||||
|
"sync"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
// fakeTTY stands in for the terminal driver. It models the one detail that made
|
||||||
|
// the skipped-question bug possible: MIN and TIME are not part of the
|
||||||
|
// canonical/non-canonical switch, so they survive `stty icanon` and carry over
|
||||||
|
// into the next prompt.
|
||||||
|
type fakeTTY struct {
|
||||||
|
mu sync.Mutex
|
||||||
|
state map[string]string
|
||||||
|
calls [][]string
|
||||||
|
ready chan struct{} // signalled once single-key mode is in effect
|
||||||
|
}
|
||||||
|
|
||||||
|
func newFakeTTY() *fakeTTY {
|
||||||
|
return &fakeTTY{
|
||||||
|
state: map[string]string{"icanon": "on", "echo": "on", "min": "1", "time": "0"},
|
||||||
|
ready: make(chan struct{}, 4),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (f *fakeTTY) run(args ...string) (string, error) {
|
||||||
|
f.mu.Lock()
|
||||||
|
defer f.mu.Unlock()
|
||||||
|
f.calls = append(f.calls, args)
|
||||||
|
|
||||||
|
switch {
|
||||||
|
case len(args) == 1 && args[0] == "-g":
|
||||||
|
return f.serializeLocked(), nil
|
||||||
|
case len(args) == 1 && strings.HasPrefix(args[0], "saved:"):
|
||||||
|
for _, kv := range strings.Split(strings.TrimPrefix(args[0], "saved:"), ",") {
|
||||||
|
if k, v, ok := strings.Cut(kv, "="); ok {
|
||||||
|
f.state[k] = v
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return "", nil
|
||||||
|
}
|
||||||
|
for i := 0; i < len(args); i++ {
|
||||||
|
switch a := args[i]; a {
|
||||||
|
case "min", "time":
|
||||||
|
if i+1 < len(args) {
|
||||||
|
f.state[a] = args[i+1]
|
||||||
|
i++
|
||||||
|
}
|
||||||
|
default:
|
||||||
|
f.state[strings.TrimPrefix(a, "-")] = boolWord(!strings.HasPrefix(a, "-"))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if strings.Join(args, " ") == strings.Join(keyModeArgs, " ") {
|
||||||
|
f.ready <- struct{}{} // single-key mode is set; the read comes next
|
||||||
|
}
|
||||||
|
return "", nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func boolWord(on bool) string {
|
||||||
|
if on {
|
||||||
|
return "on"
|
||||||
|
}
|
||||||
|
return "off"
|
||||||
|
}
|
||||||
|
|
||||||
|
// serializeLocked renders the settings as one token, the way `stty -g` does.
|
||||||
|
func (f *fakeTTY) serializeLocked() string {
|
||||||
|
return "saved:icanon=" + f.state["icanon"] + ",echo=" + f.state["echo"] +
|
||||||
|
",min=" + f.state["min"] + ",time=" + f.state["time"]
|
||||||
|
}
|
||||||
|
|
||||||
|
func (f *fakeTTY) get(k string) string {
|
||||||
|
f.mu.Lock()
|
||||||
|
defer f.mu.Unlock()
|
||||||
|
return f.state[k]
|
||||||
|
}
|
||||||
|
|
||||||
|
func (f *fakeTTY) snapshot() string {
|
||||||
|
f.mu.Lock()
|
||||||
|
defer f.mu.Unlock()
|
||||||
|
return f.serializeLocked()
|
||||||
|
}
|
||||||
|
|
||||||
|
// installFakeTTY points getkey at the fake terminal for the duration of a test.
|
||||||
|
func installFakeTTY(t *testing.T) *fakeTTY {
|
||||||
|
t.Helper()
|
||||||
|
f := newFakeTTY()
|
||||||
|
oldRun, oldIsTTY, oldStdin := sttyRun, stdinIsTTY, os.Stdin
|
||||||
|
sttyRun = f.run
|
||||||
|
stdinIsTTY = func() bool { return true }
|
||||||
|
t.Cleanup(func() {
|
||||||
|
sttyRun, stdinIsTTY, os.Stdin = oldRun, oldIsTTY, oldStdin
|
||||||
|
})
|
||||||
|
return f
|
||||||
|
}
|
||||||
|
|
||||||
|
// askOnce runs one getkey against the fake terminal, answering with keys once
|
||||||
|
// the terminal is actually in single-key mode. It reports the MIN in effect at
|
||||||
|
// the moment of the read — the value the old code got wrong.
|
||||||
|
func askOnce(t *testing.T, f *fakeTTY, keys string) (key string, ok bool, minAtRead string) {
|
||||||
|
t.Helper()
|
||||||
|
r, w, err := os.Pipe()
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
os.Stdin = r
|
||||||
|
|
||||||
|
done := make(chan struct{})
|
||||||
|
go func() {
|
||||||
|
key, ok = getkey("question? ")
|
||||||
|
close(done)
|
||||||
|
}()
|
||||||
|
|
||||||
|
select {
|
||||||
|
case <-f.ready:
|
||||||
|
case <-time.After(5 * time.Second):
|
||||||
|
t.Fatal("the terminal was never put into single-key mode")
|
||||||
|
}
|
||||||
|
minAtRead = f.get("min")
|
||||||
|
w.WriteString(keys)
|
||||||
|
w.Close() // so the drain that follows the keypress sees EOF instead of blocking
|
||||||
|
<-done
|
||||||
|
r.Close()
|
||||||
|
return key, ok, minAtRead
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestEveryQuestionWaitsForAnAnswer is the regression test for the bug where the
|
||||||
|
// second y/n question of a session was skipped: drainTTY left the terminal at
|
||||||
|
// "min 0" ("return what is buffered, do not wait"), the restore only named
|
||||||
|
// icanon and echo, and so the next read returned zero bytes and answered the
|
||||||
|
// question with its default.
|
||||||
|
func TestEveryQuestionWaitsForAnAnswer(t *testing.T) {
|
||||||
|
f := installFakeTTY(t)
|
||||||
|
|
||||||
|
for i, want := range []string{"y", "n", "y"} {
|
||||||
|
key, ok, minAtRead := askOnce(t, f, want)
|
||||||
|
if !ok || key != want {
|
||||||
|
t.Fatalf("question %d: got (%q, %v), want (%q, true)", i+1, key, ok, want)
|
||||||
|
}
|
||||||
|
if minAtRead != "1" {
|
||||||
|
t.Errorf("question %d read the terminal at min=%s, want min=1 — "+
|
||||||
|
"min 0 returns without waiting and answers the question by itself",
|
||||||
|
i+1, minAtRead)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestPromptHandsTheTerminalBackUnchanged: mgsh must not leave the terminal in
|
||||||
|
// a mode it chose. The old restore named only icanon and echo and left "min 0"
|
||||||
|
// behind, which outlived mgsh itself and broke the next program's single-key
|
||||||
|
// reads too.
|
||||||
|
func TestPromptHandsTheTerminalBackUnchanged(t *testing.T) {
|
||||||
|
f := installFakeTTY(t)
|
||||||
|
initial := f.snapshot()
|
||||||
|
|
||||||
|
if _, ok, _ := askOnce(t, f, "y"); !ok {
|
||||||
|
t.Fatal("getkey did not read the key")
|
||||||
|
}
|
||||||
|
if got := f.snapshot(); got != initial {
|
||||||
|
t.Errorf("terminal left as %s, want it back at %s", got, initial)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestPromptRestoresFromTheSavedState pins *how* the terminal is restored: from
|
||||||
|
// the state captured with `stty -g`, not by naming the flags we changed.
|
||||||
|
func TestPromptRestoresFromTheSavedState(t *testing.T) {
|
||||||
|
f := installFakeTTY(t)
|
||||||
|
if _, ok, _ := askOnce(t, f, "y"); !ok {
|
||||||
|
t.Fatal("getkey did not read the key")
|
||||||
|
}
|
||||||
|
|
||||||
|
f.mu.Lock()
|
||||||
|
defer f.mu.Unlock()
|
||||||
|
if len(f.calls) == 0 {
|
||||||
|
t.Fatal("stty was never called")
|
||||||
|
}
|
||||||
|
if first := f.calls[0]; len(first) != 1 || first[0] != "-g" {
|
||||||
|
t.Errorf("first stty call was %v, want [-g]: the state has to be captured "+
|
||||||
|
"before it is changed", first)
|
||||||
|
}
|
||||||
|
last := f.calls[len(f.calls)-1]
|
||||||
|
if len(last) != 1 || !strings.HasPrefix(last[0], "saved:") {
|
||||||
|
t.Errorf("last stty call was %v, want the saved state played back", last)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestSingleKeyModeSetsMinExplicitly: MIN and TIME are inherited, so they have
|
||||||
|
// to be named on the way in. Without that, a terminal left at "min 0" by an
|
||||||
|
// earlier program (or an earlier mgsh) skips the question.
|
||||||
|
func TestSingleKeyModeSetsMinExplicitly(t *testing.T) {
|
||||||
|
args := strings.Join(keyModeArgs, " ")
|
||||||
|
for _, want := range []string{"-icanon", "-echo", "min 1", "time 0"} {
|
||||||
|
if !strings.Contains(args, want) {
|
||||||
|
t.Errorf("single-key mode is %q, missing %q", args, want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestQuestionSkippedByAPoisonedTerminalIsNotAYes: even if the terminal is
|
||||||
|
// already in the broken state when mgsh starts, the answer must not be the
|
||||||
|
// default — nobody saw the question, so nobody agreed to anything.
|
||||||
|
func TestQuestionSkippedByAPoisonedTerminalIsNotAYes(t *testing.T) {
|
||||||
|
oldRun, oldIsTTY, oldStdin := sttyRun, stdinIsTTY, os.Stdin
|
||||||
|
defer func() { sttyRun, stdinIsTTY, os.Stdin = oldRun, oldIsTTY, oldStdin }()
|
||||||
|
|
||||||
|
sttyRun = func(args ...string) (string, error) { return "", nil }
|
||||||
|
stdinIsTTY = func() bool { return true }
|
||||||
|
|
||||||
|
r, w, _ := os.Pipe()
|
||||||
|
w.Close() // a terminal that returns nothing at all
|
||||||
|
os.Stdin = r
|
||||||
|
defer r.Close()
|
||||||
|
|
||||||
|
if key, ok := getkey("question? "); ok {
|
||||||
|
t.Fatalf("getkey reported a key %q from a terminal that gave none", key)
|
||||||
|
}
|
||||||
|
if yesno("destroy everything?", true) {
|
||||||
|
t.Error("an unanswerable question was taken as yes")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -145,7 +145,7 @@ 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, "open": 1,
|
"push": 1, "pushremote": 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,
|
||||||
}
|
}
|
||||||
|
|||||||
+8
-8
@@ -142,7 +142,7 @@ func TestParseConfig(t *testing.T) {
|
|||||||
githost = 10.0.0.1
|
githost = 10.0.0.1
|
||||||
GitPort: 22
|
GitPort: 22
|
||||||
gituser = "deploy"
|
gituser = "deploy"
|
||||||
editor = 'code'
|
gitkey = 'mgit_rsa'
|
||||||
ignored line without separator
|
ignored line without separator
|
||||||
base=/tmp/src
|
base=/tmp/src
|
||||||
`
|
`
|
||||||
@@ -151,7 +151,7 @@ base=/tmp/src
|
|||||||
"githost": "10.0.0.1",
|
"githost": "10.0.0.1",
|
||||||
"gitport": "22",
|
"gitport": "22",
|
||||||
"gituser": "deploy",
|
"gituser": "deploy",
|
||||||
"editor": "code",
|
"gitkey": "mgit_rsa",
|
||||||
"base": "/tmp/src",
|
"base": "/tmp/src",
|
||||||
}
|
}
|
||||||
for k, want := range checks {
|
for k, want := range checks {
|
||||||
@@ -166,7 +166,7 @@ base=/tmp/src
|
|||||||
|
|
||||||
func TestParseConfigInlineComments(t *testing.T) {
|
func TestParseConfigInlineComments(t *testing.T) {
|
||||||
rc := `
|
rc := `
|
||||||
editor = code # fallback opener for ` + "`open`" + `
|
gitkey = mgit_rsa # fallback opener comment
|
||||||
mirror = true # ` + "`push`" + ` also mirrors via pushremote
|
mirror = true # ` + "`push`" + ` also mirrors via pushremote
|
||||||
gitport = 22 # ssh port
|
gitport = 22 # ssh port
|
||||||
remotekey = abc#123
|
remotekey = abc#123
|
||||||
@@ -176,7 +176,7 @@ gitemail = # value is only a comment
|
|||||||
`
|
`
|
||||||
m := parseConfig(rc)
|
m := parseConfig(rc)
|
||||||
checks := map[string]string{
|
checks := map[string]string{
|
||||||
"editor": "code",
|
"gitkey": "mgit_rsa",
|
||||||
"mirror": "true",
|
"mirror": "true",
|
||||||
"gitport": "22",
|
"gitport": "22",
|
||||||
"remotekey": "abc#123", // '#' not preceded by space stays part of the value
|
"remotekey": "abc#123", // '#' not preceded by space stays part of the value
|
||||||
@@ -539,7 +539,7 @@ func TestResolveProjectConfig(t *testing.T) {
|
|||||||
dir := t.TempDir()
|
dir := t.TempDir()
|
||||||
global := Config{
|
global := Config{
|
||||||
Base: "/base", GitHost: "global.example", GitPort: "22", GitUser: "git",
|
Base: "/base", GitHost: "global.example", GitPort: "22", GitUser: "git",
|
||||||
GitPath: "/home/git", GitName: "Global Name", Editor: "vi",
|
GitPath: "/home/git", GitName: "Global Name", GitKey: "global_rsa",
|
||||||
Remotes: []RemoteTarget{{Name: "gitea", URL: "https://gitea.example", Key: "tok"}},
|
Remotes: []RemoteTarget{{Name: "gitea", URL: "https://gitea.example", Key: "tok"}},
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -550,7 +550,7 @@ func TestResolveProjectConfig(t *testing.T) {
|
|||||||
|
|
||||||
rc := `
|
rc := `
|
||||||
githost = project.example
|
githost = project.example
|
||||||
editor = code
|
gitkey = project_rsa
|
||||||
base = /somewhere/else
|
base = /somewhere/else
|
||||||
gitname = Project Name
|
gitname = Project Name
|
||||||
remote.hub.url = https://github.com
|
remote.hub.url = https://github.com
|
||||||
@@ -562,8 +562,8 @@ remote.gitea.visibility = public
|
|||||||
}
|
}
|
||||||
got := resolveConfig(global, dir)
|
got := resolveConfig(global, dir)
|
||||||
|
|
||||||
if got.GitHost != "project.example" || got.Editor != "code" {
|
if got.GitHost != "project.example" || got.GitKey != "project_rsa" {
|
||||||
t.Errorf("project overrides not applied: host=%q editor=%q", got.GitHost, got.Editor)
|
t.Errorf("project overrides not applied: host=%q gitkey=%q", got.GitHost, got.GitKey)
|
||||||
}
|
}
|
||||||
// base and the git identity stay global
|
// base and the git identity stay global
|
||||||
if got.Base != "/base" {
|
if got.Base != "/base" {
|
||||||
|
|||||||
@@ -20,7 +20,6 @@ gitpath = /home/git
|
|||||||
# gitname = Your Name
|
# gitname = Your Name
|
||||||
# gitemail = you@example.com
|
# gitemail = you@example.com
|
||||||
# pushdefault = matching
|
# pushdefault = matching
|
||||||
# editor = code
|
|
||||||
|
|
||||||
# --- pushremote: mirror to public servers (gitea/github/gitlab) via their API ---
|
# --- pushremote: mirror to public servers (gitea/github/gitlab) via their API ---
|
||||||
# One "remote.<name>.<field>" block per server, with the fields url, key, type
|
# One "remote.<name>.<field>" block per server, with the fields url, key, type
|
||||||
|
|||||||
+65
-25
@@ -56,46 +56,68 @@ func (r *remoteAPI) releaseByTagPath(owner, repo, tag string) string {
|
|||||||
return r.releasePath(owner, repo) + "/tags/" + url.PathEscape(tag)
|
return r.releasePath(owner, repo) + "/tags/" + url.PathEscape(tag)
|
||||||
}
|
}
|
||||||
|
|
||||||
// findRelease returns the provider's id for the release of tag and whether it
|
// releaseRef identifies a release for the calls that follow creating it.
|
||||||
// exists at all. GitLab addresses releases by tag, so there the tag is the id.
|
type releaseRef struct {
|
||||||
func (r *remoteAPI) findRelease(owner, repo, tag string) (id string, found bool, err error) {
|
tag string
|
||||||
|
id string // provider id; the tag itself on GitLab
|
||||||
|
// GitHub uploads assets to a different host than its API, and names it in
|
||||||
|
// the release object as an RFC 6570 template
|
||||||
|
uploadURL string
|
||||||
|
}
|
||||||
|
|
||||||
|
// parseReleaseRef reads the identifying fields out of a release object.
|
||||||
|
func (r *remoteAPI) parseReleaseRef(tag string, data []byte) releaseRef {
|
||||||
|
ref := releaseRef{tag: tag, id: tag}
|
||||||
|
if r.kind == kindGitLab {
|
||||||
|
return ref // GitLab addresses a release by its tag throughout
|
||||||
|
}
|
||||||
|
var res struct {
|
||||||
|
ID int64 `json:"id"`
|
||||||
|
UploadURL string `json:"upload_url"`
|
||||||
|
}
|
||||||
|
json.Unmarshal(data, &res)
|
||||||
|
ref.id = strconv.FormatInt(res.ID, 10)
|
||||||
|
// ".../assets{?name,label}" -> ".../assets"
|
||||||
|
if i := strings.IndexByte(res.UploadURL, '{'); i >= 0 {
|
||||||
|
ref.uploadURL = res.UploadURL[:i]
|
||||||
|
} else {
|
||||||
|
ref.uploadURL = res.UploadURL
|
||||||
|
}
|
||||||
|
return ref
|
||||||
|
}
|
||||||
|
|
||||||
|
// findRelease returns the release of tag and whether it exists at all.
|
||||||
|
func (r *remoteAPI) findRelease(owner, repo, tag string) (ref releaseRef, found bool, err error) {
|
||||||
code, data, err := r.do("GET", r.releaseByTagPath(owner, repo, tag), nil)
|
code, data, err := r.do("GET", r.releaseByTagPath(owner, repo, tag), nil)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return "", false, err
|
return releaseRef{}, false, err
|
||||||
}
|
}
|
||||||
switch code {
|
switch code {
|
||||||
case 404:
|
case 404:
|
||||||
return "", false, nil
|
return releaseRef{}, false, nil
|
||||||
case 200:
|
case 200:
|
||||||
if r.kind == kindGitLab {
|
return r.parseReleaseRef(tag, data), true, nil
|
||||||
return tag, true, nil
|
|
||||||
}
|
|
||||||
var res struct {
|
|
||||||
ID int64 `json:"id"`
|
|
||||||
}
|
|
||||||
json.Unmarshal(data, &res)
|
|
||||||
return strconv.FormatInt(res.ID, 10), true, nil
|
|
||||||
default:
|
default:
|
||||||
return "", false, fmt.Errorf("checking release failed (HTTP %d): %s", code, firstLine(data))
|
return releaseRef{}, false, fmt.Errorf("checking release failed (HTTP %d): %s", code, firstLine(data))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// createRelease publishes a new release for an already pushed tag.
|
// createRelease publishes a new release for an already pushed tag.
|
||||||
func (r *remoteAPI) createRelease(owner, repo string, rel release) error {
|
func (r *remoteAPI) createRelease(owner, repo string, rel release) (releaseRef, error) {
|
||||||
code, data, err := r.do("POST", r.releasePath(owner, repo), r.releaseBody(rel, true))
|
code, data, err := r.do("POST", r.releasePath(owner, repo), r.releaseBody(rel, true))
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return releaseRef{}, err
|
||||||
}
|
}
|
||||||
if code != 200 && code != 201 {
|
if code != 200 && code != 201 {
|
||||||
return fmt.Errorf("creating release failed (HTTP %d): %s", code, firstLine(data))
|
return releaseRef{}, fmt.Errorf("creating release failed (HTTP %d): %s", code, firstLine(data))
|
||||||
}
|
}
|
||||||
return nil
|
return r.parseReleaseRef(rel.Tag, data), nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// updateRelease rewrites the name and notes of an existing release. Gitea and
|
// updateRelease rewrites the name and notes of an existing release. Gitea and
|
||||||
// GitHub patch it by numeric id, GitLab puts it by tag.
|
// GitHub patch it by numeric id, GitLab puts it by tag.
|
||||||
func (r *remoteAPI) updateRelease(owner, repo, id string, rel release) error {
|
func (r *remoteAPI) updateRelease(owner, repo string, ref releaseRef, rel release) error {
|
||||||
method, ep := "PATCH", r.releasePath(owner, repo)+"/"+id
|
method, ep := "PATCH", r.releasePath(owner, repo)+"/"+ref.id
|
||||||
if r.kind == kindGitLab {
|
if r.kind == kindGitLab {
|
||||||
method, ep = "PUT", r.releaseByTagPath(owner, repo, rel.Tag)
|
method, ep = "PUT", r.releaseByTagPath(owner, repo, rel.Tag)
|
||||||
}
|
}
|
||||||
@@ -164,9 +186,20 @@ func handleRelease(args string) {
|
|||||||
body = releaseNotes(DIR, tag)
|
body = releaseNotes(DIR, tag)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ./bin and ./assets ride along when they are there; nothing to configure,
|
||||||
|
// and nothing happens for a project that has neither
|
||||||
|
assets, skipped := collectAssets(DIR)
|
||||||
|
for _, s := range skipped {
|
||||||
|
errorln("skipping " + s + ": another directory already contributes that name")
|
||||||
|
}
|
||||||
|
if len(assets) > 0 {
|
||||||
|
fmt.Printf("%s %s %s\n", col(cGray, "attaching"), col(cYellow, assetSummary(assets)),
|
||||||
|
col(cGray, "from "+strings.Join(assetDirList(DIR), " and ")))
|
||||||
|
}
|
||||||
|
|
||||||
done := 0
|
done := 0
|
||||||
for _, t := range targets {
|
for _, t := range targets {
|
||||||
if publishRelease(t, PRJ, tag, body) {
|
if publishRelease(t, PRJ, tag, body, assets) {
|
||||||
done++
|
done++
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -223,7 +256,7 @@ func releaseNotes(dir, tag string) string {
|
|||||||
// publishRelease pushes the tag to one mirror target and turns it into a
|
// publishRelease pushes the tag to one mirror target and turns it into a
|
||||||
// release there. It returns success, so one unreachable server does not stop
|
// release there. It returns success, so one unreachable server does not stop
|
||||||
// the remaining ones.
|
// the remaining ones.
|
||||||
func publishRelease(t RemoteTarget, repo, tag, body string) bool {
|
func publishRelease(t RemoteTarget, repo, tag, body string, assets []releaseAsset) bool {
|
||||||
api := newRemoteAPI(t.URL, t.Key, t.Type)
|
api := newRemoteAPI(t.URL, t.Key, t.Type)
|
||||||
|
|
||||||
owner, err := api.authUser()
|
owner, err := api.authUser()
|
||||||
@@ -248,17 +281,17 @@ func publishRelease(t RemoteTarget, repo, tag, body string) bool {
|
|||||||
}
|
}
|
||||||
|
|
||||||
rel := release{Tag: tag, Name: tag, Body: body, Prerelease: preReleaseRe.MatchString(tag)}
|
rel := release{Tag: tag, Name: tag, Body: body, Prerelease: preReleaseRe.MatchString(tag)}
|
||||||
id, found, err := api.findRelease(owner, repo, tag)
|
ref, found, err := api.findRelease(owner, repo, tag)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
errorln(t.Name + ": " + err.Error())
|
errorln(t.Name + ": " + err.Error())
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
if found {
|
if found {
|
||||||
if err := api.updateRelease(owner, repo, id, rel); err != nil {
|
if err := api.updateRelease(owner, repo, ref, rel); err != nil {
|
||||||
errorln(t.Name + ": " + err.Error())
|
errorln(t.Name + ": " + err.Error())
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
} else if err := api.createRelease(owner, repo, rel); err != nil {
|
} else if ref, err = api.createRelease(owner, repo, rel); err != nil {
|
||||||
errorln(t.Name + ": " + err.Error())
|
errorln(t.Name + ": " + err.Error())
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
@@ -272,5 +305,12 @@ func publishRelease(t RemoteTarget, repo, tag, body string) bool {
|
|||||||
}
|
}
|
||||||
fmt.Printf("%s %s %s %s\n", col(cGray, "remote"), col(cYellow, t.Name),
|
fmt.Printf("%s %s %s %s\n", col(cGray, "remote"), col(cYellow, t.Name),
|
||||||
col(cGreen, what), col(cCyan, api.repoWebURL(owner, repo)))
|
col(cGreen, what), col(cCyan, api.repoWebURL(owner, repo)))
|
||||||
|
|
||||||
|
if len(assets) > 0 {
|
||||||
|
if err := api.uploadAssets(owner, repo, ref, assets); err != nil {
|
||||||
|
errorln(t.Name + ": " + err.Error())
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
|
|||||||
+15
-11
@@ -9,6 +9,7 @@ package main
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
|
"io"
|
||||||
"net/http"
|
"net/http"
|
||||||
"net/http/httptest"
|
"net/http/httptest"
|
||||||
"os"
|
"os"
|
||||||
@@ -20,6 +21,8 @@ import (
|
|||||||
// recordedReq is one request the fake provider received.
|
// recordedReq is one request the fake provider received.
|
||||||
type recordedReq struct {
|
type recordedReq struct {
|
||||||
method, path, query string
|
method, path, query string
|
||||||
|
ctype string
|
||||||
|
raw []byte // the body as sent, for the asset uploads
|
||||||
body map[string]any
|
body map[string]any
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -42,11 +45,12 @@ func newFakeProvider(t *testing.T) *fakeProvider {
|
|||||||
body string
|
body string
|
||||||
}{}}
|
}{}}
|
||||||
f.Server = httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
f.Server = httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
rec := recordedReq{method: r.Method, path: r.URL.EscapedPath(), query: r.URL.RawQuery}
|
rec := recordedReq{method: r.Method, path: r.URL.EscapedPath(), query: r.URL.RawQuery,
|
||||||
|
ctype: r.Header.Get("Content-Type")}
|
||||||
if r.Body != nil {
|
if r.Body != nil {
|
||||||
var m map[string]any
|
raw, _ := io.ReadAll(r.Body)
|
||||||
json.NewDecoder(r.Body).Decode(&m)
|
rec.raw = raw
|
||||||
rec.body = m
|
json.Unmarshal(raw, &rec.body)
|
||||||
}
|
}
|
||||||
f.got = append(f.got, rec)
|
f.got = append(f.got, rec)
|
||||||
|
|
||||||
@@ -106,7 +110,7 @@ func TestCreateReleasePerProvider(t *testing.T) {
|
|||||||
f.route("POST "+c.wantPath, 201, `{"id":7}`)
|
f.route("POST "+c.wantPath, 201, `{"id":7}`)
|
||||||
|
|
||||||
api := newRemoteAPI(f.URL, "tok", c.typ)
|
api := newRemoteAPI(f.URL, "tok", c.typ)
|
||||||
if err := api.createRelease("mike", "mgsh", rel); err != nil {
|
if _, err := api.createRelease("mike", "mgsh", rel); err != nil {
|
||||||
t.Fatalf("%s: createRelease: %v (requests: %v)", c.typ, err, f.paths())
|
t.Fatalf("%s: createRelease: %v (requests: %v)", c.typ, err, f.paths())
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -145,11 +149,11 @@ func TestFindReleaseAndUpdate(t *testing.T) {
|
|||||||
f.route(c.wantMethod+" "+c.wantUpdate, 200, `{}`)
|
f.route(c.wantMethod+" "+c.wantUpdate, 200, `{}`)
|
||||||
|
|
||||||
api := newRemoteAPI(f.URL, "tok", c.typ)
|
api := newRemoteAPI(f.URL, "tok", c.typ)
|
||||||
id, found, err := api.findRelease("mike", "mgsh", "v1.2")
|
ref, found, err := api.findRelease("mike", "mgsh", "v1.2")
|
||||||
if err != nil || !found {
|
if err != nil || !found {
|
||||||
t.Fatalf("%s: findRelease = %q,%v,%v", c.typ, id, found, err)
|
t.Fatalf("%s: findRelease = %+v,%v,%v", c.typ, ref, found, err)
|
||||||
}
|
}
|
||||||
if err := api.updateRelease("mike", "mgsh", id, release{Tag: "v1.2", Name: "v1.2", Body: "new"}); err != nil {
|
if err := api.updateRelease("mike", "mgsh", ref, release{Tag: "v1.2", Name: "v1.2", Body: "new"}); err != nil {
|
||||||
t.Fatalf("%s: updateRelease: %v (requests: %v)", c.typ, err, f.paths())
|
t.Fatalf("%s: updateRelease: %v (requests: %v)", c.typ, err, f.paths())
|
||||||
}
|
}
|
||||||
req := f.find(c.wantMethod + " " + c.wantUpdate)
|
req := f.find(c.wantMethod + " " + c.wantUpdate)
|
||||||
@@ -168,9 +172,9 @@ func TestFindReleaseAndUpdate(t *testing.T) {
|
|||||||
func TestFindReleaseMissing(t *testing.T) {
|
func TestFindReleaseMissing(t *testing.T) {
|
||||||
f := newFakeProvider(t) // everything 404s
|
f := newFakeProvider(t) // everything 404s
|
||||||
api := newRemoteAPI(f.URL, "tok", "gitea")
|
api := newRemoteAPI(f.URL, "tok", "gitea")
|
||||||
id, found, err := api.findRelease("mike", "mgsh", "v9")
|
ref, found, err := api.findRelease("mike", "mgsh", "v9")
|
||||||
if err != nil || found || id != "" {
|
if err != nil || found || ref.id != "" {
|
||||||
t.Fatalf("findRelease on empty server = %q,%v,%v", id, found, err)
|
t.Fatalf("findRelease on empty server = %+v,%v,%v", ref, found, err)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -214,10 +214,10 @@ func TestShellCandidatesThroughAlias(t *testing.T) {
|
|||||||
shellTree(t)
|
shellTree(t)
|
||||||
fakeCommands(t, "vi", "ls")
|
fakeCommands(t, "vi", "ls")
|
||||||
withAliases(t, map[string]string{
|
withAliases(t, map[string]string{
|
||||||
"ll": "!ls -la",
|
"ll": "!ls -la",
|
||||||
"e": "!vi $1",
|
"e": "!vi $1",
|
||||||
"co": "checkout $1", // a builtin, not a shell command
|
"co": "checkout $1", // expands to a builtin, not a shell command
|
||||||
"open": "!xdg-open $1", // shadows a builtin: must not count
|
"status": "!git status", // shadows a builtin: must not count
|
||||||
})
|
})
|
||||||
|
|
||||||
// arguments of a shell alias complete against the filesystem
|
// arguments of a shell alias complete against the filesystem
|
||||||
@@ -246,7 +246,7 @@ func TestShellCandidatesThroughAlias(t *testing.T) {
|
|||||||
t.Error("an alias expanding to a builtin was treated as a shell line")
|
t.Error("an alias expanding to a builtin was treated as a shell line")
|
||||||
}
|
}
|
||||||
// nor is a name that a builtin owns, since runCommand never expands those
|
// nor is a name that a builtin owns, since runCommand never expands those
|
||||||
if _, _, ok := shellLine("open ma"); ok {
|
if _, _, ok := shellLine("status ma"); ok {
|
||||||
t.Error("a builtin name was resolved through an alias")
|
t.Error("a builtin name was resolved through an alias")
|
||||||
}
|
}
|
||||||
// nor an undefined name
|
// nor an undefined name
|
||||||
|
|||||||
+1
-2
@@ -42,7 +42,6 @@ func showConfig() {
|
|||||||
{"gitname", cfg.GitName},
|
{"gitname", cfg.GitName},
|
||||||
{"gitemail", cfg.GitEmail},
|
{"gitemail", cfg.GitEmail},
|
||||||
{"pushdefault", cfg.PushDefault},
|
{"pushdefault", cfg.PushDefault},
|
||||||
{"editor", cfg.Editor},
|
|
||||||
{"mirror", cfg.Mirror},
|
{"mirror", cfg.Mirror},
|
||||||
{"secretscan", cfg.SecretScan},
|
{"secretscan", cfg.SecretScan},
|
||||||
{"remotes", cfg.RemoteNames},
|
{"remotes", cfg.RemoteNames},
|
||||||
@@ -146,7 +145,7 @@ func envName(key string) string { return "MGSH_" + strings.ToUpper(key) }
|
|||||||
func configKeys() []string {
|
func configKeys() []string {
|
||||||
keys := []string{
|
keys := []string{
|
||||||
"base", "githost", "gitport", "gituser", "gitpath", "gitkey",
|
"base", "githost", "gitport", "gituser", "gitpath", "gitkey",
|
||||||
"gitname", "gitemail", "pushdefault", "editor",
|
"gitname", "gitemail", "pushdefault",
|
||||||
"remotes", "mirror", "secretscan",
|
"remotes", "mirror", "secretscan",
|
||||||
}
|
}
|
||||||
sort.Strings(keys)
|
sort.Strings(keys)
|
||||||
|
|||||||
+1
-1
@@ -1 +1 @@
|
|||||||
4.0.46
|
4.0.57
|
||||||
|
|||||||
Reference in New Issue
Block a user