6 files changed,
167 insertions(+),
0 deletions(-)
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