36 files changed,
1866 insertions(+),
33 deletions(-)
Author:
Oleksandr Smirnov
olexsmir@gmail.com
Committed by:
GitHub
noreply@github.com
Committed at:
2026-04-02 20:37:35 +0300
Parent:
f35f950
jump to
M
.github/workflows/ci.yml
路路路 20 20 21 21 - name: Tests 22 22 run: go test -v -race ./... 23 + 24 + nix: 25 + runs-on: ubuntu-latest 26 + steps: 27 + - uses: actions/checkout@v5 28 + 29 + - uses: cachix/install-nix-action@v31 30 + with: 31 + github_access_token: ${{ secrets.GITHUB_TOKEN }} 32 + 33 + - run: nix build 34 + - run: nix flake check
M
flake.nix
路路路 14 14 pname = "mugit"; 15 15 version = version; 16 16 src = ./.; 17 - vendorHash = "sha256-eJ6L6o2cisJRZxoEDf9gtHL8T+xpnIDq9KPQr1vgLig="; 17 + vendorHash = "sha256-ZqDG7EniAWVQQ259m4HnARLi06m1Dqpru2p7NYou8Vw="; 18 18 ldflags = [ "-s" "-w" "-X main.version=${version}" ]; 19 + nativeCheckInputs = [ pkgs.git ]; 19 20 meta = with pkgs.lib; { 20 21 homepage = "https://git.olexsmir.xyz/mugit"; 21 22 license = licenses.mit;
M
go.mod
路路路 6 6 github.com/bluekeyes/go-gitdiff v0.8.1 7 7 github.com/cyphar/filepath-securejoin v0.6.1 8 8 github.com/go-git/go-git/v5 v5.17.0 9 + github.com/rogpeppe/go-internal v1.14.1 9 10 github.com/urfave/cli/v3 v3.7.0 10 11 github.com/yuin/goldmark v1.7.16 11 12 github.com/yuin/goldmark-emoji v1.0.6 路路路 34 35 github.com/xanzy/ssh-agent v0.3.3 // indirect 35 36 golang.org/x/net v0.51.0 // indirect 36 37 golang.org/x/sys v0.41.0 // indirect 38 + golang.org/x/tools v0.26.0 // indirect 37 39 gopkg.in/warnings.v0 v0.1.2 // indirect 38 40 )
M
go.sum
路路路 104 104 golang.org/x/text v0.34.0 h1:oL/Qq0Kdaqxa1KbNeMKwQq0reLCCaFtqu2eNuSeNHbk= 105 105 golang.org/x/text v0.34.0/go.mod h1:homfLqTYRFyVYemLBFl5GgL/DWEiH5wcsQ5gSh1yziA= 106 106 golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= 107 +golang.org/x/tools v0.26.0 h1:v/60pFQmzmT9ExmjDv2gGIfi3OqfKoEP6I5+umXlbnQ= 108 +golang.org/x/tools v0.26.0/go.mod h1:TPVVj70c7JJ3WCazhD8OdXcZg/og+b9+tH/KxylGwH0= 107 109 gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= 108 110 gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= 109 111 gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk=
M
internal/cache/in_memory_test.go
路路路 16 16 c.Set("asdf", "qwer") 17 17 is.Equal(t, c.data["asdf"].v, "qwer") 18 18 }) 19 + 19 20 t.Run("overwrites prev value", func(t *testing.T) { 20 21 c.Set("asdf", "one") 21 22 c.Set("asdf", "two") 路路路 32 33 is.Equal(t, true, found) 33 34 is.Equal(t, "qwer", v) 34 35 }) 36 + 35 37 t.Run("miss", func(t *testing.T) { 36 38 _, found := c.Get("missing") 37 39 is.Equal(t, false, found) 38 40 }) 41 + 39 42 t.Run("expired item", func(t *testing.T) { 40 43 synctest.Test(t, func(t *testing.T) { 41 44 c.Set("asdf", "qwer") 路路路 45 48 is.Equal(t, "", v) 46 49 }) 47 50 }) 51 +} 52 + 53 +func TestInMemory_ZeroTTL(t *testing.T) { 54 + c := NewInMemory[string](0) 55 + c.Set("key", "val") 56 + 57 + _, found := c.Get("key") 58 + is.Equal(t, false, found) 59 +} 60 + 61 +func TestInMemory_StructType(t *testing.T) { 62 + type testItem struct{ v string } 63 + 64 + c := NewInMemory[testItem](time.Minute) 65 + expected := testItem{v: "repo"} 66 + c.Set("k", expected) 67 + 68 + v, found := c.Get("k") 69 + is.Equal(t, expected, v) 70 + is.Equal(t, true, found) 71 +} 72 + 73 +func TestInMemory_EmptyKey(t *testing.T) { 74 + c := NewInMemory[string](time.Minute) 75 + c.Set("", "empty-key-val") 76 + 77 + v, found := c.Get("") 78 + is.Equal(t, "empty-key-val", v) 79 + is.Equal(t, true, found) 48 80 } 49 81 50 82 func TestInMemory_ConcurrentSetGet(t *testing.T) {
M
internal/cli/repo.go
路路路 26 26 return fmt.Errorf("repository already exists: %s", name) 27 27 } 28 28 29 + mirrorURL := cmd.String("mirror") 30 + if mirrorURL != "" { 31 + if merr := mirror.IsRemoteSupported(mirrorURL); merr != nil { 32 + return merr 33 + } 34 + } 35 + 29 36 if err = git.Init(path); err != nil { 30 37 return err 31 38 } 路路路 39 46 return fmt.Errorf("failed to set private status: %w", err) 40 47 } 41 48 42 - mirrorURL := cmd.String("mirror") 43 49 if mirrorURL != "" { 44 - if err := mirror.IsRemoteSupported(mirrorURL); err != nil { 45 - return err 46 - } 47 50 if err := repo.SetMirrorRemote(mirrorURL); err != nil { 48 51 return fmt.Errorf("failed to set mirror remote: %w", err) 49 52 } 路路路 123 126 } 124 127 125 128 branch := cmd.Args().Get(0) 126 - slog.Info("chaining repo head", "repo", name, "branch", branch) 127 - err = repo.Checkout(branch) 129 + if err = repo.Checkout(branch); err != nil { 130 + return err 131 + } 132 + 133 + slog.Info("changed repo head", "repo", name, "branch", branch) 128 134 return err 129 135 } 130 136
M
internal/config/config.go
路路路 35 35 } 36 36 37 37 type SSHConfig struct { 38 - Enable bool `yaml:"enable"` 39 - User string `yaml:"user"` 40 - Keys []string `yaml:"keys"` 38 + Enable bool `yaml:"enable"` 39 + User string `yaml:"user"` 40 + Keys []string `yaml:"keys"` 41 41 } 42 42 43 43 type MirrorConfig struct {
A
internal/git/archive_test.go
路路路 1 +package git 2 + 3 +import ( 4 + "testing" 5 + 6 + "olexsmir.xyz/x/is" 7 +) 8 + 9 +func TestIsValidRef(t *testing.T) { 10 + tests := []struct { 11 + name string 12 + ref string 13 + want bool 14 + }{ 15 + {name: "simple branch", ref: "main", want: true}, 16 + {name: "branch with slash", ref: "feature/new-thing", want: true}, 17 + {name: "version tag", ref: "v1.2.3", want: true}, 18 + {name: "short hash", ref: "abc123d", want: true}, 19 + {name: "full hash", ref: "abc123def456789abc123def456789abc123def4", want: true}, 20 + {name: "refs/heads path", ref: "refs/heads/main", want: true}, 21 + {name: "refs/tags path", ref: "refs/tags/v1.0.0", want: true}, 22 + {name: "branch with underscore", ref: "feature_branch", want: true}, 23 + {name: "branch with dot", ref: "release.1.0", want: true}, 24 + {name: "branch with hyphen", ref: "bug-fix", want: true}, 25 + 26 + // security sensitive 27 + {name: "empty string", ref: "", want: false}, 28 + {name: "double dot traversal", ref: "..", want: false}, 29 + {name: "path traversal start", ref: "../etc/passwd", want: false}, 30 + {name: "path traversal middle", ref: "refs/../../../etc/passwd", want: false}, 31 + {name: "double dot in path", ref: "feature/..secret", want: false}, 32 + 33 + // invalid characters 34 + {name: "space in name", ref: "my branch", want: false}, 35 + {name: "newline injection", ref: "main\nmalicious", want: false}, 36 + {name: "null byte", ref: "main\x00malicious", want: false}, 37 + {name: "shell metachar semicolon", ref: "main;rm -rf", want: false}, 38 + {name: "shell metachar backtick", ref: "main`whoami`", want: false}, 39 + {name: "shell metachar dollar", ref: "main$PATH", want: false}, 40 + {name: "shell metachar pipe", ref: "main|cat", want: false}, 41 + {name: "shell metachar ampersand", ref: "main&id", want: false}, 42 + {name: "single quote", ref: "main'test", want: false}, 43 + {name: "double quote", ref: "main\"test", want: false}, 44 + {name: "tilde", ref: "~root", want: false}, 45 + {name: "asterisk", ref: "main*", want: false}, 46 + {name: "question mark", ref: "main?", want: false}, 47 + {name: "brackets", ref: "main[0]", want: false}, 48 + {name: "parentheses", ref: "main()", want: false}, 49 + {name: "hash", ref: "main#comment", want: false}, 50 + {name: "percent", ref: "main%20test", want: false}, 51 + {name: "caret", ref: "main^", want: false}, 52 + {name: "at sign", ref: "main@{0}", want: false}, 53 + {name: "exclamation", ref: "main!", want: false}, 54 + {name: "backslash", ref: "main\\test", want: false}, 55 + {name: "colon", ref: "main:test", want: false}, 56 + } 57 + 58 + for _, tt := range tests { 59 + t.Run(tt.name, func(t *testing.T) { 60 + is.Equal(t, isValidRef(tt.ref), tt.want) 61 + }) 62 + } 63 +}
M
internal/git/config.go
路路路 78 78 79 79 func (g *Repo) SetDescription(desc string) error { 80 80 path := filepath.Join(g.path, "description") 81 - return os.WriteFile(path, []byte(desc), 0o644) 81 + return os.WriteFile(path, []byte(desc), 0o600) 82 82 } 83 83 84 84 func (g *Repo) LastSync() (time.Time, error) {
A
internal/git/config_test.go
路路路 1 +package git 2 + 3 +import ( 4 + "os" 5 + "path/filepath" 6 + "testing" 7 + 8 + "olexsmir.xyz/x/is" 9 +) 10 + 11 +func TestRepo_IsPrivate(t *testing.T) { 12 + t.Run("default is not private", func(t *testing.T) { 13 + tr := newTestRepo(t) 14 + private, err := tr.open().IsPrivate() 15 + is.Equal(t, private, false) 16 + is.Err(t, err, nil) 17 + }) 18 + 19 + t.Run("set to private", func(t *testing.T) { 20 + r := newTestRepo(t).open() 21 + is.Err(t, r.SetPrivate(true), nil) 22 + 23 + private, err := r.IsPrivate() 24 + is.Err(t, err, nil) 25 + is.Equal(t, private, true) 26 + }) 27 + 28 + t.Run("can be set back to public", func(t *testing.T) { 29 + r := newTestRepo(t).open() 30 + 31 + is.Err(t, r.SetPrivate(true), nil) 32 + is.Err(t, r.SetPrivate(false), nil) 33 + 34 + private, err := r.IsPrivate() 35 + is.Equal(t, private, false) 36 + is.Err(t, err, nil) 37 + }) 38 +} 39 + 40 +func TestRepo_Description(t *testing.T) { 41 + t.Run("default description is empty description", func(t *testing.T) { 42 + r := newTestRepo(t) 43 + r.commitFile("dummy.txt", "dummy", "Initial commit") 44 + 45 + descPath := filepath.Join(r.path, ".git", "description") 46 + _ = os.WriteFile(descPath, []byte(defaultDescription), 0o644) 47 + 48 + desc, err := r.open().Description() 49 + is.Err(t, err, nil) 50 + is.Equal(t, desc, "") 51 + }) 52 + 53 + t.Run("empty description returns empty string", func(t *testing.T) { 54 + r := newTestRepo(t) 55 + r.commitFile("dummy.txt", "dummy", "Initial commit") 56 + 57 + desc, err := r.open().Description() 58 + is.Err(t, err, nil) 59 + is.Equal(t, desc, "") 60 + }) 61 + 62 + t.Run("default git description is treated as empty", func(t *testing.T) { 63 + r := newTestRepo(t) 64 + r.commitFile("dummy.txt", "dummy", "Initial commit") 65 + 66 + // Write the default git description to the .git directory 67 + descPath := filepath.Join(r.path, ".git", "description") 68 + err := os.WriteFile(descPath, []byte(defaultDescription), 0o644) 69 + is.Err(t, err, nil) 70 + 71 + desc, err := r.open().Description() 72 + is.Err(t, err, nil) 73 + is.Equal(t, desc, "") 74 + }) 75 + 76 + t.Run("set and get description", func(t *testing.T) { 77 + r := newTestRepo(t) 78 + r.commitFile("dummy.txt", "dummy", "Initial commit") 79 + 80 + repo := r.open() 81 + err := repo.SetDescription("My awesome project") 82 + is.Err(t, err, nil) 83 + 84 + desc, err := repo.Description() 85 + is.Err(t, err, nil) 86 + is.Equal(t, desc, "My awesome project") 87 + }) 88 + 89 + t.Run("description with newlines", func(t *testing.T) { 90 + r := newTestRepo(t) 91 + r.commitFile("dummy.txt", "dummy", "Initial commit") 92 + 93 + repo := r.open() 94 + multiLine := "My project\n\nWith multiple lines\nof description." 95 + err := repo.SetDescription(multiLine) 96 + is.Err(t, err, nil) 97 + 98 + desc, err := repo.Description() 99 + is.Err(t, err, nil) 100 + is.Equal(t, desc, multiLine) 101 + }) 102 +} 103 + 104 +func TestRepo_Mirror(t *testing.T) { 105 + t.Run("repo without origin remote can't be a mirror", func(t *testing.T) { 106 + r := newTestRepo(t) 107 + r.commitFile("dummy.txt", "dummy", "Initial commit") 108 + 109 + _, err := r.open().IsMirror() 110 + is.Err(t, err, "failed to get remote: ") 111 + }) 112 + 113 + t.Run("set mirror remote", func(t *testing.T) { 114 + r := newTestRepo(t).open() 115 + 116 + expectedURL := "https://github.com/example/repo.git" 117 + err := r.SetMirrorRemote(expectedURL) 118 + is.Err(t, err, nil) 119 + 120 + isMirror, err := r.IsMirror() 121 + is.Equal(t, isMirror, true) 122 + is.Err(t, err, nil) 123 + 124 + url, err := r.RemoteURL() 125 + is.Err(t, err, nil) 126 + is.Equal(t, url, expectedURL) 127 + }) 128 +}
A
internal/git/diff_test.go
路路路 1 +package git 2 + 3 +import ( 4 + "testing" 5 + 6 + "github.com/bluekeyes/go-gitdiff/gitdiff" 7 + "olexsmir.xyz/x/is" 8 +) 9 + 10 +func TestRepo_Diff(t *testing.T) { 11 + t.Run("single file addition", func(t *testing.T) { 12 + r := newTestRepo(t) 13 + r.commitFile("README.md", "# Test", "Initial commit") 14 + r.commitFile("hello.txt", "hello world\n", "Add hello file") 15 + 16 + diff, err := r.open().Diff() 17 + is.Err(t, err, nil) 18 + is.Equal(t, diff.Stat.FilesChanged, 1) 19 + is.Equal(t, diff.Stat.Insertions, 1) 20 + is.Equal(t, diff.Stat.Deletions, 0) 21 + is.Equal(t, len(diff.Diff), 1) 22 + is.Equal(t, diff.Diff[0].Name.New, "hello.txt") 23 + is.Equal(t, diff.Diff[0].IsNew, true) 24 + }) 25 + 26 + t.Run("file modification", func(t *testing.T) { 27 + r := newTestRepo(t) 28 + r.commitFile("README.md", "# Original\n", "Initial commit") 29 + r.commitFile("README.md", "# Modified\n\nNew content here.\n", "Update README") 30 + 31 + diff, err := r.open().Diff() 32 + is.Err(t, err, nil) 33 + is.Equal(t, diff.Stat.FilesChanged, 1) 34 + is.Equal(t, diff.Diff[0].Name.New, "README.md") 35 + is.Equal(t, diff.Diff[0].IsNew, false) 36 + is.Equal(t, diff.Diff[0].IsDelete, false) 37 + if diff.Stat.Insertions == 0 { 38 + t.Error("expected insertions > 0") 39 + } 40 + if diff.Stat.Deletions == 0 { 41 + t.Error("expected deletions > 0") 42 + } 43 + }) 44 + 45 + t.Run("file deletion", func(t *testing.T) { 46 + r := newTestRepo(t) 47 + r.commitFile("todelete.txt", "temp content\n", "Add temp file") 48 + r.deleteFile("todelete.txt", "Delete temp file") 49 + 50 + diff, err := r.open().Diff() 51 + is.Err(t, err, nil) 52 + is.Equal(t, diff.Stat.FilesChanged, 1) 53 + is.Equal(t, diff.Stat.Deletions, 1) 54 + is.Equal(t, diff.Diff[0].IsDelete, true) 55 + }) 56 + 57 + t.Run("multiple files changed", func(t *testing.T) { 58 + r := newTestRepo(t) 59 + r.commitFile("file1.txt", "content 1\n", "Add file1") 60 + r.commitFile("file2.txt", "content 2\n", "Add file2") 61 + r.commitFile("file3.txt", "content 3\n", "Add file3") 62 + 63 + diff, err := r.open().Diff() 64 + is.Err(t, err, nil) 65 + is.Equal(t, diff.Stat.FilesChanged, 1) 66 + is.Equal(t, diff.Stat.Insertions, 1) 67 + }) 68 + 69 + t.Run("has parent hashes", func(t *testing.T) { 70 + r := newTestRepo(t) 71 + r.commitFile("first.txt", "first\n", "First commit") 72 + r.commitFile("second.txt", "second file\n", "Add second file") 73 + 74 + diff, err := r.open().Diff() 75 + is.Err(t, err, nil) 76 + is.Equal(t, len(diff.Parents), 1) 77 + if len(diff.Parents[0]) == 0 { 78 + t.Error("expected parent hash to be non-empty") 79 + } 80 + }) 81 + 82 + t.Run("initial commit has no parents", func(t *testing.T) { 83 + r := newTestRepo(t) 84 + r.commitFile("initial.txt", "initial\n", "Initial commit") 85 + 86 + commits, err := r.open().Commits("") 87 + is.Err(t, err, nil) 88 + if len(commits) == 0 { 89 + t.Fatal("expected at least one commit") 90 + } 91 + 92 + initial := r.open(commits[len(commits)-1].Hash) 93 + diff, err := initial.Diff() 94 + is.Equal(t, len(diff.Parents), 0) 95 + is.Err(t, err, nil) 96 + }) 97 + 98 + t.Run("text fragments have line info", func(t *testing.T) { 99 + r := newTestRepo(t) 100 + r.commitFile("README.md", "original\n", "Initial commit") 101 + r.commitFile("README.md", "line 1\nline 2\nline 3\n", "Multi-line change") 102 + 103 + diff, err := r.open().Diff() 104 + is.Err(t, err, nil) 105 + if len(diff.Diff) == 0 { 106 + t.Fatal("expected at least one diff") 107 + } 108 + if len(diff.Diff[0].TextFragments) == 0 { 109 + t.Fatal("expected at least one text fragment") 110 + } 111 + 112 + frag := diff.Diff[0].TextFragments[0] 113 + if len(frag.Lines) == 0 { 114 + t.Fatal("expected at least one line") 115 + } 116 + 117 + // Check that lines have operations 118 + hasAdd := false 119 + for _, line := range frag.Lines { 120 + if line.Op == gitdiff.OpAdd { 121 + hasAdd = true 122 + } 123 + } 124 + if !hasAdd { 125 + t.Error("expected at least one added line") 126 + } 127 + }) 128 + 129 + t.Run("commit info is populated", func(t *testing.T) { 130 + r := newTestRepo(t) 131 + r.commitFile("info.txt", "test\n", "Test commit message") 132 + 133 + diff, err := r.open().Diff() 134 + is.Err(t, err, nil) 135 + is.Equal(t, diff.Commit.Message, "Test commit message") 136 + is.Equal(t, diff.Commit.AuthorName, "Test User") 137 + is.Equal(t, diff.Commit.AuthorEmail, "test@test.local") 138 + if len(diff.Commit.Hash) == 0 { 139 + t.Error("expected commit hash to be non-empty") 140 + } 141 + }) 142 +} 143 + 144 +func TestTextFragment(t *testing.T) { 145 + frag := TextFragment{ 146 + Header: "@@ -1,3 +1,4 @@", 147 + OldPosition: 1, 148 + NewPosition: 1, 149 + Lines: []gitdiff.Line{ 150 + {Op: gitdiff.OpContext, Line: "context line"}, 151 + {Op: gitdiff.OpAdd, Line: "added line"}, 152 + }, 153 + } 154 + is.Equal(t, frag.Header, "@@ -1,3 +1,4 @@") 155 + is.Equal(t, frag.OldPosition, int64(1)) 156 + is.Equal(t, frag.NewPosition, int64(1)) 157 + is.Equal(t, len(frag.Lines), 2) 158 +}
A
internal/git/paths_test.go
路路路 1 +package git 2 + 3 +import ( 4 + "testing" 5 + 6 + "olexsmir.xyz/x/is" 7 +) 8 + 9 +func TestResolveName(t *testing.T) { 10 + tests := []struct { 11 + name string 12 + input string 13 + want string 14 + }{ 15 + {name: "empty string", input: "", want: ".git"}, 16 + {name: "already suffixed", input: "myrepo.git", want: "myrepo.git"}, 17 + {name: "no suffix", input: "myrepo", want: "myrepo.git"}, 18 + {name: ".git.git", input: "repo.git.git", want: "repo.git.git"}, 19 + { 20 + name: "special characters", 21 + input: "my-awesome_project", 22 + want: "my-awesome_project.git", 23 + }, 24 + } 25 + 26 + for _, tt := range tests { 27 + t.Run(tt.name, func(t *testing.T) { 28 + is.Equal(t, ResolveName(tt.input), tt.want) 29 + }) 30 + } 31 +} 32 + 33 +func TestResolvePath(t *testing.T) { 34 + base := "/repos" 35 + tests := []struct { 36 + name string 37 + base string 38 + path string 39 + want string 40 + }{ 41 + {"simple", base, "myrepo.git", "/repos/myrepo.git"}, 42 + {"empty", base, "", "/repos"}, // FIXME: block this 43 + {"nested", base, "user/project.git", "/repos/user/project.git"}, // FIXME: support only one level deep paths 44 + {"block path traversal", base, "../etc/passwd", "/repos/etc/passwd"}, 45 + {"block absolute path", base, "/etc/passwd", "/repos/etc/passwd"}, 46 + {"multiple traversal attempts", base, "../../../../../../etc/passwd", "/repos/etc/passwd"}, 47 + {"base with trailing slash", "/repos/", "myrepo.git", "/repos/myrepo.git"}, 48 + } 49 + 50 + for _, tt := range tests { 51 + t.Run(tt.name, func(t *testing.T) { 52 + path, err := ResolvePath(tt.base, tt.path) 53 + is.Equal(t, tt.want, path) 54 + is.Err(t, err, nil) 55 + }) 56 + } 57 +}
A
internal/git/repo_test.go
路路路 1 +package git 2 + 3 +import ( 4 + "os" 5 + "path/filepath" 6 + "testing" 7 + 8 + "github.com/go-git/go-git/v5/plumbing" 9 + "olexsmir.xyz/x/is" 10 +) 11 + 12 +func TestRepo_Name(t *testing.T) { 13 + tests := []struct { 14 + name string 15 + path string 16 + want string 17 + }{ 18 + {name: "name", path: "/repos/myrepo", want: "myrepo"}, 19 + {name: "with .git", path: "/repos/myrepo.git", want: "myrepo"}, 20 + {name: "nested path", path: "/home/user/code/project", want: "project"}, 21 + {name: "nested with .git", path: "/home/user/repos/awesome-project.git", want: "awesome-project"}, 22 + } 23 + 24 + for _, tt := range tests { 25 + t.Run(tt.name, func(t *testing.T) { 26 + r := &Repo{path: tt.path} 27 + is.Equal(t, r.Name(), tt.want) 28 + }) 29 + } 30 +} 31 + 32 +func TestRepo_IsEmpty(t *testing.T) { 33 + t.Run("empty repo", func(t *testing.T) { 34 + r := &Repo{h: plumbing.ZeroHash} 35 + is.Equal(t, r.IsEmpty(), true) 36 + }) 37 + 38 + t.Run("non-empty repo", func(t *testing.T) { 39 + r := &Repo{h: plumbing.NewHash("abc123def456789abc123def456789abc123def4")} 40 + is.Equal(t, r.IsEmpty(), false) 41 + }) 42 +} 43 + 44 +func TestInit(t *testing.T) { 45 + t.Run("creates bare repo", func(t *testing.T) { 46 + dir := t.TempDir() 47 + repoPath := filepath.Join(dir, "test.git") 48 + 49 + err := Init(repoPath) 50 + is.Err(t, err, nil) 51 + 52 + _, err = os.Stat(filepath.Join(repoPath, "HEAD")) 53 + is.Err(t, err, nil) 54 + }) 55 + 56 + t.Run("fails on existing repo", func(t *testing.T) { 57 + dir := t.TempDir() 58 + repoPath := filepath.Join(dir, "test.git") 59 + 60 + err := Init(repoPath) 61 + is.Err(t, err, nil) 62 + 63 + is.Err(t, Init(repoPath), "repository already exists") 64 + }) 65 +} 66 + 67 +func TestOpen(t *testing.T) { 68 + t.Run("opens repo at HEAD", func(t *testing.T) { 69 + r := newTestRepo(t) 70 + r.commitFile("README.md", "Test", "Initial commit") 71 + 72 + repo, err := Open(r.path, "") 73 + is.Equal(t, repo.IsEmpty(), false) 74 + is.Err(t, err, nil) 75 + }) 76 + 77 + t.Run("opens repo at specific ref", func(t *testing.T) { 78 + r := newTestRepo(t) 79 + firstHash := r.commitFile("file1.txt", "first", "first commit") 80 + r.commitFile("file2.txt", "second", "second commit") 81 + 82 + repo := r.open(firstHash.String()) 83 + commit, err := repo.LastCommit() 84 + is.Equal(t, commit.Message, "first commit") 85 + is.Err(t, err, nil) 86 + }) 87 + 88 + t.Run("fails on invalid path", func(t *testing.T) { 89 + _, err := Open("/nonexistent/path", "") 90 + is.Err(t, err, "does not exist") 91 + }) 92 + 93 + t.Run("fails on invalid ref", func(t *testing.T) { 94 + r := newTestRepo(t) 95 + r.commitFile("README.md", "# Test", "Initial commit") 96 + 97 + _, err := Open(r.path, "nonexistent-ref") 98 + is.Err(t, err, "resolving rev ") 99 + }) 100 +} 101 + 102 +func TestOpenPublic(t *testing.T) { 103 + t.Run("opens public repo", func(t *testing.T) { 104 + r := newTestRepo(t) 105 + r.commitFile("README.md", "# Test", "Initial commit") 106 + 107 + repo, err := OpenPublic(r.path, "") 108 + is.Equal(t, repo.IsEmpty(), false) 109 + is.Err(t, err, nil) 110 + }) 111 + 112 + t.Run("returns ErrPrivate for private repo", func(t *testing.T) { 113 + r := newTestRepo(t) 114 + r.commitFile("README.md", "# Test", "Initial commit") 115 + 116 + err := r.open().SetPrivate(true) 117 + is.Err(t, err, nil) 118 + 119 + _, err = OpenPublic(r.path, "") 120 + is.Err(t, err, ErrPrivate) 121 + }) 122 +} 123 + 124 +func TestRepo_Commits(t *testing.T) { 125 + t.Run("returns commits in reverse chronological order", func(t *testing.T) { 126 + r := newTestRepo(t) 127 + r.commitFile("README.md", "# Test", "Initial commit") 128 + r.commitFile("a.txt", "a", "Add a") 129 + r.commitFile("b.txt", "b", "Add b") 130 + r.commitFile("c.txt", "c", "Add c") 131 + 132 + commits, err := r.open().Commits("") 133 + is.Err(t, err, nil) 134 + 135 + is.Equal(t, len(commits), 4) 136 + is.Equal(t, commits[0].Message, "Add c") 137 + is.Equal(t, commits[1].Message, "Add b") 138 + is.Equal(t, commits[2].Message, "Add a") 139 + is.Equal(t, commits[3].Message, "Initial commit") 140 + }) 141 + 142 + t.Run("pagination with after cursor", func(t *testing.T) { 143 + r := newTestRepo(t) 144 + r.commitFile("README.md", "# Test", "Initial commit") 145 + r.commitFile("a.txt", "a", "Add a") 146 + r.commitFile("b.txt", "b", "Add b") 147 + 148 + // get all commits first 149 + all, err := r.open().Commits("") 150 + is.Equal(t, len(all), 3) 151 + is.Err(t, err, nil) 152 + 153 + // get commits after the first one 154 + after, err := r.open().Commits(all[0].HashShort) 155 + is.Err(t, err, nil) 156 + is.Equal(t, len(after), 2) 157 + is.Equal(t, after[0].Message, "Add a") 158 + }) 159 + 160 + t.Run("empty repo returns empty slice", func(t *testing.T) { 161 + r := newTestRepo(t) 162 + commits, err := r.open().Commits("") 163 + is.Equal(t, len(commits), 0) 164 + is.Err(t, err, nil) 165 + }) 166 +} 167 + 168 +func TestRepo_LastCommit(t *testing.T) { 169 + t.Run("returns HEAD commit", func(t *testing.T) { 170 + r := newTestRepo(t) 171 + r.commitFile("readme", "test", "init") 172 + r.commitFile("latest.txt", "latest", "latest commit") 173 + 174 + commit, err := r.open().LastCommit() 175 + is.Equal(t, commit.Message, "latest commit") 176 + is.Err(t, err, nil) 177 + }) 178 + 179 + t.Run("empty repo returns empty commit", func(t *testing.T) { 180 + commit, err := newTestRepo(t).open().LastCommit() 181 + is.Err(t, err, nil) 182 + is.Equal(t, commit.Message, "") 183 + }) 184 +} 185 + 186 +func TestRepo_Branches(t *testing.T) { 187 + r := newTestRepo(t) 188 + hash := r.commitFile("file.txt", "content", "A commit") 189 + r.createBranch("feature", hash) 190 + r.createBranch("develop", hash) 191 + 192 + branches, err := r.open().Branches() 193 + is.Err(t, err, nil) 194 + 195 + names := make(map[string]bool, len(branches)) 196 + for _, b := range branches { 197 + names[b.Name] = true 198 + } 199 + 200 + is.Equal(t, names["master"], true) // got on init 201 + is.Equal(t, names["feature"], true) 202 + is.Equal(t, names["develop"], true) 203 +} 204 + 205 +func TestRepo_IsGoMod(t *testing.T) { 206 + t.Run("without go.mod", func(t *testing.T) { 207 + r := newTestRepo(t) 208 + r.commitFile("readme", "test", "init") 209 + is.Equal(t, r.open().IsGoMod(), false) 210 + }) 211 + 212 + t.Run("with go.mod", func(t *testing.T) { 213 + r := newTestRepo(t) 214 + r.commitFile("go.mod", "module example.com/test\n\ngo 1.21\n", "Add go.mod") 215 + is.Equal(t, r.open().IsGoMod(), true) 216 + }) 217 +} 218 + 219 +func TestRepo_FindMasterBranch(t *testing.T) { 220 + t.Run("finds master branch", func(t *testing.T) { 221 + r := newTestRepo(t) 222 + r.commitFile("README.md", "test", "init") 223 + branch, err := r.open().FindMasterBranch([]string{"master", "main"}) 224 + 225 + is.Equal(t, branch, "master") 226 + is.Err(t, err, nil) 227 + }) 228 + 229 + t.Run("finds first matching candidate", func(t *testing.T) { 230 + r := newTestRepo(t) 231 + hash := r.commitFile("file.txt", "x", "Commit") 232 + r.createBranch("main", hash) 233 + 234 + // master should be found first (master is created by init) 235 + branch, err := r.open().FindMasterBranch([]string{"master", "main"}) 236 + is.Equal(t, branch, "master") 237 + is.Err(t, err, nil) 238 + 239 + // if main is check first, it should be found 240 + branch, err = r.open().FindMasterBranch([]string{"main", "master"}) 241 + is.Equal(t, branch, "main") 242 + is.Err(t, err, nil) 243 + }) 244 + 245 + t.Run("returns error when no match", func(t *testing.T) { 246 + r := newTestRepo(t) 247 + r.commitFile("file.txt", "x", "Commit") 248 + _, err := r.open().FindMasterBranch([]string{"nonexistent"}) 249 + is.Err(t, err, "unable to find master") 250 + }) 251 + 252 + t.Run("returns error for empty repo", func(t *testing.T) { 253 + r := newTestRepo(t) 254 + _, err := r.open().FindMasterBranch([]string{"main"}) 255 + is.Err(t, err, ErrEmptyRepo) 256 + }) 257 +}
A
internal/git/testutil_test.go
路路路 1 +package git 2 + 3 +import ( 4 + "os" 5 + "path/filepath" 6 + "testing" 7 + "time" 8 + 9 + "github.com/go-git/go-git/v5" 10 + "github.com/go-git/go-git/v5/plumbing" 11 + "github.com/go-git/go-git/v5/plumbing/object" 12 + "olexsmir.xyz/x/is" 13 +) 14 + 15 +type testRepo struct { 16 + tb testing.TB 17 + r *git.Repository 18 + path string 19 +} 20 + 21 +func newTestRepo(tb testing.TB) *testRepo { 22 + tb.Helper() 23 + path := tb.TempDir() 24 + 25 + // creates non-bare repo, bare repos require to keep track of HEAD manually 26 + r, err := git.PlainInit(path, false) 27 + is.Err(tb, err, nil) 28 + 29 + cfg, err := r.Config() 30 + is.Err(tb, err, nil) 31 + 32 + cfg.User.Name = "Test User" 33 + cfg.User.Email = "test@test.local" 34 + is.Err(tb, r.SetConfig(cfg), nil) 35 + 36 + return &testRepo{tb: tb, path: path, r: r} 37 +} 38 + 39 +func (t *testRepo) commitFileAt(name, content, msg string, when time.Time) plumbing.Hash { 40 + t.tb.Helper() 41 + 42 + filePath := filepath.Join(t.path, name) 43 + is.Err(t.tb, os.MkdirAll(filepath.Dir(filePath), 0o755), nil) 44 + is.Err(t.tb, os.WriteFile(filePath, []byte(content), 0o644), nil) 45 + 46 + wt, err := t.r.Worktree() 47 + is.Err(t.tb, err, nil) 48 + 49 + _, err = wt.Add(name) 50 + is.Err(t.tb, err, nil) 51 + 52 + hash, err := wt.Commit(msg, &git.CommitOptions{ 53 + Author: &object.Signature{ 54 + Name: "Test User", 55 + Email: "test@test.local", 56 + When: when, 57 + }, 58 + }) 59 + is.Err(t.tb, err, nil) 60 + return hash 61 +} 62 + 63 +func (t *testRepo) commitFile(name, content, msg string) plumbing.Hash { 64 + t.tb.Helper() 65 + return t.commitFileAt(name, content, msg, time.Now()) 66 +} 67 + 68 +func (t *testRepo) deleteFile(name, msg string) plumbing.Hash { 69 + t.tb.Helper() 70 + 71 + wt, err := t.r.Worktree() 72 + is.Err(t.tb, err, nil) 73 + 74 + _, err = wt.Remove(name) 75 + is.Err(t.tb, err, nil) 76 + 77 + hash, err := wt.Commit(msg, &git.CommitOptions{ 78 + Author: &object.Signature{ 79 + Name: "Test User", 80 + Email: "test@test.local", 81 + When: time.Now(), 82 + }, 83 + }) 84 + is.Err(t.tb, err, nil) 85 + return hash 86 +} 87 + 88 +func (t *testRepo) createBranch(name string, hash plumbing.Hash) { 89 + t.tb.Helper() 90 + ref := plumbing.NewHashReference(plumbing.NewBranchReferenceName(name), hash) 91 + is.Err(t.tb, t.r.Storer.SetReference(ref), nil) 92 +} 93 + 94 +func (t *testRepo) createTag(name string, hash plumbing.Hash) { 95 + t.tb.Helper() 96 + ref := plumbing.NewHashReference(plumbing.NewTagReferenceName(name), hash) 97 + is.Err(t.tb, t.r.Storer.SetReference(ref), nil) 98 +} 99 + 100 +func (t *testRepo) createAnnotatedTag(name, msg string, hash plumbing.Hash, when time.Time) { 101 + t.tb.Helper() 102 + _, err := t.r.CreateTag(name, hash, &git.CreateTagOptions{ 103 + Tagger: &object.Signature{ 104 + Name: "Test User", 105 + Email: "test@test.local", 106 + When: when, 107 + }, 108 + Message: msg, 109 + }) 110 + is.Err(t.tb, err, nil) 111 +} 112 + 113 +func (t *testRepo) open(ref ...string) *Repo { 114 + t.tb.Helper() 115 + re := "" 116 + if len(ref) == 1 { 117 + re = ref[0] 118 + } 119 + r, err := Open(t.path, re) 120 + is.Err(t.tb, err, nil) 121 + return r 122 +}
A
internal/git/tree_test.go
路路路 1 +package git 2 + 3 +import ( 4 + "testing" 5 + 6 + "olexsmir.xyz/x/is" 7 +) 8 + 9 +func TestRepo_FileTree(t *testing.T) { 10 + t.Run("root tree", func(t *testing.T) { 11 + r := newTestRepo(t) 12 + r.commitFile("README.md", "# Test", "Initial commit") 13 + r.commitFile("src/main.go", "package main", "Add main.go") 14 + r.commitFile("docs/guide.md", "# Guide", "Add guide") 15 + 16 + tree, err := r.open().FileTree(t.Context(), "") 17 + is.Err(t, err, nil) 18 + 19 + names := make(map[string]bool) 20 + for _, entry := range tree { 21 + names[entry.Name] = true 22 + } 23 + is.Equal(t, names["README.md"], true) 24 + is.Equal(t, names["src"], true) 25 + is.Equal(t, names["docs"], true) 26 + }) 27 + 28 + t.Run("subdirectory", func(t *testing.T) { 29 + r := newTestRepo(t) 30 + r.commitFile("src/main.go", "package main", "Add main.go") 31 + r.commitFile("src/util/helper.go", "package util", "Add helper") 32 + 33 + tree, err := r.open().FileTree(t.Context(), "src") 34 + is.Err(t, err, nil) 35 + 36 + names := make(map[string]bool) 37 + for _, entry := range tree { 38 + names[entry.Name] = true 39 + } 40 + 41 + is.Equal(t, names["main.go"], true) 42 + is.Equal(t, names["util"], true) 43 + }) 44 + 45 + t.Run("distinguishes files and directories", func(t *testing.T) { 46 + r := newTestRepo(t) 47 + r.commitFile("file.txt", "content", "Add file") 48 + r.commitFile("dir/nested.txt", "nested", "Add nested") 49 + 50 + tree, err := r.open().FileTree(t.Context(), "") 51 + is.Err(t, err, nil) 52 + for _, entry := range tree { 53 + switch entry.Name { 54 + case "file.txt": 55 + is.Equal(t, entry.IsFile, true) 56 + is.Equal(t, entry.Commit.Message, "Add file") 57 + case "dir": 58 + is.Equal(t, entry.IsFile, false) 59 + is.Equal(t, entry.Commit.Message, "Add nested") 60 + } 61 + } 62 + }) 63 + 64 + t.Run("includes file sizes", func(t *testing.T) { 65 + r := newTestRepo(t) 66 + content := "Hello, World!" 67 + r.commitFile("hello.txt", content, "Add hello") 68 + 69 + tree, err := r.open().FileTree(t.Context(), "") 70 + is.Err(t, err, nil) 71 + for _, entry := range tree { 72 + if entry.Name == "hello.txt" { 73 + is.Equal(t, entry.Size, int64(len(content))) 74 + is.Equal(t, entry.Commit.Message, "Add hello") 75 + } 76 + } 77 + }) 78 +} 79 + 80 +func TestRepo_FileContent(t *testing.T) { 81 + t.Run("returns text file content", func(t *testing.T) { 82 + r := newTestRepo(t) 83 + r.commitFile("hello.txt", "Hello, World!", "Add hello") 84 + 85 + fc, err := r.open().FileContent("hello.txt") 86 + is.Err(t, err, nil) 87 + is.Equal(t, fc.String(), "Hello, World!") 88 + is.Equal(t, fc.IsBinary, false) 89 + is.Equal(t, fc.IsImage, false) 90 + }) 91 + 92 + t.Run("returns file in subdirectory", func(t *testing.T) { 93 + r := newTestRepo(t) 94 + r.commitFile("lua/hello.lua", `vim.print "hi"`, "add stuff") 95 + 96 + fc, err := r.open().FileContent("lua/hello.lua") 97 + is.Err(t, err, nil) 98 + is.Equal(t, fc.String(), `vim.print "hi"`) 99 + }) 100 + 101 + t.Run("returns ErrFileNotFound for missing file", func(t *testing.T) { 102 + r := newTestRepo(t) 103 + r.commitFile("dummy.txt", "dummy", "Initial commit") 104 + 105 + _, err := r.open().FileContent("nonexistent.txt") 106 + is.Err(t, err, ErrFileNotFound) 107 + }) 108 + 109 + t.Run("detects mime type from extension", func(t *testing.T) { 110 + r := newTestRepo(t) 111 + r.commitFile("style.css", "body { color: red; }", "Add css") 112 + r.commitFile("script.js", "console.log('hi')", "Add js") 113 + 114 + repo := r.open() 115 + css, err := repo.FileContent("style.css") 116 + is.Equal(t, css.Mime, "text/css; charset=utf-8") 117 + is.Err(t, err, nil) 118 + 119 + js, err := repo.FileContent("script.js") 120 + is.Equal(t, js.Mime, "text/javascript; charset=utf-8") 121 + is.Err(t, err, nil) 122 + }) 123 + 124 + t.Run("defaults to text/plain for unknown extension", func(t *testing.T) { 125 + r := newTestRepo(t) 126 + r.commitFile("data.mugitunknown", "some data", "Add data") 127 + 128 + fc, err := r.open().FileContent("data.mugitunknown") 129 + is.Equal(t, fc.Mime, "text/plain") 130 + is.Err(t, err, nil) 131 + }) 132 +} 133 + 134 +func TestFileContent_String(t *testing.T) { 135 + t.Run("returns content for text", func(t *testing.T) { 136 + is.Equal(t, (&FileContent{ 137 + Content: []byte("hello"), 138 + IsBinary: false, 139 + IsImage: false, 140 + }).String(), "hello") 141 + }) 142 + 143 + t.Run("returns empty for binary and images", func(t *testing.T) { 144 + is.Equal(t, (&FileContent{ 145 + Content: []byte("binary-data"), 146 + IsBinary: true, 147 + IsImage: false, 148 + }).String(), "") 149 + 150 + is.Equal(t, (&FileContent{ 151 + Content: []byte("image data"), 152 + IsBinary: false, 153 + IsImage: true, 154 + }).String(), "") 155 + }) 156 +}
M
internal/handlers/feed.go
路路路 23 23 type rssItemXML struct { 24 24 Title string `xml:"title"` 25 25 Link string `xml:"link"` 26 - Guid string `xml:"guid"` 26 + GUID string `xml:"guid"` 27 27 Description string `xml:"description,omitempty"` 28 28 PubDate string `xml:"pubDate,omitempty"` 29 29 } 路路路 69 69 it := rssItemXML{ 70 70 Title: "branch: " + branch.Name, 71 71 Link: href, 72 - Guid: href, 72 + GUID: href, 73 73 } 74 74 if !branch.LastUpdate.IsZero() { 75 75 it.PubDate = branch.LastUpdate.Format(time.RFC1123Z) 路路路 85 85 it := rssItemXML{ 86 86 Title: "tag: " + tag.Name(), 87 87 Link: href, 88 - Guid: href, 88 + GUID: href, 89 89 Description: tag.Message(), 90 90 } 91 91 if !tag.When().IsZero() { 路路路 130 130 it := rssItemXML{ 131 131 Title: repo.Name, 132 132 Link: href, 133 - Guid: href, 133 + GUID: href, 134 134 Description: repo.Desc, 135 135 } 136 136 if !repo.LastCommit.IsZero() {
M
internal/handlers/handlers.go
路路路 74 74 var templateFuncs = template.FuncMap{ 75 75 "inc": func(n int) int { return n + 1 }, 76 76 "inc64": func(n int64) int64 { return n + 1 }, 77 - "humanizeRelTime": func(t time.Time) string { return humanize.Time(t) }, 78 77 "humanizeTime": func(t time.Time) string { return t.Format("2006-01-02 15:04:05 MST") }, 79 - "urlencode": func(s string) string { return url.PathEscape(s) }, 80 - "commitSummary": func(s string) string { 81 - before, after, found := strings.Cut(s, "\n") 82 - first := strings.TrimSuffix(before, "\r") 83 - if !found { 84 - return first 85 - } 86 - 87 - if strings.Contains(after, "\n") { 88 - return first + "..." 89 - } 78 + "humanizeRelTime": humanize.Time, 79 + "urlencode": url.PathEscape, 80 + "commitSummary": commitSummary, 81 +} 90 82 83 +func commitSummary(commitMsg string) string { 84 + before, after, found := strings.Cut(commitMsg, "\n") 85 + first := strings.TrimSuffix(before, "\r") 86 + if !found { 91 87 return first 92 - }, 88 + } 89 + 90 + // if there is any content after the first newline, indicate it with "..." 91 + after = strings.TrimLeft(after, "\r\n") 92 + if after != "" { 93 + return first + "..." 94 + } 95 + 96 + return first 93 97 }
A
internal/handlers/handlers_test.go
路路路 1 +package handlers 2 + 3 +import ( 4 + "testing" 5 + 6 + "olexsmir.xyz/x/is" 7 +) 8 + 9 +func TestBreadcrumbs(t *testing.T) { 10 + tests := []struct { 11 + name string 12 + path string 13 + want []Breadcrumb 14 + }{ 15 + {name: "empty path", path: "", want: nil}, 16 + { 17 + name: "single segment", 18 + path: "src", 19 + want: []Breadcrumb{{Name: "src", Path: "src", IsLast: true}}, 20 + }, 21 + { 22 + name: "two segments", 23 + path: "src/main", 24 + want: []Breadcrumb{ 25 + {Name: "src", Path: "src", IsLast: false}, 26 + {Name: "main", Path: "src/main", IsLast: true}, 27 + }, 28 + }, 29 + { 30 + name: "deep nesting", 31 + path: "src/internal/handlers/repo.go", 32 + want: []Breadcrumb{ 33 + {Name: "src", Path: "src", IsLast: false}, 34 + {Name: "internal", Path: "src/internal", IsLast: false}, 35 + {Name: "handlers", Path: "src/internal/handlers", IsLast: false}, 36 + {Name: "repo.go", Path: "src/internal/handlers/repo.go", IsLast: true}, 37 + }, 38 + }, 39 + } 40 + 41 + for _, tt := range tests { 42 + t.Run(tt.name, func(t *testing.T) { 43 + is.Equal(t, Breadcrumbs(tt.path), tt.want) 44 + }) 45 + } 46 +} 47 + 48 +func TestParseRef(t *testing.T) { 49 + tests := []struct { 50 + name string 51 + input string 52 + want string 53 + }{ 54 + {name: "simple ref", input: "main", want: "main"}, 55 + {name: "url encoded slash", input: "feature%2Fnew-thing", want: "feature/new-thing"}, 56 + {name: "url encoded spaces", input: "my%20branch", want: "my branch"}, 57 + {name: "already decoded", input: "refs/heads/main", want: "refs/heads/main"}, 58 + {name: "version tag", input: "v1.2.3", want: "v1.2.3"}, 59 + {name: "hash", input: "abc123def", want: "abc123def"}, 60 + } 61 + 62 + h := handlers{} 63 + for _, tt := range tests { 64 + t.Run(tt.name, func(t *testing.T) { 65 + is.Equal(t, h.parseRef(tt.input), tt.want) 66 + }) 67 + } 68 +} 69 + 70 +func TestTemplate_CommitSummary(t *testing.T) { 71 + tests := []struct { 72 + name string 73 + input string 74 + want string 75 + }{ 76 + {name: "empty string", input: "", want: ""}, 77 + {name: "single line", input: "Fix bug in handler", want: "Fix bug in handler"}, 78 + {name: "trailing newline only", input: "Fix bug\n", want: "Fix bug"}, 79 + { 80 + name: "no blank line separator (malformed)", 81 + input: "Fix bug\nMore details", 82 + want: "Fix bug...", 83 + }, 84 + { 85 + name: "proper body with blank line", 86 + input: "Fix bug\n\nMore details here", 87 + want: "Fix bug...", 88 + }, 89 + { 90 + name: "multiple body paragraphs", 91 + input: "Fix bug\n\nMore details\n\nEven more", 92 + want: "Fix bug...", 93 + }, 94 + { 95 + name: "trailing blank lines only", 96 + input: "Fix bug\n\n", 97 + want: "Fix bug", 98 + }, 99 + { 100 + name: "with CRLF no body", 101 + input: "Fix bug\r\n", 102 + want: "Fix bug", 103 + }, 104 + { 105 + name: "with CRLF and body", 106 + input: "Fix bug\r\n\r\nMore details", 107 + want: "Fix bug...", 108 + }, 109 + } 110 + 111 + for _, tt := range tests { 112 + t.Run(tt.name, func(t *testing.T) { 113 + is.Equal(t, commitSummary(tt.input), tt.want) 114 + }) 115 + } 116 +}
A
internal/markdown/relink_test.go
路路路 1 +package markdown 2 + 3 +import ( 4 + "testing" 5 + 6 + "olexsmir.xyz/x/is" 7 +) 8 + 9 +func TestIsAbsoluteURL(t *testing.T) { 10 + tests := []struct { 11 + name string 12 + link string 13 + want bool 14 + }{ 15 + // absolute URLs 16 + {name: "https url", link: "https://example.com", want: true}, 17 + {name: "http url", link: "http://example.com", want: true}, 18 + {name: "https with path", link: "https://example.com/path/to/file", want: true}, 19 + {name: "protocol relative", link: "//example.com/path", want: true}, 20 + {name: "anchor link", link: "#section", want: true}, 21 + {name: "anchor with id", link: "#my-heading-id", want: true}, 22 + {name: "ftp scheme", link: "ftp://files.example.com", want: true}, 23 + {name: "mailto scheme", link: "mailto:user@example.com", want: true}, 24 + {name: "data uri", link: "data:image/png;base64,abc123", want: true}, 25 + 26 + // relative URLs 27 + {name: "relative path", link: "path/to/file.md"}, 28 + {name: "relative with dot", link: "./relative/path"}, 29 + {name: "parent directory", link: "../other/file.md"}, 30 + {name: "absolute path no scheme", link: "/absolute/path"}, 31 + {name: "just filename", link: "README.md"}, 32 + {name: "image file", link: "images/logo.png"}, 33 + {name: "empty string", link: ""}, 34 + } 35 + 36 + for _, tt := range tests { 37 + t.Run(tt.name, func(t *testing.T) { 38 + is.Equal(t, isAbsoluteURL(tt.link), tt.want) 39 + }) 40 + } 41 +} 42 + 43 +func TestRelLinkTransformer_imageFromRepo(t *testing.T) { 44 + tests := []struct { 45 + name string 46 + repoName string 47 + repoRef string 48 + baseDir string 49 + dst string 50 + want string 51 + }{ 52 + { 53 + name: "simple image at root", 54 + repoName: "myrepo", 55 + repoRef: "main", 56 + baseDir: "", 57 + dst: "logo.png", 58 + want: "/myrepo/raw/main/logo.png", 59 + }, 60 + { 61 + name: "image in subdirectory", 62 + repoName: "myrepo", 63 + repoRef: "main", 64 + baseDir: "assets/docs", 65 + dst: "images/diagram.png", 66 + want: "/myrepo/raw/main/assets/docs/images/diagram.png", 67 + }, 68 + { 69 + name: "absolute image path", 70 + repoName: "myrepo", 71 + repoRef: "master", 72 + dst: "/assets/logo.png", 73 + want: "/myrepo/raw/master/assets/logo.png", 74 + }, 75 + { 76 + name: "external URL unchanged", 77 + repoName: "myrepo", 78 + repoRef: "main", 79 + baseDir: "", 80 + dst: "https://example.com/image.png", 81 + want: "https://example.com/image.png", 82 + }, 83 + { 84 + name: "protocol relative URL unchanged", 85 + repoName: "myrepo", 86 + repoRef: "main", 87 + baseDir: "", 88 + dst: "//cdn.example.com/image.png", 89 + want: "//cdn.example.com/image.png", 90 + }, 91 + { 92 + name: "with version tag ref", 93 + repoName: "myrepo", 94 + repoRef: "v1.2.3", 95 + baseDir: "", 96 + dst: "screenshot.png", 97 + want: "/myrepo/raw/v1.2.3/screenshot.png", 98 + }, 99 + { 100 + name: "repo name with special chars", 101 + repoName: "my-repo.git", 102 + repoRef: "main", 103 + baseDir: "", 104 + dst: "img.png", 105 + want: "/my-repo.git/raw/main/img.png", 106 + }, 107 + } 108 + 109 + for _, tt := range tests { 110 + t.Run(tt.name, func(t *testing.T) { 111 + m := &relLinkTransformer{ 112 + repoName: tt.repoName, 113 + repoRef: tt.repoRef, 114 + baseDir: tt.baseDir, 115 + } 116 + is.Equal(t, m.imageFromRepo(tt.dst), tt.want) 117 + }) 118 + } 119 +} 120 + 121 +func TestRelLinkTransformer_path(t *testing.T) { 122 + tests := []struct { 123 + name string 124 + baseDir string 125 + dst string 126 + want string 127 + }{ 128 + { 129 + name: "relative from root", 130 + baseDir: "", 131 + dst: "file.md", 132 + want: "file.md", 133 + }, 134 + { 135 + name: "relative from subdirectory", 136 + baseDir: "docs", 137 + dst: "guide.md", 138 + want: "docs/guide.md", 139 + }, 140 + { 141 + name: "absolute path ignores baseDir", 142 + baseDir: "docs", 143 + dst: "/README.md", 144 + want: "/README.md", 145 + }, 146 + { 147 + name: "nested paths", 148 + baseDir: "docs/api", 149 + dst: "endpoints/users.md", 150 + want: "docs/api/endpoints/users.md", 151 + }, 152 + } 153 + 154 + for _, tt := range tests { 155 + t.Run(tt.name, func(t *testing.T) { 156 + m := &relLinkTransformer{baseDir: tt.baseDir} 157 + is.Equal(t, m.path(tt.dst), tt.want) 158 + }) 159 + } 160 +}
M
internal/mirror/mirror.go
路路路 23 23 return nil 24 24 } 25 25 26 +func IsGithubRemote(remoteURL string) bool { 27 + return strings.Contains(remoteURL, "github.com") 28 +} 29 + 26 30 type Worker struct { 27 31 c *config.Config 28 32 } 路路路 100 104 return err 101 105 } 102 106 103 - if w.isRemoteGithub(remoteURL) && w.c.Mirror.GithubToken != "" { 107 + if IsGithubRemote(remoteURL) && w.c.Mirror.GithubToken != "" { 104 108 if err := repo.FetchFromGithubWithToken(ctx, w.c.Mirror.GithubToken); err != nil { 105 109 slog.Error("mirror: fetch failed (github)", "repo", name, "err", err) 106 110 return err 路路路 153 157 154 158 return repos, nil 155 159 } 156 - 157 -func (w *Worker) isRemoteGithub(remoteURL string) bool { 158 - return strings.Contains(remoteURL, "github.com") 159 -}
A
internal/mirror/mirror_test.go
路路路 1 +package mirror 2 + 3 +import ( 4 + "testing" 5 + 6 + "olexsmir.xyz/x/is" 7 +) 8 + 9 +func TestIsRemoteSupported(t *testing.T) { 10 + tests := []struct { 11 + name string 12 + remote string 13 + wantErr bool 14 + }{ 15 + // supported 16 + {name: "https url", remote: "https://github.com/user/repo.git"}, 17 + {name: "http url", remote: "http://example.com/repo.git"}, 18 + {name: "https without .git", remote: "https://github.com/user/repo"}, 19 + 20 + // unsupported 21 + {name: "ssh url", remote: "git@github.com:user/repo.git", wantErr: true}, 22 + {name: "git protocol", remote: "git://github.com/user/repo.git", wantErr: true}, 23 + {name: "local path", remote: "/path/to/repo", wantErr: true}, 24 + {name: "relative path", remote: "../other-repo", wantErr: true}, 25 + {name: "file protocol", remote: "file:///path/to/repo", wantErr: true}, 26 + {name: "empty string", remote: "", wantErr: true}, 27 + } 28 + 29 + for _, tt := range tests { 30 + t.Run(tt.name, func(t *testing.T) { 31 + err := IsRemoteSupported(tt.remote) 32 + if tt.wantErr { 33 + is.Err(t, err, "only http and https") 34 + } else { 35 + is.Err(t, err, nil) 36 + } 37 + }) 38 + } 39 +} 40 + 41 +func TestIsGithubRemote(t *testing.T) { 42 + tests := []struct { 43 + name string 44 + remote string 45 + want bool 46 + }{ 47 + {name: "github https", remote: "https://github.com/user/repo.git", want: true}, 48 + {name: "github http", remote: "http://github.com/user/repo", want: true}, 49 + {name: "github enterprise", remote: "https://github.mycompany.com/user/repo", want: false}, 50 + {name: "raw github", remote: "https://raw.github.com/user/repo/file", want: true}, 51 + 52 + {name: "gitlab", remote: "https://gitlab.com/user/repo.git", want: false}, 53 + {name: "bitbucket", remote: "https://bitbucket.org/user/repo.git", want: false}, 54 + {name: "generic git server", remote: "https://git.example.com/repo.git", want: false}, 55 + {name: "empty url", remote: "", want: false}, 56 + } 57 + 58 + for _, tt := range tests { 59 + t.Run(tt.name, func(t *testing.T) { 60 + is.Equal(t, IsGithubRemote(tt.remote), tt.want) 61 + }) 62 + } 63 +}
A
testscript/cli-repo-description.txtar
路路路 1 +mugit repo new desc-repo 2 + 3 +mugit repo description desc-repo 4 +stderr 'description=""' # fix output 5 + 6 +mugit repo description desc-repo 'new test desc' 7 +stderr 'new_description="new test desc"' # fix output 8 + 9 +mugit repo description desc-repo 10 +stderr 'new_description="new test desc"' # fix output
A
testscript/cli-repo-new-errors.txtar
路路路 1 +# missing repo name 2 +! mugit repo new 3 +stderr 'no name provided' 4 + 5 +# repo already exists 6 +mugit repo new existing-repo 7 +! mugit repo new existing-repo 8 +stderr 'already exists' 9 + 10 +# invalid mirror URL 11 +! mugit repo new mirrored-repo --mirror 'invalid://url' 12 +stderr 'only http and https remotes are supported' 13 +! exists $REPOS/mirrored-repo.git 14 + 15 +! mugit repo new mirrored-repo-ssh --mirror 'git@github.com:olexsmir/gopher.nvim.git' 16 +stderr 'only http and https remotes are supported' 17 +! exists $REPOS/mirrored-repo-ssh.git
A
testscript/cli-repo-private.txtar
路路路 1 +mugit repo new test-private 2 + 3 + 4 +exec cat $REPOS/test-private.git/config 5 +stdout 'private = false' 6 + 7 + 8 +mugit repo private test-private 9 +stderr 'is_private=true' # fix output 10 + 11 +exec cat $REPOS/test-private.git/config 12 +stdout 'private = true' 13 + 14 + 15 +mugit repo private test-private.git 16 +stderr 'is_private=false' # fix output 17 + 18 +exec cat $REPOS/test-private.git/config 19 +stdout 'private = false'
A
testscript/cli-repo-set-head.txtar
路路路 1 +# set head: sets default branch 2 + 3 +git init local 4 +cp file.txt local/file.txt 5 +git -C local add file.txt 6 +git -C local commit -m 'init master' 7 + 8 +git -C local switch -c develop 9 +cp develop.txt local/develop.txt 10 +git -C local add develop.txt 11 +git -C local commit -m 'init develop' 12 + 13 + 14 +mugit repo new heady 15 +git -C local push file://$REPOS/heady.git master 16 +git -C local push file://$REPOS/heady.git develop 17 + 18 + 19 +exec cat $REPOS/heady.git/HEAD 20 +stdout 'ref: refs/heads/master' 21 + 22 +# change head 23 +mugit repo set-head heady develop 24 +stderr 'changed repo head repo=heady.git branch=develop' # fix output 25 + 26 +exec cat $REPOS/heady.git/HEAD 27 +stdout 'ref: refs/heads/develop' 28 + 29 +# go back to master 30 +mugit repo set-head heady.git master 31 +stderr 'changed repo head repo=heady.git branch=master' # fix output 32 + 33 +exec cat $REPOS/heady.git/HEAD 34 +stdout 'ref: refs/heads/master' 35 + 36 + 37 +-- file.txt -- 38 +initial content 39 + 40 +-- develop.txt -- 41 +develop content
A
testscript/http-clone-private.txtar
路路路 1 +# http: deny access to private repos 2 + 3 +git init local 4 +cp readme.txt local/readme.txt 5 +git -C local add . 6 +git -C local commit -m 'init' 7 + 8 +mugit repo new http-private 9 +mugit repo private http-private 10 + 11 +git -C local push file://$REPOS/http-private.git master 12 + 13 + 14 +! exec git clone $MURL/http-private private-clone 15 +stderr 'remote: repository not found' 16 + 17 + 18 +-- readme.txt -- 19 +private repo
A
testscript/http-clone.txtar
路路路 1 +# http: clone 2 + 3 +git init local 4 +cp readme.txt local/readme.txt 5 +cp main.go local/main.go 6 +git -C local add . 7 +git -C local commit -m 'init' 8 + 9 +mugit repo new http-clone.git 10 +git -C local push file://$REPOS/http-clone.git master 11 + 12 + 13 +git clone $MURL/http-clone clone 14 +exists clone/readme.txt 15 +exists clone/main.go 16 + 17 +exec cat clone/readme.txt 18 +stdout 'hello world' 19 + 20 + 21 +-- readme.txt -- 22 +hello world 23 + 24 +-- main.go -- 25 +package main 26 + 27 +func main() { 28 + println("hello") 29 +}
A
testscript/http-push-denied.txtar
路路路 1 +# http: reject push 2 + 3 +git init local 4 +cp file.txt local/file.txt 5 +git -C local add file.txt 6 +git -C local commit -m initial 7 + 8 +mugit repo new http-push 9 + 10 + 11 +! git -C local push $MURL/http-push.git master 12 +stderr 'remote: pushes are only supported over ssh' 13 + 14 + 15 +-- file.txt -- 16 +hello
A
testscript/ssh-push.txtar
路路路 1 +git init local 2 +cp file.txt local/file.txt 3 +git -C local add file.txt 4 +git -C local commit -m initial 5 + 6 +mugit repo new ssh-push 7 +exec env GIT_SSH_COMMAND=$SSH_WRAPPER git -C local push git@localhost:ssh-push.git master 8 + 9 +exec git clone $MURL/ssh-push verify-clone 10 +exists verify-clone/file.txt 11 +exec cat verify-clone/file.txt 12 +stdout 'hello from ssh' 13 + 14 +-- file.txt -- 15 +hello from ssh
A
testscript_test.go
路路路 1 +package main_test 2 + 3 +import ( 4 + "context" 5 + "fmt" 6 + "net" 7 + "net/http" 8 + "os" 9 + "os/exec" 10 + "path/filepath" 11 + "strconv" 12 + "testing" 13 + "time" 14 + 15 + "github.com/rogpeppe/go-internal/testscript" 16 + "gopkg.in/yaml.v2" 17 + 18 + "olexsmir.xyz/mugit/internal/config" 19 + "olexsmir.xyz/mugit/internal/handlers" 20 +) 21 + 22 +var ( 23 + mugitBin string 24 + httpPort int 25 + reposDir string 26 + configPath string 27 +) 28 + 29 +func TestMain(m *testing.M) { os.Exit(testMain(m)) } 30 +func testMain(m *testing.M) int { 31 + ctx, cancel := context.WithCancel(context.Background()) 32 + defer cancel() 33 + 34 + tmpDir, err := os.MkdirTemp("", "mugit-test-*") 35 + if err != nil { 36 + fmt.Fprintf(os.Stderr, "failed to create temp dir: %v\n", err) 37 + return 1 38 + } 39 + defer os.RemoveAll(tmpDir) 40 + 41 + reposDir = filepath.Join(tmpDir, "repos") 42 + if jerr := os.MkdirAll(reposDir, 0o755); jerr != nil { 43 + fmt.Fprintf(os.Stderr, "failed to create repo dir: %v\n", jerr) 44 + return 1 45 + } 46 + 47 + if berr := buildMugitBinary(tmpDir); berr != nil { 48 + fmt.Fprintf(os.Stderr, "failed to build binary: %v\n", berr) 49 + return 1 50 + } 51 + 52 + port, err := findFreePort() 53 + if err != nil { 54 + fmt.Fprintf(os.Stderr, "failed to find free port: %v\n", err) 55 + return 1 56 + } 57 + httpPort = port 58 + 59 + cfg := &config.Config{ 60 + Server: config.ServerConfig{ 61 + Host: "127.0.0.1", 62 + Port: httpPort, 63 + }, 64 + Meta: config.MetaConfig{ 65 + Title: "test mugit", 66 + Host: "localhost", 67 + }, 68 + Repo: config.RepoConfig{ 69 + Dir: reposDir, 70 + Readmes: []string{"README.md"}, 71 + Masters: []string{"master", "main"}, 72 + }, 73 + SSH: config.SSHConfig{Enable: true, User: "git"}, 74 + Mirror: config.MirrorConfig{Enable: false}, 75 + Cache: config.CacheConfig{ 76 + HomePage: 0, 77 + Readme: 0, 78 + Diff: 0, 79 + }, 80 + } 81 + 82 + configPath = filepath.Join(tmpDir, "config.yaml") 83 + configBytes, err := yaml.Marshal(cfg) 84 + if err != nil { 85 + fmt.Fprintf(os.Stderr, "failed to marshal config: %v\n", err) 86 + return 1 87 + } 88 + if err := os.WriteFile(configPath, configBytes, 0o600); err != nil { 89 + fmt.Fprintf(os.Stderr, "failed to write config: %v\n", err) 90 + return 1 91 + } 92 + 93 + httpServer := &http.Server{ 94 + Addr: net.JoinHostPort(cfg.Server.Host, strconv.Itoa(cfg.Server.Port)), 95 + Handler: handlers.InitRoutes(cfg), 96 + } 97 + go func() { 98 + if err := httpServer.ListenAndServe(); err != nil && err != http.ErrServerClosed { 99 + fmt.Fprintf(os.Stderr, "HTTP server error: %v\n", err) 100 + } 101 + }() 102 + 103 + if err := waitForPort(httpPort, 5*time.Second); err != nil { 104 + fmt.Fprintf(os.Stderr, "server did not become ready: %v\n", err) 105 + return 1 106 + } 107 + 108 + code := m.Run() 109 + httpServer.Shutdown(ctx) 110 + return code 111 +} 112 + 113 +func TestScript(t *testing.T) { 114 + if testing.Short() { 115 + t.Skip("skipping integration tests") 116 + } 117 + 118 + sshWrapperContent := fmt.Sprintf(`#!/bin/sh 119 +export SSH_ORIGINAL_COMMAND="$2" 120 +exec %s shell -c %s`, mugitBin, configPath) 121 + 122 + testscript.Run(t, testscript.Params{ 123 + Dir: "testscript", 124 + Cmds: map[string]func(ts *testscript.TestScript, neg bool, args []string){ 125 + "mugit": cmdMugit, 126 + "git": cmdGit, 127 + }, 128 + Setup: func(env *testscript.Env) error { 129 + work := env.Getenv("WORK") 130 + sshWrapperPath := filepath.Join(work, "ssh-wrapper.sh") 131 + if err := os.WriteFile(sshWrapperPath, []byte(sshWrapperContent), 0o700); err != nil { 132 + return fmt.Errorf("failed to create ssh wrapper: %w", err) 133 + } 134 + 135 + env.Setenv("SSH_WRAPPER", sshWrapperPath) 136 + env.Setenv("REPOS", reposDir) 137 + env.Setenv("MPORT", strconv.Itoa(httpPort)) 138 + env.Setenv("MURL", fmt.Sprintf("http://127.0.0.1:%d", httpPort)) 139 + return nil 140 + }, 141 + }) 142 +} 143 + 144 +func buildMugitBinary(tmpDir string) error { 145 + mugitBin = filepath.Join(tmpDir, "mugit") 146 + cmd := exec.Command("go", "build", "-o", mugitBin, ".") 147 + cmd.Dir = "." 148 + if out, err := cmd.CombinedOutput(); err != nil { 149 + os.RemoveAll(tmpDir) 150 + return fmt.Errorf("go build: %v\n%s", err, out) 151 + } 152 + return nil 153 +} 154 + 155 +func findFreePort() (int, error) { 156 + l, err := net.Listen("tcp", "127.0.0.1:0") 157 + if err != nil { 158 + return 0, err 159 + } 160 + port := l.Addr().(*net.TCPAddr).Port 161 + l.Close() 162 + return port, nil 163 +} 164 + 165 +func waitForPort(port int, timeout time.Duration) error { 166 + deadline := time.Now().Add(timeout) 167 + for time.Now().Before(deadline) { 168 + if conn, err := net.DialTimeout( 169 + "tcp", 170 + net.JoinHostPort("127.0.0.1", strconv.Itoa(port)), 171 + 200*time.Millisecond, 172 + ); err == nil { 173 + conn.Close() 174 + return nil 175 + } 176 + time.Sleep(50 * time.Millisecond) 177 + } 178 + return fmt.Errorf("port %d not ready after %s", port, timeout) 179 +} 180 + 181 +func cmdMugit(ts *testscript.TestScript, neg bool, args []string) { 182 + if len(args) < 1 { 183 + ts.Fatalf("usage: mugit <subcommand> ...") 184 + } 185 + cmd := exec.Command(mugitBin, append([]string{"-c", configPath}, args...)...) 186 + cmd.Env = os.Environ() 187 + cmd.Stdout = ts.Stdout() 188 + cmd.Stderr = ts.Stderr() 189 + err := cmd.Run() 190 + if neg { 191 + if err == nil { 192 + ts.Fatalf("expected mugit to fail, it succeeded") 193 + } 194 + } else { 195 + if err != nil { 196 + ts.Fatalf("mugit: %v", err) 197 + } 198 + } 199 +} 200 + 201 +func cmdGit(ts *testscript.TestScript, neg bool, args []string) { 202 + if len(args) > 0 && args[0] == "init" { 203 + hasBranch := false 204 + for _, arg := range args { 205 + if arg == "-b" || arg == "--initial-branch" { 206 + hasBranch = true 207 + break 208 + } 209 + } 210 + if !hasBranch { 211 + args = append([]string{"init", "-b", "master"}, args[1:]...) 212 + } 213 + } 214 + args = append([]string{ 215 + "-c", "user.email=test@test.local", 216 + "-c", "user.name=Test User", 217 + }, args...) 218 + cmd := exec.Command("git", args...) 219 + cmd.Dir = ts.Getenv("WORK") 220 + cmd.Stdout = ts.Stdout() 221 + cmd.Stderr = ts.Stderr() 222 + 223 + err := cmd.Run() 224 + if err == nil && neg { 225 + ts.Fatalf("expected git to fail, but it succeeded") 226 + } 227 + if err != nil && !neg { 228 + ts.Fatalf("git: %v", err) 229 + } 230 +}