`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>
988 lines
30 KiB
Go
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)
|
|
}
|
|
}
|
|
}
|