summaryrefslogtreecommitdiffhomepage
path: root/misc/git_hook/githook/install.go
blob: 3c08daf8d7e6a73cfe6d82b09f957a949f1fcfe0 (plain)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
// Copyright (c) Tailscale Inc & contributors
// SPDX-License-Identifier: BSD-3-Clause

package githook

import (
	"fmt"
	"io"
	"os"
	"os/exec"
	"path/filepath"
	"regexp"
	"strings"
)

// Install writes the launcher to .git/hooks/ts-git-hook and runs it
// once with "version", bootstrapping the binary build and per-hook
// wrappers. Called from each repo's misc/install-git-hooks.go.
func Install() error {
	hookDir, err := findHookDir()
	if err != nil {
		return err
	}
	target := filepath.Join(hookDir, "ts-git-hook")
	if err := writeLauncher(target); err != nil {
		return err
	}

	// The launcher execs the binary with our arg at the end; we pass
	// "version" only to trigger the rebuild-if-stale path, and discard
	// its stdout so the version string doesn't leak to the caller.
	cmd := exec.Command(target, "version")
	cmd.Stdout = io.Discard
	cmd.Stderr = os.Stderr
	if err := cmd.Run(); err != nil {
		return fmt.Errorf("initial hook setup failed: %v", err)
	}
	return nil
}

// WriteHooks writes the launcher to .git/hooks/ts-git-hook and a wrapper
// for each name in hooks to .git/hooks/<name>. Stale wrappers from
// prior versions (ours, but no longer in hooks) are removed. If a path
// we are about to write exists and is not one of our wrappers,
// WriteHooks aborts with an error rather than clobber the user's hook.
// Called by the binary's "install" handler (after a rebuild) and by
// Install (initial setup).
func WriteHooks(hooks []string) error {
	hookDir, err := findHookDir()
	if err != nil {
		return err
	}
	if err := writeLauncher(filepath.Join(hookDir, "ts-git-hook")); err != nil {
		return err
	}
	want := make(map[string]bool, len(hooks))
	for _, h := range hooks {
		want[h] = true
	}
	entries, err := os.ReadDir(hookDir)
	if err != nil {
		return fmt.Errorf("reading hooks dir: %v", err)
	}
	for _, e := range entries {
		if e.IsDir() {
			continue
		}
		name := e.Name()
		path := filepath.Join(hookDir, name)
		mine, err := isOurWrapper(path)
		if err != nil {
			return fmt.Errorf("inspecting %s: %v", path, err)
		}
		switch {
		case want[name] && !mine:
			return fmt.Errorf("%s exists and is not a ts-git-hook wrapper; "+
				"move your hook to %s.local (it will be chained after the wrapper) or delete it, then re-run: ./tool/go run ./misc/install-git-hooks.go",
				path, name)
		case !want[name] && mine:
			// Stale wrapper from a prior version (e.g. a hook we used
			// to install but no longer do).
			if err := os.Remove(path); err != nil {
				return fmt.Errorf("removing stale wrapper %s: %v", name, err)
			}
		}
	}
	for _, h := range hooks {
		content := fmt.Sprintf(wrapperScript, h)
		if err := os.WriteFile(filepath.Join(hookDir, h), []byte(content), 0755); err != nil {
			return fmt.Errorf("writing wrapper for %s: %v", h, err)
		}
	}
	return nil
}

// isOurWrapper reports whether path is a hook wrapper written by us
// (in any historical format). Files we will never own (the launcher
// itself, user-chained .local hooks, git's .sample examples) return
// false unconditionally and are not read. An I/O error other than
// "not found" is returned to the caller; a missing file is not an
// error.
func isOurWrapper(path string) (bool, error) {
	name := filepath.Base(path)
	if name == "ts-git-hook" ||
		strings.HasSuffix(name, ".local") ||
		strings.HasSuffix(name, ".sample") {
		return false, nil
	}
	b, err := os.ReadFile(path)
	if os.IsNotExist(err) {
		return false, nil
	}
	if err != nil {
		return false, err
	}
	return wrapperRE.Match(b), nil
}

// writeLauncher writes the embedded launcher to target via atomic rename,
// so a currently-running launcher keeps reading its old inode.
func writeLauncher(target string) error {
	dir, name := filepath.Split(target)
	f, err := os.CreateTemp(dir, name+".*")
	if err != nil {
		return fmt.Errorf("creating temp launcher: %v", err)
	}
	tmp := f.Name()
	if _, err := f.Write(Launcher); err != nil {
		f.Close()
		os.Remove(tmp)
		return fmt.Errorf("writing temp launcher: %v", err)
	}
	if err := f.Close(); err != nil {
		os.Remove(tmp)
		return err
	}
	if err := os.Chmod(tmp, 0755); err != nil {
		os.Remove(tmp)
		return err
	}
	if err := os.Rename(tmp, target); err != nil {
		os.Remove(tmp)
		return fmt.Errorf("installing launcher: %v", err)
	}
	return nil
}

func findHookDir() (string, error) {
	out, err := exec.Command("git", "rev-parse", "--git-path", "hooks").CombinedOutput()
	if err != nil {
		return "", fmt.Errorf("finding hooks dir: %v, %s", err, out)
	}
	hookDir, err := filepath.Abs(strings.TrimSpace(string(out)))
	if err != nil {
		return "", err
	}
	fi, err := os.Stat(hookDir)
	if err != nil {
		return "", fmt.Errorf("checking hooks dir: %v", err)
	}
	if !fi.IsDir() {
		return "", fmt.Errorf("%s is not a directory", hookDir)
	}
	return hookDir, nil
}

const wrapperScript = `#!/usr/bin/env bash
exec "$(dirname "${BASH_SOURCE[0]}")/ts-git-hook" %s "$@"
`

// wrapperRE matches every historical shape of wrapperScript: a tiny
// bash script that execs a sibling ts-git-hook with a single hook-name
// argument. The inner quoting of ${BASH_SOURCE[0]} changed between
// versions, hence the "?s.
var wrapperRE = regexp.MustCompile(
	`\A#!/usr/bin/env bash\nexec "\$\(dirname "?\$\{BASH_SOURCE\[0\]\}"?\)/ts-git-hook" [\w-]+ "\$@"\n?\z`,
)