5 files changed,
0 insertions(+),
166 deletions(-)
Author:
Oleksandr Smirnov
olexsmir@gmail.com
Committed at:
2026-06-10 11:50:20 +0300
Authored at:
2026-05-31 13:11:11 +0300
Change ID:
pksurstsyoursrqlwqkzrmklssnrluko
Parent:
f492c42
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 - 212 185 ## License 213 186 214 187 mugit is licensed under the MIT License.
D
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 -}
D
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 - } 98 95 return nil 99 96 } 100 97
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/ 15 7 16 8 17 9 # missing repo name