tenseleyflow/shithub / 6943eaf

Browse files

S14: git hook shim install package

Authored by mfwolffe <wolffemf@dukes.jmu.edu>
SHA
6943eaf038ed9cdd46c0ab74aa862f5070b1827e
Parents
e95a7a7
Tree
49a8e76

2 changed files

StatusFile+-
A internal/git/hooks/install.go 86 0
A internal/git/hooks/install_test.go 84 0
internal/git/hooks/install.goadded
@@ -0,0 +1,86 @@
1
+// SPDX-License-Identifier: AGPL-3.0-or-later
2
+
3
+// Package hooks owns the bare-repo hook installation contract used by
4
+// the push pipeline (S14). pre-receive and post-receive are tiny shell
5
+// shims that re-invoke shithubd. They're symlinks so a binary upgrade
6
+// doesn't require touching every repo's hooks directory.
7
+//
8
+// Why shell shims and not direct symlinks to the binary: git invokes
9
+// the hook with stdin piped and a particular cwd, and the shim lets us
10
+// preserve the env (the SHITHUB_* vars set by S12/S13) while routing
11
+// to a stable subcommand interface. The shim is generated once at
12
+// install time; we don't depend on its file path matching the binary.
13
+package hooks
14
+
15
+import (
16
+	"fmt"
17
+	"os"
18
+	"path/filepath"
19
+)
20
+
21
+// SupportedHooks is the canonical list of hooks the push pipeline owns.
22
+// Add new hook names here and they'll be installed on every repo init.
23
+var SupportedHooks = []string{"pre-receive", "post-receive"}
24
+
25
+// Install (re)installs every supported hook on the bare repo at gitDir.
26
+// shithubdPath is the absolute path to the shithubd binary the shim
27
+// should invoke. Permissions are forced to 0o755 (owner: rwx; group +
28
+// other: rx) — git refuses to run hooks lacking the executable bit.
29
+//
30
+// Install is idempotent: re-running on a repo that already has up-to-
31
+// date hooks is a no-op. If a hook file exists but doesn't match the
32
+// expected shim, it's overwritten.
33
+func Install(gitDir, shithubdPath string) error {
34
+	hooksDir := filepath.Join(gitDir, "hooks")
35
+	if err := os.MkdirAll(hooksDir, 0o755); err != nil {
36
+		return fmt.Errorf("hooks: mkdir %s: %w", hooksDir, err)
37
+	}
38
+	for _, name := range SupportedHooks {
39
+		path := filepath.Join(hooksDir, name)
40
+		body := shim(shithubdPath, name)
41
+		if err := writeAtomic(path, body, 0o755); err != nil {
42
+			return fmt.Errorf("hooks: write %s: %w", path, err)
43
+		}
44
+	}
45
+	return nil
46
+}
47
+
48
+// shim is the body of the wrapper script git executes. It exec's into
49
+// shithubd, replacing itself so signals (and the exit code) propagate
50
+// cleanly. Stdin is the pkt-line stream git passes the hook; we forward
51
+// it untouched.
52
+func shim(shithubdPath, hookName string) string {
53
+	return fmt.Sprintf(`#!/bin/sh
54
+# Generated by shithubd hooks.Install. Do not edit by hand — re-run
55
+# 'shithubd hooks reinstall' if the binary path changes.
56
+exec %q hook %s "$@"
57
+`, shithubdPath, hookName)
58
+}
59
+
60
+// writeAtomic writes body to path via a same-directory tmpfile + rename
61
+// so a partial write can never expose an unbootable hook to git.
62
+func writeAtomic(path string, body string, mode os.FileMode) error {
63
+	dir := filepath.Dir(path)
64
+	tmp, err := os.CreateTemp(dir, ".shithub-hook-*")
65
+	if err != nil {
66
+		return err
67
+	}
68
+	tmpName := tmp.Name()
69
+	defer func() {
70
+		// If rename succeeded the file is gone and Remove is a no-op error
71
+		// we can swallow.
72
+		_ = os.Remove(tmpName)
73
+	}()
74
+	if _, err := tmp.WriteString(body); err != nil {
75
+		_ = tmp.Close()
76
+		return err
77
+	}
78
+	if err := tmp.Chmod(mode); err != nil {
79
+		_ = tmp.Close()
80
+		return err
81
+	}
82
+	if err := tmp.Close(); err != nil {
83
+		return err
84
+	}
85
+	return os.Rename(tmpName, path)
86
+}
internal/git/hooks/install_test.goadded
@@ -0,0 +1,84 @@
1
+// SPDX-License-Identifier: AGPL-3.0-or-later
2
+
3
+package hooks_test
4
+
5
+import (
6
+	"os"
7
+	"path/filepath"
8
+	"strings"
9
+	"testing"
10
+
11
+	"github.com/tenseleyFlow/shithub/internal/git/hooks"
12
+)
13
+
14
+func TestInstall_WritesExecutableShim(t *testing.T) {
15
+	t.Parallel()
16
+	gitDir := t.TempDir()
17
+	bin := "/usr/local/bin/shithubd"
18
+
19
+	if err := hooks.Install(gitDir, bin); err != nil {
20
+		t.Fatalf("Install: %v", err)
21
+	}
22
+
23
+	for _, name := range hooks.SupportedHooks {
24
+		path := filepath.Join(gitDir, "hooks", name)
25
+		st, err := os.Stat(path)
26
+		if err != nil {
27
+			t.Fatalf("stat %s: %v", path, err)
28
+		}
29
+		if st.Mode().Perm()&0o111 == 0 {
30
+			t.Errorf("%s: not executable, mode = %v", path, st.Mode())
31
+		}
32
+		body, err := os.ReadFile(path)
33
+		if err != nil {
34
+			t.Fatalf("read %s: %v", path, err)
35
+		}
36
+		s := string(body)
37
+		if !strings.Contains(s, "#!/bin/sh") {
38
+			t.Errorf("%s: missing shebang", path)
39
+		}
40
+		if !strings.Contains(s, "hook "+name) {
41
+			t.Errorf("%s: missing 'hook %s' subcommand reference", path, name)
42
+		}
43
+		if !strings.Contains(s, bin) {
44
+			t.Errorf("%s: missing binary path %q", path, bin)
45
+		}
46
+	}
47
+}
48
+
49
+func TestInstall_Idempotent(t *testing.T) {
50
+	t.Parallel()
51
+	gitDir := t.TempDir()
52
+	bin := "/usr/local/bin/shithubd"
53
+
54
+	if err := hooks.Install(gitDir, bin); err != nil {
55
+		t.Fatalf("first Install: %v", err)
56
+	}
57
+	if err := hooks.Install(gitDir, bin); err != nil {
58
+		t.Fatalf("second Install: %v", err)
59
+	}
60
+}
61
+
62
+func TestInstall_OverwritesStale(t *testing.T) {
63
+	t.Parallel()
64
+	gitDir := t.TempDir()
65
+	hooksDir := filepath.Join(gitDir, "hooks")
66
+	if err := os.MkdirAll(hooksDir, 0o755); err != nil {
67
+		t.Fatal(err)
68
+	}
69
+	stale := filepath.Join(hooksDir, "pre-receive")
70
+	if err := os.WriteFile(stale, []byte("#!/bin/sh\nold contents\n"), 0o755); err != nil {
71
+		t.Fatal(err)
72
+	}
73
+
74
+	if err := hooks.Install(gitDir, "/usr/local/bin/shithubd-new"); err != nil {
75
+		t.Fatalf("Install: %v", err)
76
+	}
77
+	body, err := os.ReadFile(stale)
78
+	if err != nil {
79
+		t.Fatal(err)
80
+	}
81
+	if !strings.Contains(string(body), "shithubd-new") {
82
+		t.Errorf("stale hook not overwritten: %q", string(body))
83
+	}
84
+}