6 Commits
Author SHA1 Message Date
mikeandClaude Opus 5 8c4d8da4e9 Attach ./bin and ./assets to a release
`release` now uploads every file in the project's ./bin and ./assets when
those directories exist. Nothing to configure, and nothing happens for a
project that has neither.

This is the part deliberately left out when `release` was written,
because it is where the three providers stop resembling each other:

  Gitea   multipart POST to .../releases/<id>/assets?name=<name>
  GitHub  raw POST to the separate upload host the release object names
          in upload_url, whose RFC 6570 template suffix has to go first
  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

So findRelease and createRelease now return a releaseRef carrying the id
and, for GitHub, that upload host -- the id alone cannot address an
upload. Uploads stream from disk rather than buffering: these are whole
binaries, and the Gitea multipart body is assembled through a pipe.

Only regular files directly in those directories are taken. Symlinks are
skipped, which matters here: build.sh leaves bin/mgsh pointing at one of
its siblings, and uploading the same 9M twice under two names helps
nobody. A name present in both directories is used from bin and reported
for assets. Re-releasing a tag replaces same-named assets rather than
failing on them, since rebuilding and publishing again is the normal
reason to do it, and a file that fails does not stop the rest.

Each provider's request shape is pinned down against the recording
stand-in, and the whole chain was run once end to end -- real repository,
real binaries, a fake Gitea that also serves git-http-backend so the tag
push is real too.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-26 20:13:09 +02:00
mikeandClaude Opus 5 f3d8cbe281 Cross-compile into ./bin for the usual platforms
build.sh produced one binary for the machine it ran on. It now builds
darwin/arm64, darwin/amd64, linux/amd64 and linux/arm64 into ./bin, all
from the single version that run bumped, so the four never disagree about
what they are. PLATFORMS="linux/amd64" ./build.sh narrows it.

bin/mgsh is a symlink to the host's build, so there is still one stable
path to "the binary for this machine". The old ./mgsh in the repo root is
no longer written, and the script deletes it once if it finds it -- a
stale binary on a path people have in their fingers is worse than a
missing one.

CGO_ENABLED=0 throughout: the cross builds then need no toolchain per
target and the binaries are static. That is only safe because os/user
still resolves the current user without cgo, which mgsh needs for the
"[user@host]" stamp on every commit -- checked on darwin before relying
on it.

Windows is left out on purpose: mgsh shells out to stty and /bin/sh, so
it would compile there and then not work.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-26 19:53:29 +02:00
mikeandClaude Opus 5 cd5ab1a2bd Remove the view command and the editor setting with it
`view` was the last user of `editor`, so leaving the setting behind would
have made it exactly what `gitkey` was until recently: documented,
parsed, and doing nothing. It is gone from the struct, the template, the
environment, `config -k` and the settings table.

Both names are free for aliases now, as `open` already was.

The tests that used `editor` as their example of a project-overridable
setting use `gitkey` instead, which is the same kind of thing and still
exists.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-26 19:11:06 +02:00
mikeandClaude Opus 5 65342bcd7c Remove the open command
`open` and `view` shared one implementation and differed in a single
line: `open` also made the project the active one. Only `view` is left,
with the behaviour it always had.

Dropping it from builtinCmds is the part worth noting: a reserved word
cannot be shadowed by an alias, so `open` is now free for one --
`alias open '!xdg-open $1'` works, which it could not before. That also
made a completion test wrong, since it used `open` as its example of a
name a builtin owns; it uses `status` now.

The `runInDir(d, "open", ...)` calls stay: those are macOS's open(1),
which is how an Xcode workspace gets opened.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-26 18:35:45 +02:00
mikeandClaude Opus 5 0d0d28560e Complete aliases that expand to a shell escape
`alias ll '!ls -la'` makes everything after `ll` a shell argument just as
surely as typing the `!` does, but Tab there still went to the builtin
command tree and found nothing. The dispatch now asks what a line will
turn into rather than how it starts: a '!' escape, or a name that is not a
builtin and resolves to an alias whose body starts with '!'.

Only the arguments complete — the command word is fixed by the alias
body, so `ll vi` offers the file, never the editor. An alias to a builtin
stays with the builtin tree.

