Files
mgsh/mgsh_test.go
T
mikeandClaude Opus 5 61a7059f61 Fold the unpublished projects into the overview table
They were a list underneath the table, which meant reading the same
project names in two different shapes. They are rows now, with the action
in an "init" column that only appears when some row needs it, and they
sort to the bottom as their own group: an un-inited directory is a
different kind of task and should not push the daily ones down.

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

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

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-26 17:49:59 +02:00

1052 lines
33 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
package main
import (
"os"
"os/exec"
"path/filepath"
"reflect"
"strconv"
"strings"
"testing"
"time"
"unicode/utf8"
)
func TestSanitizeComment(t *testing.T) {
cases := []struct{ in, want string }{
{"", ""},
{"simple comment", "simple_comment"},
{"it's a test", "it_s_a_test"},
{"a/b:c;d", "a_b_c_d"},
{"added -v flag", "added_v_flag"},
{" padded spaces ", "padded_spaces"},
{"weird<>|?chars", "weird_chars"},
}
for _, c := range cases {
if got := sanitizeComment(c.in); got != c.want {
t.Errorf("sanitizeComment(%q) = %q, want %q", c.in, got, c.want)
}
}
}
func TestArchiveStampFormat(t *testing.T) {
s := archiveStamp()
// expect DDMMYY.HHMM => 6 digits, dot, 4 digits
if len(s) != 11 || s[6] != '.' {
t.Fatalf("archiveStamp() = %q, want DDMMYY.HHMM shape", s)
}
for i, r := range s {
if i == 6 {
continue
}
if r < '0' || r > '9' {
t.Fatalf("archiveStamp() = %q has non-digit at %d", s, i)
}
}
}
func TestFormatLogNoColor(t *testing.T) {
useColor = false
// fixed reference time: 2020-06-15 12:00:00 local
now := time.Date(2020, 6, 15, 12, 0, 0, 0, time.Local)
old := time.Date(2019, 10, 17, 16, 9, 0, 0, time.Local).Unix()
line := "f9e76ff " + strconv.FormatInt(old, 10) + " initial commit"
out := formatLog([]string{line}, now)
if !strings.Contains(out, "f9e76ff") || !strings.Contains(out, "initial commit") {
t.Fatalf("formatLog missing hash/subject: %q", out)
}
// older than a week -> full date form contains year and weekday
if !strings.Contains(out, "2019") || !strings.Contains(out, "Thu") {
t.Fatalf("formatLog full date form expected, got %q", out)
}
}
func TestFormatLogRecentCompact(t *testing.T) {
useColor = false
now := time.Date(2020, 6, 15, 12, 0, 0, 0, time.Local)
recent := now.Add(-2 * time.Hour).Unix()
out := formatLog([]string{"abc123 " + strconv.FormatInt(recent, 10) + " recent work"}, now)
// within a day -> tiny form: only HH:MM, no year, no weekday
if strings.Contains(out, "2020") {
t.Fatalf("recent entry should use compact time form, got %q", out)
}
if !strings.Contains(out, "recent work") {
t.Fatalf("missing subject, got %q", out)
}
}
func TestFormatRepoList(t *testing.T) {
useColor = false
entries := []lsEntry{
{name: "short", date: "Sep 28 2016", size: 4096},
{name: "a-much-longer-name", date: "Jan 3 14:32", size: 1536},
}
out := formatRepoList(entries, false)
lines := strings.Split(strings.TrimRight(out, "\n"), "\n")
if len(lines) != 2 {
t.Fatalf("expected 2 lines, got %d: %q", len(lines), out)
}
// order is preserved: `ls -ltr` already sorted by modification time
if !strings.Contains(lines[0], "short") || !strings.Contains(lines[1], "a-much-longer-name") {
t.Errorf("order not preserved: %q", out)
}
// the date starts at the same column on every line
if strings.Index(lines[0], "Sep") != strings.Index(lines[1], "Jan") {
t.Errorf("date column not aligned:\n%s", out)
}
if strings.Contains(out, "4.0K") {
t.Errorf("size shown for repositories: %q", out)
}
if withSize := formatRepoList(entries, true); !strings.Contains(withSize, "4.0K") ||
!strings.Contains(withSize, "1.5K") {
t.Errorf("archive sizes missing: %q", withSize)
}
// colour must decorate the layout, never change it
useColor = true
colored := formatRepoList(entries, false)
useColor = false
strip := func(s string) string {
for _, c := range []string{cReset, cGreen, cYellow, cGray} {
s = strings.ReplaceAll(s, c, "")
}
return s
}
if strip(colored) != out {
t.Errorf("colour changed the layout:\n%q\n%q", strip(colored), out)
}
}
func TestHumanSize(t *testing.T) {
cases := []struct {
n int64
want string
}{
{0, "0B"}, {512, "512B"}, {1024, "1.0K"}, {1536, "1.5K"},
{1024 * 1024, "1.0M"}, {3 * 1024 * 1024 * 1024, "3.0G"},
// past 10 the decimal carries nothing, as with `ls -h`
{512 * 1024, "512K"}, {99 * 1024 * 1024, "99M"},
}
for _, c := range cases {
if got := humanSize(c.n); got != c.want {
t.Errorf("humanSize(%d) = %q, want %q", c.n, got, c.want)
}
}
}
func TestParseConfig(t *testing.T) {
rc := `
# comment line
githost = 10.0.0.1
GitPort: 22
gituser = "deploy"
editor = 'code'
ignored line without separator
base=/tmp/src
`
m := parseConfig(rc)
checks := map[string]string{
"githost": "10.0.0.1",
"gitport": "22",
"gituser": "deploy",
"editor": "code",
"base": "/tmp/src",
}
for k, want := range checks {
if m[k] != want {
t.Errorf("parseConfig[%q] = %q, want %q", k, m[k], want)
}
}
if _, ok := m["ignored line without separator"]; ok {
t.Errorf("line without separator should be ignored")
}
}
func TestParseConfigInlineComments(t *testing.T) {
rc := `
editor = code # fallback opener for ` + "`open`" + `
mirror = true # ` + "`push`" + ` also mirrors via pushremote
gitport = 22 # ssh port
remotekey = abc#123
remoteurl = "https://git.example.com" # quoted, comment after
gitname = ' Spaced # Name '
gitemail = # value is only a comment
`
m := parseConfig(rc)
checks := map[string]string{
"editor": "code",
"mirror": "true",
"gitport": "22",
"remotekey": "abc#123", // '#' not preceded by space stays part of the value
"remoteurl": "https://git.example.com",
"gitname": " Spaced # Name ",
"gitemail": "",
}
for k, want := range checks {
if m[k] != want {
t.Errorf("parseConfig[%q] = %q, want %q", k, m[k], want)
}
}
if !truthy(m["mirror"]) {
t.Errorf("mirror with a trailing comment must stay truthy, got %q", m["mirror"])
}
}
func TestParseLsEntry(t *testing.T) {
cases := []struct {
line, suffix string
name, date string
size int64
ok bool
}{
// ownership is not assumed: any user/group must parse
{"drwxr-xr-x 7 git git 4096 Sep 28 2016 myproj.git", ".git", "myproj", "Sep 28 2016", 4096, true},
{"drwxr-xr-x 7 deploy deploy 4096 Sep 28 2016 myproj.git", ".git", "myproj", "Sep 28 2016", 4096, true},
{"drwxr-xr-x. 7 git users 4096 Sep 28 2016 myproj.git", ".git", "myproj", "Sep 28 2016", 4096, true},
// a recent entry carries a time instead of a year, and still lines up
{"drwxr-xr-x 7 mike staff 224 Jan 3 14:32 myproj.git", ".git", "myproj", "Jan 3 14:32", 224, true},
// a symlinked bare repo lists its target too — only the link name counts
{"lrwxrwxrwx 1 git git 14 Sep 28 2016 myproj.git -> /srv/other.git", ".git", "myproj", "Sep 28 2016", 14, true},
// archives carry a size worth showing
{"-rw-r--r-- 1 git git 524288 Sep 28 2016 myproj.git.tar.gz", ".git.tar.gz", "myproj", "Sep 28 2016", 524288, true},
// suffixes must not cross over
{"-rw-r--r-- 1 git git 512 Sep 28 2016 myproj.git.tar.gz", ".git", "", "", 0, false},
{"drwxr-xr-x 7 git git 4096 Sep 28 2016 myproj.git", ".git.tar.gz", "", "", 0, false},
{"lrwxrwxrwx 1 git git 5 Sep 28 2016 notes -> x.git", ".git", "", "", 0, false},
// non-entries
{"total 48", ".git", "", "", 0, false},
{"", ".git", "", "", 0, false},
{"drwxr-xr-x 7 git git 4096 Sep 28 2016 notes", ".git", "", "", 0, false},
}
for _, c := range cases {
e, ok := parseLsEntry(c.line, c.suffix)
if ok != c.ok {
t.Errorf("parseLsEntry(%q, %q) ok = %v, want %v", c.line, c.suffix, ok, c.ok)
continue
}
if !ok {
continue
}
if e.name != c.name || e.size != c.size {
t.Errorf("parseLsEntry(%q) = %+v, want name %q size %d", c.line, e, c.name, c.size)
}
// every date renders to the same width, whichever form ls used
if e.date != c.date || len(e.date) != 12 {
t.Errorf("parseLsEntry(%q) date = %q (len %d), want %q at 12",
c.line, e.date, len(e.date), c.date)
}
}
}
func TestMaskSecret(t *testing.T) {
cases := []struct{ in, want string }{
{"", "(unset)"},
{"ab", "**"},
{"abcd", "****"},
{"abcdef", "ab**ef"},
}
for _, c := range cases {
if got := maskSecret(c.in); got != c.want {
t.Errorf("maskSecret(%q) = %q, want %q", c.in, got, c.want)
}
}
// a real-length token must not leak its middle
tok := strings.Repeat("s3cr3t", 6)
if got := maskSecret(tok); strings.Contains(got, "s3cr3ts3cr3t") || len(got) != len(tok) {
t.Errorf("maskSecret leaked or resized: %q", got)
}
}
func TestSSHKeyPath(t *testing.T) {
home := t.TempDir()
t.Setenv("HOME", home)
old := cfg
defer func() { cfg = old }()
cfg = Config{}
if got := sshKeyPath(); got != "" {
t.Errorf("no gitkey should yield no identity, got %q", got)
}
cfg = Config{GitKey: "mgit_rsa"} // bare name -> ~/.ssh
if want := filepath.Join(home, ".ssh", "mgit_rsa"); sshKeyPath() != want {
t.Errorf("sshKeyPath() = %q, want %q", sshKeyPath(), want)
}
cfg = Config{GitKey: "~/keys/id"} // ~/-relative
if want := filepath.Join(home, "keys", "id"); sshKeyPath() != want {
t.Errorf("sshKeyPath() = %q, want %q", sshKeyPath(), want)
}
cfg = Config{GitKey: "/etc/keys/id"} // absolute -> as given
if sshKeyPath() != "/etc/keys/id" {
t.Errorf("sshKeyPath() = %q, want /etc/keys/id", sshKeyPath())
}
// the identity must reach git through the environment, quoted
if env := gitEnv(); len(env) == 0 {
t.Fatal("gitEnv() returned no environment for a configured key")
} else if last := env[len(env)-1]; last != `GIT_SSH_COMMAND=ssh -i '/etc/keys/id'` {
t.Errorf("gitEnv() last entry = %q", last)
}
cfg = Config{}
if gitEnv() != nil {
t.Error("gitEnv() must inherit (nil) when no key is configured")
}
}
func TestProjectRCIgnored(t *testing.T) {
dir := t.TempDir()
// no repository yet: the .gitignore that `init` would use is what counts
if projectRCIgnored(dir) {
t.Error("no .gitignore should not count as ignored")
}
if err := os.WriteFile(filepath.Join(dir, ".gitignore"), []byte("*.o\n/"+projectRC+"\n"), 0644); err != nil {
t.Fatal(err)
}
if !projectRCIgnored(dir) {
t.Error("a .gitignore listing /" + projectRC + " should count as ignored")
}
}
func TestShq(t *testing.T) {
cases := []struct{ in, want string }{
{"myproj", "'myproj'"},
{"my project", "'my project'"},
{"it's", `'it'\''s'`},
{"`rm -rf ~`", "'`rm -rf ~`'"},
{"$(id)", "'$(id)'"},
{"", "''"},
}
for _, c := range cases {
if got := shq(c.in); got != c.want {
t.Errorf("shq(%q) = %q, want %q", c.in, got, c.want)
}
}
// the quoted form must survive a real shell as exactly one argument
out, err := exec.Command("/bin/sh", "-c", "printf '[%s]' "+shq("a b`id`'c")).Output()
if err != nil {
t.Fatal(err)
}
if string(out) != "[a b`id`'c]" {
t.Errorf("shq did not round-trip through /bin/sh: %q", out)
}
}
func TestValidProject(t *testing.T) {
ok := []string{"myproj", "my project", "a.b", "x-1_2"}
bad := []string{"", ".", "..", ".hidden", "foo/bar", "../etc", `foo\bar`}
for _, s := range ok {
if !validProject(s) {
t.Errorf("validProject(%q) = false, want true", s)
}
}
for _, s := range bad {
if validProject(s) {
t.Errorf("validProject(%q) = true, want false", s)
}
}
}
func TestProjectFromCwd(t *testing.T) {
base := t.TempDir()
// t.TempDir may hand back a symlinked path (/var -> /private/var on macOS);
// Getwd reports the resolved one, so compare like for like.
base, err := filepath.EvalSymlinks(base)
if err != nil {
t.Fatal(err)
}
deep := filepath.Join(base, "foo", "src", "lib")
if err := os.MkdirAll(deep, 0755); err != nil {
t.Fatal(err)
}
oldBase, oldWd := BASE, mustGetwd(t)
defer func() { BASE = oldBase; os.Chdir(oldWd) }()
BASE = base
cases := []struct{ dir, want string }{
{deep, "foo"}, // deep inside a project -> the project
{filepath.Join(base, "foo"), "foo"}, // project root
{base, ""}, // BASE itself -> no project
{filepath.Dir(base), ""}, // outside BASE -> no project
}
for _, c := range cases {
if err := os.Chdir(c.dir); err != nil {
t.Fatal(err)
}
if got := projectFromCwd(); got != c.want {
t.Errorf("projectFromCwd() in %s = %q, want %q", c.dir, got, c.want)
}
}
}
func mustGetwd(t *testing.T) string {
t.Helper()
wd, err := os.Getwd()
if err != nil {
t.Fatal(err)
}
return wd
}
func TestCdCommand(t *testing.T) {
base := t.TempDir()
if err := os.MkdirAll(filepath.Join(base, "notes"), 0755); err != nil {
t.Fatal(err)
}
oldBase, oldPrj := BASE, PRJ
defer func() { BASE = oldBase; PRJ = oldPrj }()
BASE, PRJ = base, ""
// bare `cd` must deselect the project, not panic on a missing argument
PRJ = "notes"
runCommand("cd")
if PRJ != "" {
t.Errorf("bare cd: PRJ = %q, want empty", PRJ)
}
runCommand("cd notes")
if PRJ != "notes" {
t.Errorf("cd notes: PRJ = %q, want notes", PRJ)
}
// a path with a separator would escape BASE and is rejected
runCommand("cd ../etc")
if PRJ != "notes" {
t.Errorf("cd ../etc changed PRJ to %q", PRJ)
}
runCommand("cd nosuchproject")
if PRJ != "" {
t.Errorf("cd to a missing project: PRJ = %q, want empty", PRJ)
}
}
func TestApplyConfig(t *testing.T) {
c := Config{GitName: "Original Name"}
applyConfig(&c, map[string]string{
"githost": "example.com",
"gitport": "2200",
"gitname": "", // empty must not override an existing value
})
if c.GitHost != "example.com" {
t.Errorf("GitHost = %q, want example.com", c.GitHost)
}
if c.GitPort != "2200" {
t.Errorf("GitPort = %q, want 2200", c.GitPort)
}
if c.GitName != "Original Name" {
t.Errorf("empty value must not override GitName, got %q", c.GitName)
}
}
func TestRemoteTargetsFromConfig(t *testing.T) {
rc := `
remote.gitea.url = https://git.example.com
remote.gitea.key = tok-gitea
remote.hub.url = https://github.com
remote.hub.key = tok-hub
remote.hub.type = github
remote.hub.visibility = public
remote.broken.url = https://nowhere.example # no key -> unusable
`
var c Config
applyConfig(&c, parseConfig(rc))
usable, incomplete := c.mirrorTargets()
if len(usable) != 2 {
t.Fatalf("mirrorTargets usable = %d, want 2 (%+v)", len(usable), usable)
}
// key order is deterministic: gitea before hub
if usable[0].Name != "gitea" || usable[1].Name != "hub" {
t.Errorf("target order = %q,%q, want gitea,hub", usable[0].Name, usable[1].Name)
}
if usable[1].Type != "github" || usable[1].Vis != "public" {
t.Errorf("hub target = %+v, want type github / visibility public", usable[1])
}
if len(incomplete) != 1 || incomplete[0] != "broken" {
t.Errorf("incomplete = %v, want [broken]", incomplete)
}
}
func TestMirrorTargetsLegacyAndSelection(t *testing.T) {
// the flat remoteurl/remotekey pair stays supported, as target "public"
var c Config
applyConfig(&c, parseConfig("remoteurl = https://git.example.com\nremotekey = tok\n"))
usable, _ := c.mirrorTargets()
if len(usable) != 1 || usable[0].Name != legacyRemoteName {
t.Fatalf("legacy flat config = %+v, want one target named %q", usable, legacyRemoteName)
}
// `remotes` restricts and reorders the set
rc := `
remoteurl = https://git.example.com
remotekey = tok
remote.hub.url = https://github.com
remote.hub.key = tok2
remotes = hub, public
`
var c2 Config
applyConfig(&c2, parseConfig(rc))
usable, _ = c2.mirrorTargets()
if len(usable) != 2 || usable[0].Name != "hub" || usable[1].Name != "public" {
t.Fatalf("remotes selection = %+v, want hub,public", usable)
}
var c3 Config
applyConfig(&c3, parseConfig(rc+"remotes = hub\n"))
usable, _ = c3.mirrorTargets()
if len(usable) != 1 || usable[0].Name != "hub" {
t.Fatalf("narrowed selection = %+v, want only hub", usable)
}
}
func TestParsePushRemoteArgs(t *testing.T) {
cases := []struct {
in string
names []string
desc string
}{
{"", nil, ""},
{"a fix", nil, "a fix"},
{"@hub", []string{"hub"}, ""},
{"@hub a fix", []string{"hub"}, "a fix"},
{"@hub @gitea a fix", []string{"hub", "gitea"}, "a fix"},
{"a fix @hub", nil, "a fix @hub"}, // only leading @words select
{"@", nil, ""},
}
for _, c := range cases {
names, desc := parsePushRemoteArgs(c.in)
if strings.Join(names, ",") != strings.Join(c.names, ",") || desc != c.desc {
t.Errorf("parsePushRemoteArgs(%q) = %v,%q, want %v,%q", c.in, names, desc, c.names, c.desc)
}
}
}
func TestPickRemotes(t *testing.T) {
all := []RemoteTarget{{Name: "gitea"}, {Name: "hub"}}
if got := pickRemotes(all, nil); len(got) != 2 {
t.Errorf("no selection should keep all, got %+v", got)
}
got := pickRemotes(all, []string{"HUB"}) // names are case-insensitive
if len(got) != 1 || got[0].Name != "hub" {
t.Errorf("pickRemotes(HUB) = %+v, want hub", got)
}
if got := pickRemotes(all, []string{"nope"}); len(got) != 0 {
t.Errorf("unknown name should select nothing, got %+v", got)
}
}
func TestResolveProjectConfig(t *testing.T) {
dir := t.TempDir()
global := Config{
Base: "/base", GitHost: "global.example", GitPort: "22", GitUser: "git",
GitPath: "/home/git", GitName: "Global Name", Editor: "vi",
Remotes: []RemoteTarget{{Name: "gitea", URL: "https://gitea.example", Key: "tok"}},
}
// no project file -> unchanged
if got := resolveConfig(global, dir); got.GitHost != "global.example" {
t.Fatalf("without a project file GitHost = %q", got.GitHost)
}
rc := `
githost = project.example
editor = code
base = /somewhere/else
gitname = Project Name
remote.hub.url = https://github.com
remote.hub.key = tok2
remote.gitea.visibility = public
`
if err := os.WriteFile(filepath.Join(dir, projectRC), []byte(rc), 0600); err != nil {
t.Fatal(err)
}
got := resolveConfig(global, dir)
if got.GitHost != "project.example" || got.Editor != "code" {
t.Errorf("project overrides not applied: host=%q editor=%q", got.GitHost, got.Editor)
}
// base and the git identity stay global
if got.Base != "/base" {
t.Errorf("project must not override base, got %q", got.Base)
}
if got.GitName != "Global Name" {
t.Errorf("project must not override gitname, got %q", got.GitName)
}
// a project adds a target and refines a field of a global one
targets, _ := got.mirrorTargets()
if len(targets) != 2 {
t.Fatalf("targets = %+v, want gitea and hub", targets)
}
if targets[0].Name != "gitea" || targets[0].Vis != "public" || targets[0].Key != "tok" {
t.Errorf("gitea target = %+v, want visibility public with the global key", targets[0])
}
if targets[1].Name != "hub" || targets[1].URL != "https://github.com" {
t.Errorf("hub target = %+v", targets[1])
}
// the global configuration must be untouched by the overlay
if global.GitHost != "global.example" || len(global.Remotes) != 1 || global.Remotes[0].Vis != "" {
t.Errorf("resolveConfig mutated the global config: %+v", global)
}
// MGSH_* still wins over the project file
t.Setenv("MGSH_GITHOST", "env.example")
if got := resolveConfig(global, dir); got.GitHost != "env.example" {
t.Errorf("env override lost against project file, got %q", got.GitHost)
}
}
// TestEveryConfigKeyHasEnvOverride keeps the documented settings, the `config`
// command and applyEnv in step: every key mgsh reports must really be
// overridable through its MGSH_* variable.
func TestEveryConfigKeyHasEnvOverride(t *testing.T) {
for _, key := range configKeys() {
probe := "probe-" + key
t.Setenv(envName(key), probe)
var c Config
applyEnv(&c)
if !configHasValue(c, probe) {
t.Errorf("%s does not override the %q setting", envName(key), key)
}
t.Setenv(envName(key), "")
}
}
// configHasValue reports whether any string field of c equals want.
func configHasValue(c Config, want string) bool {
v := reflect.ValueOf(c)
for i := 0; i < v.NumField(); i++ {
if f := v.Field(i); f.Kind() == reflect.String && f.String() == want {
return true
}
}
return false
}
// TestCheckoutForwardsOptions covers the option-stripping trap: mgsh pulls
// `-x` flags out of the word list, so a command that forwards to git has to use
// the raw fields or `checkout -b topic` silently loses its flag.
func TestCheckoutForwardsOptions(t *testing.T) {
base := t.TempDir()
dir := filepath.Join(base, "proj")
if err := os.MkdirAll(dir, 0755); err != nil {
t.Fatal(err)
}
for _, args := range [][]string{
{"init", "-q"},
{"-c", "user.name=t", "-c", "user.email=t@e", "commit", "-q", "--allow-empty", "-m", "x"},
} {
if out, err := exec.Command("git", append([]string{"-C", dir}, args...)...).CombinedOutput(); err != nil {
t.Fatalf("git %v: %v\n%s", args, err, out)
}
}
oldBase, oldPrj, oldDir := BASE, PRJ, DIR
defer func() { BASE, PRJ, DIR = oldBase, oldPrj, oldDir }()
BASE, PRJ, DIR = base, "proj", dir
runCommand("checkout -b topic")
out, err := exec.Command("git", "-C", dir, "rev-parse", "--abbrev-ref", "HEAD").Output()
if err != nil {
t.Fatal(err)
}
if got := strings.TrimSpace(string(out)); got != "topic" {
t.Errorf("after `checkout -b topic` HEAD is %q, want topic", got)
}
}
func TestMissingRequired(t *testing.T) {
full := Config{Base: "/b", GitHost: "h", GitPort: "22", GitUser: "u", GitPath: "/p"}
if m := full.missingRequired(); len(m) != 0 {
t.Errorf("complete config reported missing: %v", m)
}
partial := Config{Base: "/b", GitPort: "22"}
got := strings.Join(partial.missingRequired(), ",")
if got != "githost,gituser,gitpath" {
t.Errorf("missingRequired = %q, want githost,gituser,gitpath", got)
}
}
func TestSplitLines(t *testing.T) {
if got := splitLines(""); got != nil {
t.Errorf("splitLines(\"\") = %v, want nil", got)
}
got := splitLines("a\nb\nc\n")
if len(got) != 3 || got[0] != "a" || got[2] != "c" {
t.Errorf("splitLines = %v", got)
}
}
func TestWord(t *testing.T) {
ws := []string{"a", "b"}
if word(ws, 0) != "a" || word(ws, 1) != "b" || word(ws, 2) != "" {
t.Errorf("word indexing wrong")
}
}
func TestUnquote(t *testing.T) {
cases := []struct{ in, want string }{
{"'echo $1'", "echo $1"},
{`"echo $1"`, "echo $1"},
{"echo $1", "echo $1"},
{"'unbalanced", "'unbalanced"},
{"''", ""},
{"'", "'"},
}
for _, c := range cases {
if got := unquote(c.in); got != c.want {
t.Errorf("unquote(%q) = %q, want %q", c.in, got, c.want)
}
}
}
func TestConfigTemplateGenerated(t *testing.T) {
home := t.TempDir()
t.Setenv("HOME", home)
for _, e := range []string{"MGSH_BASE", "MGSH_GITHOST", "MGSH_GITPORT", "MGSH_GITUSER", "MGSH_GITPATH"} {
t.Setenv(e, "") // ensure env can't satisfy the requirements
}
rc := filepath.Join(home, ".mgshrc")
// no ~/.mgshrc yet -> loadConfig writes a blank template (no real values)
c := loadConfig()
if !fileExists(rc) {
t.Fatalf("loadConfig did not generate %s", rc)
}
if m := c.missingRequired(); len(m) != len(requiredKeys) {
t.Fatalf("blank template should leave all required unset, missing=%v", m)
}
if s := readFile(t, rc); !strings.Contains(s, "base") || !strings.Contains(s, "githost") {
t.Fatalf("template missing key hints:\n%s", s)
}
}
func TestConfigLoadAndAliasRoundTrip(t *testing.T) {
home := t.TempDir()
t.Setenv("HOME", home)
rc := filepath.Join(home, ".mgshrc")
os.WriteFile(rc, []byte(
"base = "+home+"\ngithost = h.example\ngitport = 22\ngituser = git\ngitpath = /home/git\n"), 0644)
c := loadConfig()
if m := c.missingRequired(); len(m) != 0 {
t.Fatalf("configured file still reports missing: %v", m)
}
if c.GitHost != "h.example" {
t.Fatalf("GitHost = %q, want h.example", c.GitHost)
}
// defining an alias persists it into ~/.mgshrc without losing config lines
aliases = map[string]string{"co": "checkout $1"}
saveAliases()
s := readFile(t, rc)
if !strings.Contains(s, "alias co 'checkout $1'") {
t.Fatalf("alias not written to rc:\n%s", s)
}
if !strings.Contains(s, "githost = h.example") {
t.Fatalf("saveAliases clobbered config lines:\n%s", s)
}
// and it reloads from the same file
aliases = map[string]string{}
loadAliases()
if aliases["co"] != "checkout $1" {
t.Fatalf("alias did not round-trip, got %q", aliases["co"])
}
}
func TestLegacyAliasMigration(t *testing.T) {
home := t.TempDir()
t.Setenv("HOME", home)
legacy := filepath.Join(home, ".mgsh_aliases")
if err := os.WriteFile(legacy, []byte("# header\nls\t!lsd -la\n"), 0644); err != nil {
t.Fatal(err)
}
loadConfig() // generates ~/.mgshrc and migrates the legacy alias
rc := filepath.Join(home, ".mgshrc")
if s := readFile(t, rc); !strings.Contains(s, "alias ls '!lsd -la'") {
t.Fatalf("legacy alias not migrated into rc:\n%s", s)
}
if fileExists(legacy) {
t.Fatalf("legacy alias file should be removed after migration")
}
aliases = map[string]string{}
loadAliases()
if aliases["ls"] != "!lsd -la" {
t.Fatalf("migrated alias not loaded, got %q", aliases["ls"])
}
}
func readFile(t *testing.T, p string) string {
t.Helper()
data, err := os.ReadFile(p)
if err != nil {
t.Fatal(err)
}
return string(data)
}
func TestTruthy(t *testing.T) {
on := []string{"1", "true", "TRUE", "yes", "On", " true "}
off := []string{"", "0", "false", "no", "off", "nope"}
for _, s := range on {
if !truthy(s) {
t.Errorf("truthy(%q) = false, want true", s)
}
}
for _, s := range off {
if truthy(s) {
t.Errorf("truthy(%q) = true, want false", s)
}
}
}
func TestFormatProjStatus(t *testing.T) {
useColor = false
defer func() { useColor = false }()
w := overviewWidths{label: 12, sync: 5, host: 7}
cases := []struct {
s projStatus
contains []string
absent []string
}{
{projStatus{name: "a", branch: "master", dirty: true, hasUpstream: true, ahead: 2},
[]string{"a", "*", "↑2"}, []string{"↓", "(master)"}},
{projStatus{name: "b", branch: "main", hasUpstream: true, behind: 3},
[]string{"b", "↓3"}, []string{"*", "✓", "(main)"}},
{projStatus{name: "c", branch: "master", hasUpstream: true},
[]string{"c", "✓"}, []string{"*", "↑", "↓"}},
{projStatus{name: "d", branch: "feature", dirty: true},
[]string{"d", "*", "(feature)"}, nil},
{projStatus{name: "e", branch: "master"}, // clean, no upstream
[]string{"e", ""}, []string{"*", "✓"}},
{projStatus{name: "f", branch: "master", hasUpstream: true, ahead: 1, behind: 2},
[]string{"f", "↑1↓2"}, []string{"✓"}}, // diverged shows both
{projStatus{name: "g", branch: "master", hasUpstream: true, lastHost: "laptop",
mirrors: []string{"hub", "gitea"}},
[]string{"g", "laptop", "→ hub gitea"}, nil},
}
for _, c := range cases {
c.s.isRepo = true // these all describe real repositories
got := formatProjStatus(c.s, w)
for _, sub := range c.contains {
if !strings.Contains(got, sub) {
t.Errorf("formatProjStatus(%+v) = %q, missing %q", c.s, got, sub)
}
}
for _, sub := range c.absent {
if strings.Contains(got, sub) {
t.Errorf("formatProjStatus(%+v) = %q, should not contain %q", c.s, got, sub)
}
}
}
}
// TestOverviewColumnsAlign is the point of the table: every field has to start
// at the same column on every row, whatever the name lengths or the multi-byte
// status glyphs do.
func TestOverviewColumnsAlign(t *testing.T) {
useColor = false
// host names must not occur anywhere else in a row, or the index search
// below would find them inside a project or branch name instead
rows := []projStatus{
{name: "a", branch: "master", hasUpstream: true, ahead: 12, behind: 3, lastHost: "workstation"},
{name: "a-very-long-project-name", branch: "wip", dirty: true, lastHost: "buildbox"},
{name: "mid", branch: "main", hasUpstream: true, lastHost: "laptop"},
}
w := measureOverview(rows)
var widths []int
for _, r := range rows {
line := formatProjStatus(r, w)
// the host column starts right after the padded sync field
idx := strings.Index(line, r.lastHost)
if idx < 0 {
t.Fatalf("host %q missing from %q", r.lastHost, line)
}
widths = append(widths, utf8.RuneCountInString(line[:idx]))
}
for i := 1; i < len(widths); i++ {
if widths[i] != widths[0] {
t.Errorf("host column starts at %d on row %d, %d on row 0:\n%s",
widths[i], i, widths[0], strings.Join([]string{
formatProjStatus(rows[0], w), formatProjStatus(rows[i], w)}, "\n"))
}
}
}
func TestAttentionRank(t *testing.T) {
ranks := []struct {
s projStatus
want int
}{
{projStatus{isRepo: true, dirty: true}, 0},
{projStatus{isRepo: true, ahead: 1}, 0},
{projStatus{isRepo: true, behind: 1}, 0},
{projStatus{isRepo: true, hasUpstream: true}, 1},
{projStatus{isRepo: true}, 1}, // clean, no upstream
// not on the server is a different kind of task and goes last, even
// when the working tree is dirty — it cannot be pushed anyway
{projStatus{isRepo: true, notOnServer: true}, 2},
{projStatus{isRepo: true, dirty: true, notOnServer: true}, 2},
{projStatus{notOnServer: true}, 2},
}
for _, c := range ranks {
if got := attentionRank(c.s); got != c.want {
t.Errorf("attentionRank(%+v) = %d, want %d", c.s, got, c.want)
}
}
}
func TestDetectRemoteKind(t *testing.T) {
cases := []struct {
url, override string
want remoteKind
}{
{"https://git.fhi.mpg.de", "", kindGitea},
{"https://github.com", "", kindGitHub},
{"https://api.github.com", "", kindGitHub},
{"https://gitlab.com", "", kindGitLab},
{"https://gitlab.example.org", "", kindGitLab},
{"https://git.fhi.mpg.de", "github", kindGitHub}, // override wins
{"https://github.com", "gitlab", kindGitLab}, // override wins
{"https://anything", "", kindGitea}, // default
}
for _, c := range cases {
if got := detectRemoteKind(c.url, c.override); got != c.want {
t.Errorf("detectRemoteKind(%q,%q) = %d, want %d", c.url, c.override, got, c.want)
}
}
}
func TestRemoteAPIEndpoints(t *testing.T) {
cases := []struct {
url, typ string
root, web, hdrKey string
}{
{"https://git.fhi.mpg.de/", "", "https://git.fhi.mpg.de/api/v1", "https://git.fhi.mpg.de/mike/mgsh.git", "Authorization"},
{"https://github.com", "", "https://api.github.com", "https://github.com/mike/mgsh.git", "Authorization"},
{"https://gitlab.com", "", "https://gitlab.com/api/v4", "https://gitlab.com/mike/mgsh.git", "PRIVATE-TOKEN"},
}
for _, c := range cases {
api := newRemoteAPI(c.url, "tok", c.typ)
if got := api.apiRoot(); got != c.root {
t.Errorf("apiRoot(%q) = %q, want %q", c.url, got, c.root)
}
if got := api.repoWebURL("mike", "mgsh"); got != c.web {
t.Errorf("repoWebURL(%q) = %q, want %q", c.url, got, c.web)
}
if k, _ := api.authHeader(); k != c.hdrKey {
t.Errorf("authHeader(%q) key = %q, want %q", c.url, k, c.hdrKey)
}
}
}
func TestFirstLine(t *testing.T) {
if got := firstLine([]byte(" hello\nworld ")); got != "hello" {
t.Errorf("firstLine multiline = %q, want hello", got)
}
long := strings.Repeat("x", 300)
if got := firstLine([]byte(long)); len(got) != 200 {
t.Errorf("firstLine did not truncate, len=%d", len(got))
}
}
func TestCountSourceLines(t *testing.T) {
dir := t.TempDir()
write := func(rel string, data []byte) {
p := filepath.Join(dir, rel)
if err := os.MkdirAll(filepath.Dir(p), 0755); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(p, data, 0644); err != nil {
t.Fatal(err)
}
}
// counted: any text file, regardless of extension, in any visible subdir
write("main.go", []byte("a\nb\nc\n")) // 3
write("Makefile", []byte("all:\n\techo hi\n")) // 2 (no extension)
write("sub/util.py", []byte("x\ny\n")) // 2
write("bin/script", []byte("#!/bin/sh\nls\n")) // 2
// skipped: hidden file, file in hidden dir, and a binary file
write(".gitignore", []byte("node_modules\n"))
write(".git/config", []byte("[core]\n\trepo\n"))
write("logo.png", []byte{0x89, 'P', 'N', 'G', 0x00, 0x0a, 0x00})
lines, files := countSourceLines(dir)
if files != 4 {
t.Errorf("countSourceLines files = %d, want 4", files)
}
if lines != 9 {
t.Errorf("countSourceLines lines = %d, want 9", lines)
}
}
func TestExpandAlias(t *testing.T) {
cases := []struct {
body string
args []string
want string
}{
{"checkout $1", []string{"main"}, "checkout main"},
{"!echo $1", []string{"hello"}, "!echo hello"},
{"push $*", []string{"fixed", "bug"}, "push fixed bug"},
{"push $@", []string{"a", "b"}, "push a b"},
{"echo $1 $2", []string{"a"}, "echo a"}, // missing $2 -> empty
{"status", []string{"x"}, "status x"}, // no placeholder -> append args
{"status", nil, "status"}, // no placeholder, no args
{"log", []string{}, "log"}, // empty args slice
{"diff $1", nil, "diff"}, // placeholder with no arg -> empty
}
for _, c := range cases {
if got := expandAlias(c.body, c.args); got != c.want {
t.Errorf("expandAlias(%q, %v) = %q, want %q", c.body, c.args, got, c.want)
}
}
}
func TestSplitAtMarker(t *testing.T) {
lines := []string{"a", "b", "---mgsh---", "c", "d"}
before, after := splitAtMarker(lines, "---mgsh---")
if strings.Join(before, ",") != "a,b" || strings.Join(after, ",") != "c,d" {
t.Errorf("split = %v / %v", before, after)
}
// no marker: everything is the first section, so a server that produced no
// du output simply yields no sizes
before, after = splitAtMarker([]string{"a", "b"}, "---mgsh---")
if strings.Join(before, ",") != "a,b" || after != nil {
t.Errorf("split without marker = %v / %v", before, after)
}
}
func TestParseDuSizes(t *testing.T) {
lines := []string{
"185432\tBetaflight3.0.0.git",
"2144\twebsite.git",
"876 spaced-with-blanks.git", // some du implementations use spaces
"1024\t./with-dot-slash.git",
"1500\tmy project.git", // a name with a space survives
"garbage",
"",
}
got := parseDuSizes(lines)
want := map[string]int64{
"Betaflight3.0.0.git": 185432 * 1024,
"website.git": 2144 * 1024,
"spaced-with-blanks.git": 876 * 1024,
"with-dot-slash.git": 1024 * 1024,
"my project.git": 1500 * 1024,
}
if len(got) != len(want) {
t.Fatalf("parseDuSizes = %v, want %d entries", got, len(want))
}
for k, v := range want {
if got[k] != v {
t.Errorf("parseDuSizes[%q] = %d, want %d", k, got[k], v)
}
}
}