The status field was not a column: "*", "↑2", "✓" and "✓ (no upstream)" are four different widths, so everything after them started somewhere else on every line and the eye had to hunt along each row instead of going down one. Each field now has its own measured column: name (with the branch appended when it is not master/main), a one-character dirty marker, the sync state, host and age, then the mirrors. "(no upstream)" was fifteen columns wide for something that is not even a problem, and is now "–". Colour weights the row rather than decorating it -- a project that is clean and in sync goes grey, the arrows and the dirty marker keep their colour -- and the rows needing action sort to the top, alphabetically within each group so positions stay predictable. padRight counted bytes, which was fine while everything it padded was ASCII; the arrows and check marks are three bytes and one column, so it counts runes now. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
1050 lines
33 KiB
Go
1050 lines
33 KiB
Go
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)
|
||
}
|
||
}
|
||
}
|