Files
mgsh/mgsh_test.go
T
mikeandClaude Opus 5 3a420093d1 Show repository sizes in list
`list -a` had sizes because archives are files; repositories are
directories, and a long listing reports the inode size for those -- 4096
for every single one. Taking that number would have filled the column
with the same meaningless value, so the real disk usage is asked of `du`
instead, appended to the same remote command so it still costs one round
trip.

The column is dropped entirely when no usable sizes come back, rather
than showing a column of zeroes, so a server without a working `du`
degrades to the previous output. The summary line carries the total.

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

988 lines
30 KiB
Go

package main
import (
"os"
"os/exec"
"path/filepath"
"reflect"
"strconv"
"strings"
"testing"
"time"
)
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 }()
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", "no upstream"}, []string{"*"}},
}
for _, c := range cases {
got := formatProjStatus(c.s, 8)
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)
}
}
}
}
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)
}
}
}