all repos

mugit @ 3d4f6c6

🐮 git server that your cow will love
6 files changed, 167 insertions(+), 0 deletions(-)
setup per repo hooks
Author: Oleksandr Smirnov olexsmir@gmail.com
Committed at: 2026-05-14 23:22:04 +0300
Authored at: 2026-05-05 17:18:01 +0300
Change ID: vxxpwloxnqwoymokmukkrkztzpkuytwt
Parent: c543efd
M CHANGELOG.md
···
        17
        17
         - Show remote urls and mirroring data on empty repos.

      
        18
        18
         - Improved markdown README rendering with better typography, code blocks, callouts, and dark mode support.

      
        19
        19
         - Mirror status shows last sync time(when changes were fetched) and last checked time(when checked, even without changes).

      
        
        20
        +- Run `pre-receive`, `update`, `post-receive`, `post-update` hooks.

      
        20
        21
         - Accept gzip-encoded HTTP requests for `git upload-pack`.

      
        21
        22
         - **ssh:**

      
        22
        23
           - Support `git-upload-archive` over SSH.

      
M README.md
···
        182
        182
         mugit repo sync myproject

      
        183
        183
         ```

      
        184
        184
         

      
        
        185
        +## Per-repo hooks

      
        
        186
        +

      
        
        187
        +mugit creates these server-side hooks per repository: `pre-receive`, `update`, `post-receive`, `post-update`.

      
        
        188
        +

      
        
        189
        +Each hook delegates to executable scripts in: `<repo>.git/hooks/<hook>.d/`

      
        
        190
        +

      
        
        191
        +<details>

      
        
        192
        +<summary>Example: golangci-lint as a pre-receive hook</summary>

      
        
        193
        +```bash

      
        
        194
        +# file: `<repo>.git/hooks/pre-receive.d/golangci-lint.sh`:

      
        
        195
        +#!/bin/sh

      
        
        196
        +set -eu

      
        
        197
        +

      
        
        198
        +while read oldrev newrev refname; do

      
        
        199
        +  tmpdir=$(mktemp -d)

      
        
        200
        +  trap 'rm -rf "$tmpdir"' EXIT INT TERM

      
        
        201
        +

      
        
        202
        +  git archive "$newrev" | tar -xC "$tmpdir"

      
        
        203
        +

      
        
        204
        +  if ! (cd "$tmpdir" && golangci-lint run ./...); then

      
        
        205
        +    echo "golangci-lint failed for $refname — push rejected" >&2

      
        
        206
        +    exit 1

      
        
        207
        +  fi

      
        
        208
        +done

      
        
        209
        +```

      
        
        210
        +</details>

      
        
        211
        +

      
        185
        212
         ## License

      
        186
        213
         

      
        187
        214
         mugit is licensed under the MIT License.

      
A internal/git/hooks.go
···
        
        1
        +package git

      
        
        2
        +

      
        
        3
        +import (

      
        
        4
        +	"fmt"

      
        
        5
        +	"os"

      
        
        6
        +	"os/exec"

      
        
        7
        +	"path/filepath"

      
        
        8
        +)

      
        
        9
        +

      
        
        10
        +const hookDelegateScriptBody = `# AUTO GENERATED BY MUGIT, DO NOT MODIFY

      
        
        11
        +data=$(cat)

      
        
        12
        +exitcodes=""

      
        
        13
        +hookname=$(basename "$0")

      
        
        14
        +git_dir="${GIT_DIR:-$(dirname "$0")/..}"

      
        
        15
        +

      
        
        16
        +for hook in "${git_dir}/hooks/${hookname}.d"/*; do

      
        
        17
        +  test -x "${hook}" && test -f "${hook}" || continue

      
        
        18
        +  echo "${data}" | "${hook}" "$@"

      
        
        19
        +  exitcodes="${exitcodes} $?"

      
        
        20
        +done

      
        
        21
        +

      
        
        22
        +for i in ${exitcodes}; do

      
        
        23
        +  [ ${i} -eq 0 ] || exit ${i}

      
        
        24
        +done

      
        
        25
        +`

      
        
        26
        +

      
        
        27
        +var serverHookNames = []string{"pre-receive", "post-receive", "post-update", "update"}

      
        
        28
        +

      
        
        29
        +func hookDelegateScript() (string, error) {

      
        
        30
        +	bashPath, err := exec.LookPath("bash")

      
        
        31
        +	if err != nil {

      
        
        32
        +		return "", fmt.Errorf("failed to locate bash: %w", err)

      
        
        33
        +	}

      
        
        34
        +	return fmt.Sprintf("#!%s\n%s", bashPath, hookDelegateScriptBody), nil

      
        
        35
        +}

      
        
        36
        +

      
        
        37
        +// SetupHooks installs default server-side hook delegates for this repository.

      
        
        38
        +func (g *Repo) SetupHooks() error {

      
        
        39
        +	delegateScript, err := hookDelegateScript()

      
        
        40
        +	if err != nil {

      
        
        41
        +		return err

      
        
        42
        +	}

      
        
        43
        +

      
        
        44
        +	for _, hook := range serverHookNames {

      
        
        45
        +		hookDir := filepath.Join(g.path, "hooks", hook+".d")

      
        
        46
        +		if err := os.MkdirAll(hookDir, 0o755); err != nil {

      
        
        47
        +			return fmt.Errorf("failed to create %s: %w", hookDir, err)

      
        
        48
        +		}

      
        
        49
        +

      
        
        50
        +		delegatePath := filepath.Join(g.path, "hooks", hook)

      
        
        51
        +		if err := os.WriteFile(delegatePath, []byte(delegateScript), 0o755); err != nil {

      
        
        52
        +			return fmt.Errorf("failed to write hook delegate %s: %w", delegatePath, err)

      
        
        53
        +		}

      
        
        54
        +	}

      
        
        55
        +

      
        
        56
        +	return nil

      
        
        57
        +}

      
A internal/git/hooks_test.go
···
        
        1
        +package git

      
        
        2
        +

      
        
        3
        +import (

      
        
        4
        +	"bytes"

      
        
        5
        +	"os"

      
        
        6
        +	"path/filepath"

      
        
        7
        +	"strings"

      
        
        8
        +	"testing"

      
        
        9
        +

      
        
        10
        +	"olexsmir.xyz/x/is"

      
        
        11
        +)

      
        
        12
        +

      
        
        13
        +func TestRepo_SetupHooks(t *testing.T) {

      
        
        14
        +	t.Run("creates hook delegates and hook directories", func(t *testing.T) {

      
        
        15
        +		repoPath := filepath.Join(t.TempDir(), "hooks.git")

      
        
        16
        +		is.Err(t, Init(repoPath), nil)

      
        
        17
        +

      
        
        18
        +		for _, hook := range serverHookNames {

      
        
        19
        +			delegatePath := filepath.Join(repoPath, "hooks", hook)

      
        
        20
        +			info, err := os.Stat(delegatePath)

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

      
        
        22
        +			is.Equal(t, info.Mode()&0o111 != 0, true)

      
        
        23
        +

      
        
        24
        +			hookDir := filepath.Join(repoPath, "hooks", hook+".d")

      
        
        25
        +			dirInfo, err := os.Stat(hookDir)

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

      
        
        27
        +			is.Equal(t, dirInfo.IsDir(), true)

      
        
        28
        +		}

      
        
        29
        +	})

      
        
        30
        +

      
        
        31
        +	t.Run("is idempotent", func(t *testing.T) {

      
        
        32
        +		repoPath := filepath.Join(t.TempDir(), "hooks.git")

      
        
        33
        +		is.Err(t, Init(repoPath), nil)

      
        
        34
        +

      
        
        35
        +		repo, err := Open(repoPath, "")

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

      
        
        37
        +

      
        
        38
        +		is.Err(t, repo.SetupHooks(), nil)

      
        
        39
        +		is.Err(t, repo.SetupHooks(), nil)

      
        
        40
        +	})

      
        
        41
        +

      
        
        42
        +	t.Run("keeps custom scripts in hook directories", func(t *testing.T) {

      
        
        43
        +		repoPath := filepath.Join(t.TempDir(), "hooks.git")

      
        
        44
        +		is.Err(t, Init(repoPath), nil)

      
        
        45
        +

      
        
        46
        +		customHook := filepath.Join(repoPath, "hooks", "pre-receive.d", "90-custom.sh")

      
        
        47
        +		is.Err(t, os.WriteFile(customHook, []byte("#!/bin/sh\necho ok\n"), 0o755), nil)

      
        
        48
        +

      
        
        49
        +		repo, err := Open(repoPath, "")

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

      
        
        51
        +		is.Err(t, repo.SetupHooks(), nil)

      
        
        52
        +

      
        
        53
        +		data, err := os.ReadFile(customHook)

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

      
        
        55
        +		is.Equal(t, string(data), "#!/bin/sh\necho ok\n")

      
        
        56
        +	})

      
        
        57
        +

      
        
        58
        +	t.Run("delegate forwards stdin and args to hook scripts", func(t *testing.T) {

      
        
        59
        +		repoPath := filepath.Join(t.TempDir(), "hooks.git")

      
        
        60
        +		is.Err(t, Init(repoPath), nil)

      
        
        61
        +

      
        
        62
        +		delegatePath := filepath.Join(repoPath, "hooks", "update")

      
        
        63
        +		delegate, err := os.ReadFile(delegatePath)

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

      
        
        65
        +		firstLine, _, _ := strings.Cut(string(delegate), "\n")

      
        
        66
        +		is.Equal(t, strings.HasPrefix(firstLine, "#!"), true)

      
        
        67
        +		is.Equal(t, strings.Contains(firstLine, "bash"), true)

      
        
        68
        +		is.Equal(t, strings.Contains(firstLine, "/usr/bin/env"), false)

      
        
        69
        +		is.Equal(t, bytes.Contains(delegate, []byte(`echo "${data}" | "${hook}" "$@"`)), true)

      
        
        70
        +	})

      
        
        71
        +}

      
M internal/git/repo.go
···
        92
        92
         	if _, err := git.PlainInit(path, true); err != nil {

      
        93
        93
         		return fmt.Errorf("failed to initialize repo: %w", err)

      
        94
        94
         	}

      
        
        95
        +	if err := (&Repo{path: path}).SetupHooks(); err != nil {

      
        
        96
        +		return fmt.Errorf("failed to setup hooks: %w", err)

      
        
        97
        +	}

      
        95
        98
         	return nil

      
        96
        99
         }

      
        97
        100
         

      
M testscript/cli-repo-new.txtar
···
        4
        4
         exists $REPOS/new-test-repo.git/HEAD

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

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

      
        
        7
        +exists $REPOS/new-test-repo.git/hooks/pre-receive

      
        
        8
        +exists $REPOS/new-test-repo.git/hooks/update

      
        
        9
        +exists $REPOS/new-test-repo.git/hooks/post-receive

      
        
        10
        +exists $REPOS/new-test-repo.git/hooks/post-update

      
        
        11
        +exists $REPOS/new-test-repo.git/hooks/pre-receive.d/

      
        
        12
        +exists $REPOS/new-test-repo.git/hooks/update.d/

      
        
        13
        +exists $REPOS/new-test-repo.git/hooks/post-receive.d/

      
        
        14
        +exists $REPOS/new-test-repo.git/hooks/post-update.d/

      
        7
        15
         

      
        8
        16
         

      
        9
        17
         # missing repo name