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 { 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) { needs := []projStatus{ {dirty: true}, {ahead: 1}, {behind: 1}, } quiet := []projStatus{ {hasUpstream: true}, {}, // clean, no upstream: nothing to do about it here } for _, s := range needs { if attentionRank(s) != 0 { t.Errorf("%+v should sort to the top", s) } } for _, s := range quiet { if attentionRank(s) != 1 { t.Errorf("%+v should sort below the ones needing action", s) } } } 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) } } }