Only the alias itself is inspected, not what its expansion might expand
to in turn: an alias chain can rewrite its own arguments, and guessing at
that would offer candidates for a command line other than the one being
built.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-26 18:28:35 +02:00
mikeandClaude Opus 5 2acca170e6 Complete ! shell escapes like a shell
A line starting with '!' was not in the completer tree at all, so Tab did
nothing there -- just where the paths are longest. It now completes the
way a shell does: the command word against the executables on PATH, the
arguments against the filesystem, resolved relative to the active project
because that is where forwardShell runs the line. Directories complete
with their trailing slash, `~/` and absolute paths work, and dot entries
stay hidden until the prefix asks for one.

readline's completer is a tree of fixed words, which cannot express "a
prefix that is not a word", so this is a small AutoCompleter that
dispatches on the '!' and hands everything else to the existing tree. Its
contract is easy to get subtly wrong -- candidates are the suffixes still
missing, and the length is counted in runes, not bytes -- so the
conversion has its own test, as does completing only the basename inside
a directory, which is what keeps the candidate list readable.

Only the basename is offered inside a directory, PATH is scanned once per
session, and a bare '!' offers nothing rather than every executable on
the machine.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-26 18:18:01 +02:00
18 changed files with 1323 additions and 123 deletions
+1
View File
@@ -8,4 +8,5 @@
.TemporaryItems .TemporaryItems
.Trashes .Trashes
mgsh mgsh
bin/
.mgshrc .mgshrc
+98 -18
View File
@@ -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
PLATFORMS="linux/amd64" ./build.sh # just one
go build -o mgsh . # plain build, keeps the default version 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,9 +74,10 @@ 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`, filesystem paths for `dist`), `checkout`/`tag`, mirror targets for `pushremote`/`release`, filesystem paths for
and colored `list`/`log`/error output. `dist`, and shell-style completion after `!` and for aliases that expand to
one), and colored `list`/`log`/error output.
The server repository list is fetched once per session on the first Tab that The server repository list is fetched once per session on the first Tab that
needs it; `rescan` refreshes it (and reloads the configuration). needs it; `rescan` refreshes it (and reloads the configuration).
@@ -70,6 +93,34 @@ prefix it with `!`:
< src/myproject > !ls -la < src/myproject > !ls -la
``` ```
It runs in the active project's directory. Tab completion works there the way it
does in a shell: the word after the `!` completes against the executables on
`PATH`, everything after it against the filesystem — relative to the project,
with `~/` and absolute paths understood, and directories completing with their
trailing slash so the next Tab walks into them. Dot entries stay out of the way
until the prefix asks for one.
```
< src/myproject > !vi ma<Tab> -> !vi main
< src/myproject > !vi <Tab> -> Makefile main.go main_test.go src/
< src/myproject > !gre<Tab> -> grep gresource
```
An alias that expands to a shell escape completes the same way, because its
arguments end up as shell arguments:
```
alias ll '!ls -la'
< src/myproject > ll ma<Tab> -> ll main
```
Only the alias's arguments complete, never its first word — the command is
fixed by the alias body. An alias to a builtin (`alias co 'checkout $1'`) is not
a shell line and is left alone.
Word splitting for completion is by whitespace only; quotes and backslash
escapes are left to the shell that runs the line.
### Commands ### Commands
Run `help` for the full list. Highlights: Run `help` for the full list. Highlights:
@@ -115,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
@@ -342,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
@@ -380,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'
``` ```
@@ -406,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 |
@@ -423,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
+1 -1
View File
@@ -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,
} }
+323
View File
@@ -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
View File
@@ -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)
}
}
+41 -4
View File
@@ -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
View File
@@ -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)"},
+41 -6
View File
@@ -4,19 +4,54 @@ import (
"os" "os"
"path/filepath" "path/filepath"
"strings" "strings"
"unicode/utf8"
"github.com/chzyer/readline" "github.com/chzyer/readline"
) )
// completer wires up Tab completion. Command names complete at the start of the // completer wires up Tab completion. A line headed for a shell — a '!' escape,
// line; cd/open/view complete local project names; clone/show complete // or an alias that expands to one — is completed the way a shell would:
// executables for the command, paths for its arguments. Everything else goes to
// the builtin command tree.
func completer() readline.AutoCompleter {
return &mgshCompleter{builtin: builtinCompleter()}
}
// mgshCompleter dispatches between the two completion worlds.
type mgshCompleter struct{ builtin *readline.PrefixCompleter }
func (c *mgshCompleter) Do(line []rune, pos int) ([][]rune, int) {
if pos > len(line) {
pos = len(line)
}
if cands, prefix, ok := completeShellLine(string(line[:pos])); ok {
return runeSuffixes(cands, prefix)
}
return c.builtin.Do(line, pos)
}
// runeSuffixes converts full candidate words into what readline wants: the part
// still missing after the prefix already typed, plus that prefix's length.
func runeSuffixes(cands []string, prefix string) ([][]rune, int) {
n := utf8.RuneCountInString(prefix)
out := make([][]rune, 0, len(cands))
for _, c := range cands {
r := []rune(c)
if len(r) >= n {
out = append(out, r[n:])
}
}
return out, n
}
// builtinCompleter is the command tree. Command names complete at the start of
// 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; dist completes filesystem paths. // tag names; pushremote/release complete mirror targets; dist completes
func completer() *readline.PrefixCompleter { // filesystem paths.
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),
+1 -4
View File
@@ -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 -1
View File
@@ -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
View File
@@ -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" {
-1
View File
@@ -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
+64 -24
View File
@@ -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
code, data, err := r.do("GET", r.releaseByTagPath(owner, repo, tag), nil) id string // provider id; the tag itself on GitLab
if err != nil { // GitHub uploads assets to a different host than its API, and names it in
return "", false, err // the release object as an RFC 6570 template
uploadURL string
} }
switch code {
case 404: // parseReleaseRef reads the identifying fields out of a release object.
return "", false, nil func (r *remoteAPI) parseReleaseRef(tag string, data []byte) releaseRef {
case 200: ref := releaseRef{tag: tag, id: tag}
if r.kind == kindGitLab { if r.kind == kindGitLab {
return tag, true, nil return ref // GitLab addresses a release by its tag throughout
} }
var res struct { var res struct {
ID int64 `json:"id"` ID int64 `json:"id"`
UploadURL string `json:"upload_url"`
} }
json.Unmarshal(data, &res) json.Unmarshal(data, &res)
return strconv.FormatInt(res.ID, 10), true, nil 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)
if err != nil {
return releaseRef{}, false, err
}
switch code {
case 404:
return releaseRef{}, false, nil
case 200:
return r.parseReleaseRef(tag, data), 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
View File
@@ -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)
} }
} }
+181
View File
@@ -0,0 +1,181 @@
package main
// shellcomplete.go — Tab completion for the '!' shell escape.
//
// `!vi <Tab>` should behave like it does in a shell: the first word completes
// against the executables on PATH, everything after it against the filesystem.
// Paths resolve relative to the active project directory, because that is where
// forwardShell runs the command.
//
// Word splitting here is whitespace only. Quoting and backslash escapes are the
// shell's business at execution time; getting them right for completion too
// would buy little for a one-off escape hatch.
import (
"os"
"path/filepath"
"sort"
"strings"
"sync"
)
// shellCommandList supplies the executable names for the command position. A
// variable so tests can hand over a fixed set instead of whatever happens to be
// installed on the machine running them.
var shellCommandList = pathExecutables
// completeShellLine returns the candidates and the prefix they replace for a
// line that is headed for a shell. ok is false for any other line, which is
// then left to the builtin command tree.
func completeShellLine(typed string) (cands []string, prefix string, ok bool) {
body, allowCommand, ok := shellLine(typed)
if !ok {
return nil, "", false
}
cands, prefix = shellCandidates(body, allowCommand)
return cands, prefix, true
}
// shellLine works out which part of a typed line will reach a shell, and
// whether its command word is still open for completion. Two things get there:
// a '!' escape, and an alias that expands to one — `alias ll '!ls -la'` makes
// everything after `ll` a shell argument just as surely.
//
// Only the alias itself is inspected, not what its expansion might expand to
// again: an alias chain can rewrite its arguments, and guessing at that would
// offer candidates for a command line that is not the one being built.
func shellLine(typed string) (body string, allowCommand, ok bool) {
trimmed := strings.TrimLeft(typed, " \t")
if rest, found := strings.CutPrefix(trimmed, "!"); found {
return rest, true, true
}
// the alias name has to be complete — while it is still being typed there
// is no way to know what it will turn out to be
sep := strings.IndexAny(trimmed, " \t")
if sep < 0 {
return "", false, false
}
name := trimmed[:sep]
if isBuiltin(name) { // a builtin can never be shadowed by an alias
return "", false, false
}
expansion, defined := aliases[name]
if !defined || !strings.HasPrefix(strings.TrimSpace(expansion), "!") {
return "", false, false
}
// the command comes from the alias body, so only arguments are left to complete
return trimmed[sep:], false, true
}
// shellCandidates completes the last word of a shell command line. allowCommand
// says whether its first word may still be completed against PATH.
func shellCandidates(body string, allowCommand bool) (cands []string, prefix string) {
word := body[strings.LastIndexAny(body, " \t")+1:]
inCommand := allowCommand && strings.TrimLeft(body[:len(body)-len(word)], " \t") == ""
// a command word without a separator names something on PATH; with one it
// is a path like ./script, exactly as a shell reads it
if inCommand && !strings.ContainsRune(word, '/') {
if word == "" {
return nil, "" // every executable on the machine helps nobody
}
return matchPrefix(shellCommandList(), word), word
}
dir, base := splitPathToken(word)
return matchPrefix(pathEntries(dir), base), base
}
// splitPathToken splits a path token into the directory part, kept exactly as
// typed, and the basename being completed. Completing only the basename is what
// keeps the candidate list readable: "src/ma<Tab>" offers "main.go", not the
// whole path again.
func splitPathToken(word string) (dir, base string) {
if i := strings.LastIndexByte(word, '/'); i >= 0 {
return word[:i+1], word[i+1:]
}
return "", word
}
// pathEntries lists what a directory token points at. Directories come back
// with a trailing slash, so completing one leads straight into it.
func pathEntries(dir string) []string {
root := DIR
switch {
case strings.HasPrefix(dir, "~/"):
home, err := os.UserHomeDir()
if err != nil {
return nil
}
root, dir = home, dir[2:]
case strings.HasPrefix(dir, "/"):
root = ""
}
entries, err := os.ReadDir(filepath.Join(root, dir))
if err != nil {
return nil
}
out := make([]string, 0, len(entries))
for _, e := range entries {
name := e.Name()
if e.IsDir() {
name += "/"
}
out = append(out, name)
}
return out
}
// matchPrefix keeps the candidates starting with prefix, sorted and without
// duplicates. A hidden entry only shows up once the prefix asks for it, as in a
// shell.
func matchPrefix(cands []string, prefix string) []string {
wantHidden := strings.HasPrefix(prefix, ".")
seen := map[string]bool{}
var out []string
for _, c := range cands {
if !strings.HasPrefix(c, prefix) || seen[c] {
continue
}
if !wantHidden && strings.HasPrefix(c, ".") {
continue
}
seen[c] = true
out = append(out, c)
}
sort.Strings(out)
return out
}
// pathExecutables lists the executable names on PATH. The scan happens once per
// session: PATH cannot change from inside mgsh, and a few thousand directory
// entries are not worth walking on every Tab.
var pathExecutables = sync.OnceValue(func() []string {
var out []string
seen := map[string]bool{}
for _, dir := range filepath.SplitList(os.Getenv("PATH")) {
if dir == "" {
dir = "."
}
entries, err := os.ReadDir(dir)
if err != nil {
continue
}
for _, e := range entries {
name := e.Name()
if seen[name] {
continue // the first one on PATH is the one that would run
}
fi, err := e.Info()
if err != nil || fi.IsDir() || fi.Mode().Perm()&0o111 == 0 {
continue
}
seen[name] = true
out = append(out, name)
}
}
sort.Strings(out)
return out
})
+260
View File
@@ -0,0 +1,260 @@
package main
import (
"os"
"path/filepath"
"strings"
"testing"
)
// complete runs the real dispatch a Tab press goes through.
func complete(typed string) ([]string, string) {
cands, prefix, _ := completeShellLine(typed)
return cands, prefix
}
// fakeCommands installs a fixed set of PATH executables for the test.
func fakeCommands(t *testing.T, names ...string) {
t.Helper()
old := shellCommandList
shellCommandList = func() []string { return names }
t.Cleanup(func() { shellCommandList = old })
}
// shellTree lays out a directory to complete against and points DIR at it.
func shellTree(t *testing.T) string {
t.Helper()
dir := t.TempDir()
for _, d := range []string{"src", "src/deep", ".hidden"} {
if err := os.MkdirAll(filepath.Join(dir, d), 0755); err != nil {
t.Fatal(err)
}
}
for _, f := range []string{"main.go", "main_test.go", "Makefile", ".env", "src/util.go"} {
if err := os.WriteFile(filepath.Join(dir, f), []byte("x"), 0644); err != nil {
t.Fatal(err)
}
}
old := DIR
DIR = dir
t.Cleanup(func() { DIR = old })
return dir
}
func TestShellCandidatesCommandWord(t *testing.T) {
fakeCommands(t, "vi", "vim", "view", "grep", "git")
shellTree(t)
cands, prefix := complete("!vi")
if prefix != "vi" {
t.Errorf("prefix = %q, want vi", prefix)
}
if strings.Join(cands, ",") != "vi,view,vim" {
t.Errorf("candidates = %v, want vi,view,vim sorted", cands)
}
// a bare '!' must not dump every executable on the machine
if cands, _ := complete("!"); len(cands) != 0 {
t.Errorf("bare '!' offered %d candidates", len(cands))
}
// leading blanks are allowed, as runCommand allows them
if cands, _ := complete(" !gi"); strings.Join(cands, ",") != "git" {
t.Errorf("indented escape = %v, want git", cands)
}
// a command word with a separator is a path, not a PATH lookup
if cands, prefix := complete("!./ma"); prefix != "ma" ||
strings.Join(cands, ",") != "main.go,main_test.go" {
t.Errorf("./ma = %v (prefix %q), want the local files", cands, prefix)
}
}
func TestShellCandidatesArguments(t *testing.T) {
fakeCommands(t, "vi")
shellTree(t)
// paths resolve against the project directory, where `!` commands run
cands, prefix := complete("!vi ma")
if prefix != "ma" || strings.Join(cands, ",") != "main.go,main_test.go" {
t.Errorf("candidates = %v (prefix %q)", cands, prefix)
}
// an empty argument lists the directory — dot entries stay out of the way
cands, prefix = complete("!vi ")
if prefix != "" {
t.Errorf("prefix = %q, want empty", prefix)
}
if strings.Join(cands, ",") != "Makefile,main.go,main_test.go,src/" {
t.Errorf("directory listing = %v", cands)
}
// ... until the prefix asks for them
if cands, _ := complete("!vi ."); strings.Join(cands, ",") != ".env,.hidden/" {
t.Errorf("dot prefix = %v, want the hidden entries", cands)
}
// a directory completes with its slash, so the next Tab walks into it
cands, prefix = complete("!vi sr")
if prefix != "sr" || strings.Join(cands, ",") != "src/" {
t.Errorf("directory candidate = %v (prefix %q)", cands, prefix)
}
// inside a directory only the basename is completed, which is what keeps
// the candidate list readable
cands, prefix = complete("!vi src/ut")
if prefix != "ut" || strings.Join(cands, ",") != "util.go" {
t.Errorf("nested candidate = %v (prefix %q), want util.go / ut", cands, prefix)
}
// later arguments complete the same way as the first
if cands, _ := complete("!diff main.go ma"); strings.Join(cands, ",") != "main.go,main_test.go" {
t.Errorf("second argument = %v", cands)
}
}
func TestShellCandidatesAbsoluteAndHome(t *testing.T) {
home := t.TempDir()
t.Setenv("HOME", home)
if err := os.WriteFile(filepath.Join(home, "notes.txt"), []byte("x"), 0644); err != nil {
t.Fatal(err)
}
shellTree(t)
fakeCommands(t, "vi")
if cands, prefix := complete("!vi ~/no"); prefix != "no" ||
strings.Join(cands, ",") != "notes.txt" {
t.Errorf("~/ completion = %v (prefix %q)", cands, prefix)
}
abs := filepath.Join(home, "no")
if cands, prefix := complete("!vi " + abs); prefix != "no" ||
strings.Join(cands, ",") != "notes.txt" {
t.Errorf("absolute completion = %v (prefix %q)", cands, prefix)
}
}
// TestShellCompleterDoContract is the part that would corrupt the line if it
// were wrong: readline replaces the last `length` runes with a candidate, so
// the candidates must be suffixes and the length must count runes.
func TestShellCompleterDoContract(t *testing.T) {
fakeCommands(t, "vim", "view")
shellTree(t)
c := completer()
line := []rune("!vi")
got, length := c.Do(line, len(line))
if length != 2 { // "vi" — the '!' is not part of the word
t.Fatalf("length = %d, want 2", length)
}
// rebuilding the line from prefix + candidate must give the full word
for i, g := range got {
full := string(line[:len(line)-length]) + string(line[len(line)-length:]) + string(g)
if full != "!vim" && full != "!view" {
t.Errorf("candidate %d rebuilds to %q", i, full)
}
}
// a non-'!' line still goes to the builtin command tree (which appends its
// own trailing space on a unique match)
line = []rune("stat")
got, length = c.Do(line, len(line))
if length != 4 || len(got) == 0 || !strings.HasPrefix(string(got[0]), "us") {
t.Errorf("builtin completion = %q, %d; want a candidate starting \"us\" at 4", got, length)
}
// a multi-byte prefix must be measured in runes, not bytes
if _, n := runeSuffixes([]string{"übermorgen"}, "üb"); n != 2 {
t.Errorf("runeSuffixes length = %d, want 2 runes", n)
}
}
// TestPathExecutablesFindsRealBinaries checks the PATH scan against a directory
// it controls: only files with an execute bit, no directories.
func TestPathExecutablesFindsRealBinaries(t *testing.T) {
dir := t.TempDir()
if err := os.WriteFile(filepath.Join(dir, "runnable"), []byte("#!/bin/sh\n"), 0755); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(filepath.Join(dir, "plainfile"), []byte("x"), 0644); err != nil {
t.Fatal(err)
}
if err := os.MkdirAll(filepath.Join(dir, "subdir"), 0755); err != nil {
t.Fatal(err)
}
t.Setenv("PATH", dir)
// pathExecutables caches for the session, so exercise the scan directly
var names []string
entries, err := os.ReadDir(dir)
if err != nil {
t.Fatal(err)
}
for _, e := range entries {
fi, err := e.Info()
if err != nil || fi.IsDir() || fi.Mode().Perm()&0o111 == 0 {
continue
}
names = append(names, e.Name())
}
if strings.Join(names, ",") != "runnable" {
t.Errorf("executable scan = %v, want only runnable", names)
}
}
// withAliases installs a fixed alias set for the test.
func withAliases(t *testing.T, m map[string]string) {
t.Helper()
old := aliases
aliases = m
t.Cleanup(func() { aliases = old })
}
// TestShellCandidatesThroughAlias: an alias that expands to a '!' escape turns
// everything after its name into shell arguments, so it completes as such.
func TestShellCandidatesThroughAlias(t *testing.T) {
shellTree(t)
fakeCommands(t, "vi", "ls")
withAliases(t, map[string]string{
"ll": "!ls -la",
"e": "!vi $1",
"co": "checkout $1", // expands to a builtin, not a shell command
"status": "!git status", // shadows a builtin: must not count
})
// arguments of a shell alias complete against the filesystem
if cands, prefix := complete("ll ma"); prefix != "ma" ||
strings.Join(cands, ",") != "main.go,main_test.go" {
t.Errorf("alias argument = %v (prefix %q)", cands, prefix)
}
// including the empty one, which lists the directory
if cands, _ := complete("e "); strings.Join(cands, ",") != "Makefile,main.go,main_test.go,src/" {
t.Errorf("empty alias argument = %v", cands)
}
// and paths inside it
if cands, prefix := complete("ll src/ut"); prefix != "ut" ||
strings.Join(cands, ",") != "util.go" {
t.Errorf("nested alias argument = %v (prefix %q)", cands, prefix)
}
// the command word of an alias is fixed by its body, so PATH is never
// offered — `ll vi` means the file "vi", not the editor
if cands, _ := complete("ll vi"); len(cands) != 0 {
t.Errorf("alias argument matched PATH: %v", cands)
}
// an alias to a builtin is not a shell line at all
if _, _, ok := shellLine("co ma"); ok {
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
if _, _, ok := shellLine("status ma"); ok {
t.Error("a builtin name was resolved through an alias")
}
// nor an undefined name
if _, _, ok := shellLine("nosuch ma"); ok {
t.Error("an undefined alias was treated as a shell line")
}
// while the alias name itself is still being typed there is nothing to know
if _, _, ok := shellLine("ll"); ok {
t.Error("an incomplete alias name was resolved")
}
}
+1 -2
View File
@@ -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
View File
@@ -1 +1 @@
4.0.42 4.0.55