diff --git a/README.md b/README.md index 547ad5b..e83e0c8 100644 --- a/README.md +++ b/README.md @@ -259,38 +259,37 @@ alias ec '!echo $1' # ec hello -> echo hello (shell) Besides the internal ssh git server, `pushremote` mirrors the active project to one or more public hosting servers (Gitea, GitHub or GitLab) over their REST -API. A single server is configured flat: +API. Each server is one `remote..` block: ```ini -remoteurl = https://git.example.com # base URL of the server -remotekey = # API token -# remotetype = gitea # optional; auto-detected from remoteurl -# remotevisibility = private # visibility of created repos (default private) -# mirror = true # `push` also mirrors via pushremote +remote.gitea.url = https://git.example.com +remote.gitea.key = +remote.gitea.type = gitea # optional; auto-detected from the url +remote.gitea.visibility = private # or public (default private) + +remote.gitlab.url = https://gitlab.example.com +remote.gitlab.key = +remote.gitlab.type = gitlab +remote.gitlab.visibility = public + +# remotes = gitea, gitlab # optional: restrict and order the set +# mirror = true # `push` also mirrors via pushremote ``` -Several servers get one `remote..*` block each: - -```ini -remote.gitea.url = https://git.example.com -remote.gitea.key = - -remote.hub.url = https://github.com -remote.hub.key = -remote.hub.visibility = public - -# remotes = gitea, hub # optional: restrict and order the set -``` +`` is yours to pick; there is no other spelling. Older versions had a flat +`remoteurl`/`remotekey` pair for a single server — mgsh converts those to +`remote.public.*` in place on the next start and says so, keeping the git remote +name those versions used. | command | pushes to | |------------------------|-----------------------------------------------| | `pushremote` | every configured target, in order | -| `pushremote @hub` | only `hub` | -| `pushremote @hub @gitea` | those two | +| `pushremote @gitea` | only `gitea` | +| `pushremote @gitea @gitlab` | those two | | `pushremote a fix` | every target, description "a fix" | -Each target owns a git remote of the same name in the repository (the flat form -uses `public`, as before), so `git push hub` keeps working outside mgsh. A +Each target owns a git remote of the same name in the repository, so +`git push gitlab` keeps working outside mgsh. A target that fails does not stop the others; with more than one target `pushremote` prints an `n/m remotes updated` summary. `remotes = …` restricts and orders the set, which is mostly useful in a project `.mgshrc` — see below. @@ -304,9 +303,10 @@ The token is sent as a one-shot HTTP auth header: it is never written into the repo's git config, and it reaches git through the environment rather than the command line, so it does not show up in the process table. Because `~/.mgshrc` then holds a credential, mgsh creates it mode `600` and warns at startup if an -existing file is readable by others. The provider is auto-detected from `remoteurl` (`github.com` → -GitHub, `gitlab*` → GitLab, otherwise Gitea) and can be forced with -`remotetype`. Set `mirror = true` to have every `push` mirror automatically. +existing file is readable by others. The provider is auto-detected from the url +(`github.com` → GitHub, `gitlab*` → GitLab, otherwise Gitea) and can be forced +with `remote..type`. Set `mirror = true` to have every `push` mirror +automatically. ### Releases @@ -390,8 +390,10 @@ alias co 'checkout $1' ### Settings reference Every setting can also be given as an environment variable named `MGSH_` -(e.g. `MGSH_GITHOST`), which wins over both files. "Scope" says whether a -project `.mgshrc` may override the setting. +(e.g. `MGSH_GITHOST`), which wins over both files; a mirror field is +`MGSH_REMOTE__`, so `MGSH_REMOTE_GITLAB_KEY` sets +`remote.gitlab.key`. "Scope" says whether a project `.mgshrc` may override the +setting. | setting | scope | meaning | |---|---|---| @@ -405,14 +407,10 @@ project `.mgshrc` may override the setting. | `gitemail` | global | `user.email` 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`) | -| `remoteurl` | project | base URL of a single mirror server (target name `public`) | -| `remotekey` | project | API token for `remoteurl` | -| `remotetype` | project | `gitea`\|`github`\|`gitlab`; auto-detected from the URL when unset | -| `remotevisibility` | project | `private` (default) or `public` for repositories created by `pushremote` | -| `remote..url` | project | base URL of the named mirror target | +| `remote..url` | project | base URL of the mirror target `` | | `remote..key` | project | API token for that target | -| `remote..type` | project | provider override for that target | -| `remote..visibility` | project | visibility for that target | +| `remote..type` | project | `gitea`\|`github`\|`gitlab`; auto-detected from the url when unset | +| `remote..visibility` | project | `private` (default) or `public` for repositories `pushremote` creates | | `remotes` | project | comma- or space-separated list restricting and ordering the mirror targets | | `mirror` | project | truthy (`1`/`true`/`yes`/`on`) → every `push` also mirrors | | `secretscan` | project | `off` disables the credential check `push` runs before committing (on by default; only an explicit `off` disables it) | diff --git a/config.go b/config.go index 09f2cf6..8d1ee36 100644 --- a/config.go +++ b/config.go @@ -6,6 +6,7 @@ import ( "os" "path/filepath" "regexp" + "slices" "sort" "strings" ) @@ -33,10 +34,6 @@ type Config struct { GitEmail string // git user.email 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) - RemoteURL string // public mirror server base URL (Gitea/GitHub/GitLab) - RemoteKey string // API token for the mirror server (used by `pushremote`) - RemoteType string // "gitea"|"github"|"gitlab" (auto-detected when empty) - RemoteVis string // visibility of created repos: "private" (default)|"public" Mirror string // truthy -> `push` also mirrors via `pushremote` SecretScan string // falsy -> `push` skips the credential scan Remotes []RemoteTarget @@ -54,22 +51,25 @@ type RemoteTarget struct { Vis string // "private" (default) | "public" } -// legacyRemoteName is the target name for the flat remoteurl/remotekey pair, -// matching the git remote that earlier versions created. +// legacyRemoteName is the target the pre-4.1 flat remoteurl/remotekey settings +// are migrated to. It matches the git remote those versions created, so a +// converted configuration keeps pushing to the same place. const legacyRemoteName = "public" +// legacyRemoteKeys maps the old flat spelling onto the named-target form. A +// mirror target is defined one way now, not two. +var legacyRemoteKeys = map[string]string{ + "remoteurl": "remote." + legacyRemoteName + ".url", + "remotekey": "remote." + legacyRemoteName + ".key", + "remotetype": "remote." + legacyRemoteName + ".type", + "remotevisibility": "remote." + legacyRemoteName + ".visibility", +} + // mirrorTargets returns the usable mirror targets in configured order, plus the // names of targets that are defined but unusable (missing url or key) so the // caller can complain about them instead of silently skipping. func (c Config) mirrorTargets() (usable []RemoteTarget, incomplete []string) { - var all []RemoteTarget - if c.RemoteURL != "" || c.RemoteKey != "" { - all = append(all, RemoteTarget{ - Name: legacyRemoteName, URL: c.RemoteURL, Key: c.RemoteKey, - Type: c.RemoteType, Vis: c.RemoteVis, - }) - } - all = append(all, c.Remotes...) + all := c.Remotes // `remotes = a, b` narrows and orders the set — a project .mgshrc uses it // to mirror to only some of the globally configured servers. @@ -147,11 +147,54 @@ func loadConfig() Config { m := parseConfig(string(data)) applyConfig(&c, m) warnConfigPerms(path, m) + migrateRemoteKeys(path, string(data)) } applyEnv(&c) return c } +// migrateRemoteKeys converts the pre-4.1 flat remote settings in a config file +// to the remote.. spelling, so a mirror target is defined one way +// and not two. Only the key is rewritten: values, comments, blank lines and the +// file's permissions stay exactly as they are, and commented-out lines are left +// alone. Reports what it changed rather than doing it silently. +func migrateRemoteKeys(path, data string) { + lines := strings.Split(data, "\n") + var renamed []string + + for i, ln := range lines { + trimmed := strings.TrimLeft(ln, " \t") + if trimmed == "" || strings.HasPrefix(trimmed, "#") { + continue + } + sep := strings.IndexAny(trimmed, "=:") + if sep < 0 { + continue + } + key := strings.TrimRight(trimmed[:sep], " \t") + dotted, ok := legacyRemoteKeys[strings.ToLower(key)] + if !ok { + continue + } + indent := ln[:len(ln)-len(trimmed)] + gap := trimmed[len(key):sep] // whatever alignment was there + lines[i] = indent + dotted + gap + trimmed[sep:] + renamed = append(renamed, key+" → "+dotted) + } + if len(renamed) == 0 { + return + } + + if err := os.WriteFile(path, []byte(strings.Join(lines, "\n")), configMode); err != nil { + errorln("could not update " + path + ": " + err.Error()) + return + } + fmt.Println(col(cGray, path+": mirror settings renamed to the remote..* form")) + for _, r := range renamed { + fmt.Println(col(cGray, " "+r)) + } +} + // projectGlobalOnly lists settings a project-level .mgshrc must not change: // `base` decides where projects live in the first place, and the git identity // keys are written to the user's *global* git config at startup — applying @@ -279,13 +322,15 @@ func writeConfigTemplate(path string) { b.WriteString("# pushdefault = matching\n") b.WriteString("# editor = code\n\n") b.WriteString("# --- public mirrors for `pushremote` ---\n") - b.WriteString("# One block per server; `pushremote` pushes to all of them,\n") - b.WriteString("# `pushremote @hub` to a single one.\n") - b.WriteString("# remote.hub.url = https://github.com\n") - b.WriteString("# remote.hub.key = \n") - b.WriteString("# remote.hub.visibility = public\n") - b.WriteString("# remotes = hub # optional: restrict/order the set\n") - b.WriteString("# mirror = true # `push` also mirrors\n\n") + b.WriteString("# One 'remote..*' block per server. `pushremote` pushes to all\n") + b.WriteString("# of them, `pushremote @gitlab` to a single one. is also the\n") + b.WriteString("# git remote created in the repository.\n") + b.WriteString("# remote.gitlab.url = https://gitlab.example.com\n") + b.WriteString("# remote.gitlab.key = \n") + b.WriteString("# remote.gitlab.type = gitlab # optional; detected from the url\n") + b.WriteString("# remote.gitlab.visibility = private # or public (default private)\n") + b.WriteString("# remotes = gitlab # optional: restrict/order the set\n") + b.WriteString("# mirror = true # `push` also mirrors\n\n") b.WriteString("# A project may override any of these (except base and the git\n") b.WriteString("# identity) in its own /.mgshrc.\n") @@ -365,14 +410,36 @@ func applyConfig(c *Config, m map[string]string) { set("gitemail", &c.GitEmail) set("pushdefault", &c.PushDefault) set("editor", &c.Editor) - set("remoteurl", &c.RemoteURL) - set("remotekey", &c.RemoteKey) - set("remotetype", &c.RemoteType) - set("remotevisibility", &c.RemoteVis) set("remotes", &c.RemoteNames) set("mirror", &c.Mirror) set("secretscan", &c.SecretScan) - applyRemoteTargets(c, m) + applyRemoteTargets(c, foldLegacyRemoteKeys(m)) +} + +// foldLegacyRemoteKeys rewrites the pre-4.1 flat remote settings into the +// named-target form, so a configuration that has not been converted yet still +// works while it is being read. The file itself is converted by +// migrateRemoteKeys; this only makes the current run behave. +func foldLegacyRemoteKeys(m map[string]string) map[string]string { + folded, copied := m, false + for old, dotted := range legacyRemoteKeys { + v, ok := m[old] + if !ok || v == "" { + continue + } + if _, taken := m[dotted]; taken { + continue // an explicit new-style setting always wins + } + if !copied { // copy on first write, never touch the caller's map + folded = make(map[string]string, len(m)) + for k, val := range m { + folded[k] = val + } + copied = true + } + folded[dotted] = v + } + return folded } // remoteFieldRe matches a named mirror target setting: remote... @@ -439,11 +506,55 @@ func applyEnv(c *Config) { env("MGSH_GITEMAIL", &c.GitEmail) env("MGSH_PUSHDEFAULT", &c.PushDefault) env("MGSH_EDITOR", &c.Editor) - env("MGSH_REMOTEURL", &c.RemoteURL) - env("MGSH_REMOTEKEY", &c.RemoteKey) - env("MGSH_REMOTETYPE", &c.RemoteType) - env("MGSH_REMOTEVISIBILITY", &c.RemoteVis) env("MGSH_REMOTES", &c.RemoteNames) env("MGSH_MIRROR", &c.Mirror) env("MGSH_SECRETSCAN", &c.SecretScan) + applyRemoteEnv(c) +} + +// remoteFields are the settings a mirror target is made of. +var remoteFields = []string{"url", "key", "type", "visibility"} + +// applyRemoteEnv reads MGSH_REMOTE__, the environment spelling of +// a remote.. setting — MGSH_REMOTE_GITLAB_KEY for +// remote.gitlab.key. The field is taken from the end, so a target name may +// contain underscores itself. +func applyRemoteEnv(c *Config) { + const prefix = "MGSH_REMOTE_" + + // sorted, so a target these variables introduce lands in the push order the + // same way on every run + envs := os.Environ() + sort.Strings(envs) + + for _, kv := range envs { + eq := strings.IndexByte(kv, '=') + if eq < 0 { + continue + } + name, value := kv[:eq], kv[eq+1:] + if value == "" || !strings.HasPrefix(name, prefix) { + continue + } + rest := name[len(prefix):] + us := strings.LastIndexByte(rest, '_') + if us <= 0 { + continue + } + target, field := strings.ToLower(rest[:us]), strings.ToLower(rest[us+1:]) + if !slices.Contains(remoteFields, field) { + continue // MGSH_REMOTES and anything else that merely starts alike + } + t := &c.Remotes[c.remoteIndex(target)] + switch field { + case "url": + t.URL = value + case "key": + t.Key = value + case "type": + t.Type = value + case "visibility": + t.Vis = value + } + } } diff --git a/mgsh_test.go b/mgsh_test.go index b876fd2..48b01dc 100644 --- a/mgsh_test.go +++ b/mgsh_test.go @@ -468,7 +468,7 @@ remote.broken.url = https://nowhere.example # no key -> unusable } func TestMirrorTargetsLegacyAndSelection(t *testing.T) { - // the flat remoteurl/remotekey pair stays supported, as target "public" + // the pre-4.1 flat pair still loads, folded onto the target "public" var c Config applyConfig(&c, parseConfig("remoteurl = https://git.example.com\nremotekey = tok\n")) usable, _ := c.mirrorTargets() @@ -1049,3 +1049,101 @@ func TestParseDuSizes(t *testing.T) { } } } + +// TestMigrateRemoteKeys rewrites the pre-4.1 flat spelling in place. Only the +// key changes: values, comments and everything else stay byte for byte. +func TestMigrateRemoteKeys(t *testing.T) { + dir := t.TempDir() + path := filepath.Join(dir, ".mgshrc") + before := `# my config +base = /home/me/src +remoteurl = https://git.example.com # the mirror +remotekey = s3cr3t-token + remotevisibility: public +# remotetype = gitea (commented out, must stay put) +alias co 'checkout $1' +` + if err := os.WriteFile(path, []byte(before), 0600); err != nil { + t.Fatal(err) + } + out := captureStdout(t, func() { migrateRemoteKeys(path, before) }) + + got := readFile(t, path) + for _, want := range []string{ + "remote.public.url = https://git.example.com # the mirror", + "remote.public.key = s3cr3t-token", + " remote.public.visibility: public", + "# remotetype = gitea (commented out, must stay put)", + "base = /home/me/src", + "alias co 'checkout $1'", + } { + if !strings.Contains(got, want) { + t.Errorf("migrated file missing %q:\n%s", want, got) + } + } + if strings.Contains(got, "\nremoteurl") || strings.Contains(got, "\nremotekey") { + t.Errorf("old spelling left behind:\n%s", got) + } + if !strings.Contains(out, "remoteurl → remote.public.url") { + t.Errorf("migration was not reported: %q", out) + } + // the file keeps its private mode + if fi, err := os.Stat(path); err != nil { + t.Fatal(err) + } else if fi.Mode().Perm() != 0o600 { + t.Errorf("mode after migration = %04o, want 0600", fi.Mode().Perm()) + } + + // running again changes nothing and says nothing + second := captureStdout(t, func() { migrateRemoteKeys(path, readFile(t, path)) }) + if strings.TrimSpace(second) != "" { + t.Errorf("a converted file was migrated again: %q", second) + } + if readFile(t, path) != got { + t.Error("a second migration changed the file") + } +} + +// TestLegacyKeysDoNotOverrideExplicitOnes: a config carrying both spellings must +// keep what the new one says. +func TestLegacyKeysDoNotOverrideExplicitOnes(t *testing.T) { + var c Config + applyConfig(&c, parseConfig( + "remoteurl = https://old.example\nremote.public.url = https://new.example\n"+ + "remote.public.key = tok\n")) + targets, _ := c.mirrorTargets() + if len(targets) != 1 || targets[0].URL != "https://new.example" { + t.Errorf("targets = %+v, want the remote.public.url value", targets) + } +} + +// TestRemoteEnvOverrides: MGSH_REMOTE__ is the environment +// spelling of remote... +func TestRemoteEnvOverrides(t *testing.T) { + t.Setenv("MGSH_REMOTE_GITLAB_URL", "https://gitlab.example") + t.Setenv("MGSH_REMOTE_GITLAB_KEY", "env-token") + t.Setenv("MGSH_REMOTE_GITLAB_VISIBILITY", "public") + t.Setenv("MGSH_REMOTE_MY_HUB_URL", "https://hub.example") // name with an underscore + t.Setenv("MGSH_REMOTE_MY_HUB_KEY", "hub-token") + t.Setenv("MGSH_REMOTES", "") // must not be mistaken for a target field + + var c Config + applyConfig(&c, parseConfig("remote.gitlab.url = https://from-file.example\nremote.gitlab.key = file-token\n")) + applyEnv(&c) + + targets, incomplete := c.mirrorTargets() + if len(incomplete) != 0 { + t.Fatalf("incomplete targets: %v", incomplete) + } + byName := map[string]RemoteTarget{} + for _, tg := range targets { + byName[tg.Name] = tg + } + if g := byName["gitlab"]; g.URL != "https://gitlab.example" || g.Key != "env-token" || g.Vis != "public" { + t.Errorf("env did not override the file: %+v", g) + } + // the field is taken from the end, so the name may contain underscores + if h := byName["my_hub"]; h.URL != "https://hub.example" || h.Key != "hub-token" { + t.Errorf("MGSH_REMOTE_MY_HUB_* = %+v, want target my_hub", h) + } +} diff --git a/mgshrc.example b/mgshrc.example index 1248aa9..1e6eaab 100644 --- a/mgshrc.example +++ b/mgshrc.example @@ -23,24 +23,25 @@ gitpath = /home/git # editor = code # --- pushremote: mirror to public servers (gitea/github/gitlab) via their API --- -# A single server, the flat form (this target is named "public"): -# remoteurl = https://git.example.com -# remotekey = -# remotetype = gitea # optional; auto-detected from remoteurl -# remotevisibility = private # visibility of created repos (default private) +# One "remote.." block per server, with the fields url, key, type +# and visibility. is yours to pick and becomes the git remote created in +# the repository, so `git push gitlab` keeps working outside mgsh. # -# Or any number of named servers. `pushremote` pushes to all of them in the -# order given, `pushremote @hub` to a single one. Each target gets a git remote -# of the same name in the repository. -# remote.gitea.url = https://git.example.com -# remote.gitea.key = -# remote.hub.url = https://github.com -# remote.hub.key = -# remote.hub.type = github # optional; auto-detected from the url -# remote.hub.visibility = public # default private -# remotes = gitea, hub # optional: restrict and order the set +# `pushremote` pushes to every configured server in the order given, +# `pushremote @gitlab` to a single one. # -# mirror = true # `push` also mirrors via pushremote +# remote.gitea.url = https://git.example.com +# remote.gitea.key = +# remote.gitea.type = gitea # optional; auto-detected from the url +# remote.gitea.visibility = private # or public (default private) +# +# remote.gitlab.url = https://gitlab.example.com +# remote.gitlab.key = +# remote.gitlab.type = gitlab +# remote.gitlab.visibility = public +# +# remotes = gitea, gitlab # optional: restrict and order the set +# mirror = true # `push` also mirrors via pushremote # --- safety --- # `push` checks the staged diff for private keys and API tokens before it diff --git a/remote.go b/remote.go index 06ade8c..2553ab9 100644 --- a/remote.go +++ b/remote.go @@ -4,23 +4,18 @@ package main // git hosting server (Gitea, GitHub or GitLab), creating the repository via the // server's REST API when it does not exist yet. // -// Configuration (in ~/.mgshrc, a project .mgshrc, or MGSH_* env) — either a -// single flat target: +// Configuration (in ~/.mgshrc, a project .mgshrc, or MGSH_* env): one +// remote.. block per server, which `pushremote` mirrors to in turn. // -// remoteurl = https://git.example.com base URL of the server -// remotekey = personal access token -// remotetype = gitea|github|gitlab optional; auto-detected from the URL +// remote.gitlab.url = https://gitlab.example.com +// remote.gitlab.key = +// remote.gitlab.type = gitlab optional; detected from the url +// remote.gitlab.visibility = public or private (the default) +// remotes = gitlab optional: restrict/order the set // -// or any number of named ones, which `pushremote` mirrors to in turn: -// -// remote.gitea.url = https://git.example.com -// remote.gitea.key = -// remote.hub.url = https://github.com -// remote.hub.key = -// remote.hub.visibility = public -// remotes = gitea, hub optional: restrict/order the set -// -// Each target owns a git remote of the same name in the repository. +// Each target owns a git remote of its own name in the repository. There is no +// second spelling: the pre-4.1 flat remoteurl/remotekey pair is migrated to +// remote.public.* on load. // // The token is used for the API calls and, via an HTTP Basic auth header, for // the git push. It is never written into the repository's git config, and it is @@ -287,7 +282,7 @@ func handlePushRemote(args string) { targets = pickRemotes(targets, names) if len(targets) == 0 { if len(names) == 0 { // an unknown @name already reported itself - errorln("pushremote needs 'remoteurl'/'remotekey' or a 'remote..*' block in " + configFile()) + errorln("pushremote needs a 'remote..url' and 'remote..key' in " + configFile()) } return } diff --git a/show_config.go b/show_config.go index f0530c7..ff1a6d6 100644 --- a/show_config.go +++ b/show_config.go @@ -147,7 +147,6 @@ func configKeys() []string { keys := []string{ "base", "githost", "gitport", "gituser", "gitpath", "gitkey", "gitname", "gitemail", "pushdefault", "editor", - "remoteurl", "remotekey", "remotetype", "remotevisibility", "remotes", "mirror", "secretscan", } sort.Strings(keys) diff --git a/version.txt b/version.txt index 266f857..7a54ad2 100644 --- a/version.txt +++ b/version.txt @@ -1 +1 @@ -4.0.40 +4.0.42