all repos

mugit @ 3cf8623aea4ab18930993b5305f5960b087f05e0

馃惍 git server that your cow will love
36 files changed, 1866 insertions(+), 33 deletions(-)
test: add missing tests (#5)

- Unit test internal/git layer, and internal/markdown, internal/handlers(only few tempate funcs, mirror)
- Testscript: git clone, git push(over ssh and http), cli
Author: Oleksandr Smirnov olexsmir@gmail.com
Committed by: GitHub noreply@github.com
Committed at: 2026-04-02 20:37:35 +0300
Parent: f35f950
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/tags_test.go
路路路
        
        1
        +package git

      
        
        2
        +

      
        
        3
        +import (

      
        
        4
        +	"testing"

      
        
        5
        +	"time"

      
        
        6
        +

      
        
        7
        +	"olexsmir.xyz/x/is"

      
        
        8
        +)

      
        
        9
        +

      
        
        10
        +func TestRepo_Tags(t *testing.T) {

      
        
        11
        +	t.Run("empty repo has no tags", func(t *testing.T) {

      
        
        12
        +		r := newTestRepo(t)

      
        
        13
        +		r.commitFile("dummy.txt", "dummy", "Initial commit")

      
        
        14
        +

      
        
        15
        +		tags, err := r.open().Tags()

      
        
        16
        +		is.Equal(t, len(tags), 0)

      
        
        17
        +		is.Err(t, err, nil)

      
        
        18
        +	})

      
        
        19
        +

      
        
        20
        +	t.Run("lightweight tag", func(t *testing.T) {

      
        
        21
        +		r := newTestRepo(t)

      
        
        22
        +		hash := r.commitFile("file.txt", "content", "A commit")

      
        
        23
        +		r.createTag("v1.0.0", hash)

      
        
        24
        +

      
        
        25
        +		tags, err := r.open().Tags()

      
        
        26
        +		is.Err(t, err, nil)

      
        
        27
        +		is.Equal(t, len(tags), 1)

      
        
        28
        +		is.Equal(t, tags[0].Name(), "v1.0.0")

      
        
        29
        +		is.Equal(t, tags[0].Message(), "")

      
        
        30
        +	})

      
        
        31
        +

      
        
        32
        +	t.Run("annotated tag", func(t *testing.T) {

      
        
        33
        +		tr := newTestRepo(t)

      
        
        34
        +		hash := tr.commitFile("file.txt", "content", "A commit")

      
        
        35
        +		tagTime := time.Date(2024, 6, 15, 10, 30, 0, 0, time.UTC)

      
        
        36
        +		tr.createAnnotatedTag("v2.0.0", "Release version 2.0.0\n\nThis is a major release", hash, tagTime)

      
        
        37
        +

      
        
        38
        +		tags, err := tr.open().Tags()

      
        
        39
        +		is.Err(t, err, nil)

      
        
        40
        +		is.Equal(t, len(tags), 1)

      
        
        41
        +		is.Equal(t, tags[0].Name(), "v2.0.0")

      
        
        42
        +		is.Equal(t, tags[0].Message(), "Release version 2.0.0\n\nThis is a major release\n")

      
        
        43
        +		is.Equal(t, tags[0].When(), tagTime)

      
        
        44
        +	})

      
        
        45
        +

      
        
        46
        +	t.Run("multiple tags sorted by date descending", func(t *testing.T) {

      
        
        47
        +		tr := newTestRepo(t)

      
        
        48
        +		hash := tr.commitFile("file.txt", "content", "A commit")

      
        
        49
        +

      
        
        50
        +		t1 := time.Date(2024, 1, 1, 0, 0, 0, 0, time.UTC)

      
        
        51
        +		t2 := time.Date(2024, 6, 1, 0, 0, 0, 0, time.UTC)

      
        
        52
        +		t3 := time.Date(2024, 12, 1, 0, 0, 0, 0, time.UTC)

      
        
        53
        +		tr.createAnnotatedTag("v1.0.0", "First release", hash, t1)

      
        
        54
        +		tr.createAnnotatedTag("v2.0.0", "Second release", hash, t2)

      
        
        55
        +		tr.createAnnotatedTag("v3.0.0", "Third release", hash, t3)

      
        
        56
        +

      
        
        57
        +		tags, err := tr.open().Tags()

      
        
        58
        +		is.Err(t, err, nil)

      
        
        59
        +		is.Equal(t, len(tags), 3)

      
        
        60
        +		is.Equal(t, tags[0].Name(), "v3.0.0")

      
        
        61
        +		is.Equal(t, tags[1].Name(), "v2.0.0")

      
        
        62
        +		is.Equal(t, tags[2].Name(), "v1.0.0")

      
        
        63
        +	})

      
        
        64
        +}

      
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
        +}

      
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-errors.txtar
路路路
        
        1
        +# missing repo name

      
        
        2
        +! mugit repo description

      
        
        3
        +stderr 'no name provided'

      
        
        4
        +

      
        
        5
        +# repo does not exist

      
        
        6
        +! mugit repo description nonexistent

      
        
        7
        +stderr 'failed to open repo'

      
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-new.txtar
路路路
        
        1
        +mugit repo new new-test-repo

      
        
        2
        +

      
        
        3
        +

      
        
        4
        +exists $REPOS/new-test-repo.git/HEAD

      
        
        5
        +exists $REPOS/new-test-repo.git/objects/

      
        
        6
        +exists $REPOS/new-test-repo.git/refs/

      
A testscript/cli-repo-private-errors.txtar
路路路
        
        1
        +# missing repo name

      
        
        2
        +! mugit repo private

      
        
        3
        +stderr 'no name provided'

      
        
        4
        +

      
        
        5
        +# repo does not exist

      
        
        6
        +! mugit repo private nonexistent

      
        
        7
        +stderr 'failed to open repo'

      
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-errors.txtar
路路路
        
        1
        +# missing repo name

      
        
        2
        +! mugit repo set-head

      
        
        3
        +stderr 'no name provided'

      
        
        4
        +

      
        
        5
        +# repo does not exist

      
        
        6
        +! mugit repo set-head nonexistent main

      
        
        7
        +stderr 'failed to open repo'

      
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-empty.txtar
路路路
        
        1
        +# empty repo: http clone empty repo

      
        
        2
        +

      
        
        3
        +mugit repo new emptyrepo

      
        
        4
        +git clone $MURL/emptyrepo empty-clone

      
        
        5
        +

      
        
        6
        +

      
        
        7
        +exists empty-clone/.git

      
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
        +}