1package agent
2
3import (
4 "context"
5 "os"
6 "os/exec"
7 "path/filepath"
8 "strings"
9 "testing"
10 "time"
11)
12
13// gitAvailable lets the whole suite no-op on machines without git
14// (the goreleaser cross-build is one). All these tests genuinely need
15// the git binary; the agent runtime itself never requires it.
16func gitAvailable(t *testing.T) {
17 t.Helper()
18 if _, err := exec.LookPath("git"); err != nil {
19 t.Skip("git not on PATH; skipping extension-update tests")
20 }
21}
22
23// initBareRepo creates a bare repo at path. Used as the "remote" for
24// the clone-and-pull tests.
25func initBareRepo(t *testing.T, path string) {
26 t.Helper()
27 mustRun(t, "", "git", "init", "--bare", "--initial-branch=main", path)
28}
29
30// initWorkRepo creates a working repo at path with one commit so it
31// has a real default branch. Returns the path. We commit a single
32// README so the worktree isn't empty.
33func initWorkRepo(t *testing.T, path string) {
34 t.Helper()
35 mustRun(t, "", "git", "init", "--initial-branch=main", path)
36 configRepo(t, path)
37 writeFile(t, filepath.Join(path, "README.md"), "v1\n")
38 mustRun(t, path, "git", "add", ".")
39 mustRun(t, path, "git", "commit", "-q", "-m", "init")
40}
41
42func configRepo(t *testing.T, dir string) {
43 t.Helper()
44 mustRun(t, dir, "git", "config", "user.email", "test@example.com")
45 mustRun(t, dir, "git", "config", "user.name", "Test")
46 mustRun(t, dir, "git", "config", "commit.gpgsign", "false")
47}
48
49func writeFile(t *testing.T, path, content string) {
50 t.Helper()
51 if err := os.WriteFile(path, []byte(content), 0o644); err != nil {
52 t.Fatalf("write %s: %v", path, err)
53 }
54}
55
56func mustRun(t *testing.T, dir, name string, args ...string) {
57 t.Helper()
58 cmd := exec.Command(name, args...)
59 if dir != "" {
60 cmd.Dir = dir
61 }
62 cmd.Env = append(os.Environ(), "GIT_TERMINAL_PROMPT=0")
63 out, err := cmd.CombinedOutput()
64 if err != nil {
65 t.Fatalf("%s %v: %v\n%s", name, args, err, out)
66 }
67}
68
69// makeFakeZutHome scaffolds $ZUT_HOME/extensions/ and returns the path.
70func makeFakeZutHome(t *testing.T) string {
71 t.Helper()
72 home := t.TempDir()
73 if err := os.MkdirAll(filepath.Join(home, "extensions"), 0o755); err != nil {
74 t.Fatalf("mkdir extensions: %v", err)
75 }
76 return home
77}
78
79// extWithManifest creates a directory with an extension.json so
80// updateOneExtension treats it as a real extension. content is the
81// JSON to write; pass "" for the default minimal manifest.
82func extWithManifest(t *testing.T, root, name, content string) string {
83 t.Helper()
84 dir := filepath.Join(root, "extensions", name)
85 if err := os.MkdirAll(dir, 0o755); err != nil {
86 t.Fatalf("mkdir %s: %v", dir, err)
87 }
88 if content == "" {
89 content = `{"name":"` + name + `","version":"1.0.0","exec":"./x","enabled":true}`
90 }
91 writeFile(t, filepath.Join(dir, "extension.json"), content)
92 return dir
93}
94
95// ----- tests -----
96
97func TestUpdateAllExtensions_NoExtensionsDirectory(t *testing.T) {
98 // Brand new $ZUT_HOME with no extensions/ directory at all.
99 // Must not panic and must print nothing alarming.
100 home := t.TempDir()
101 updateAllExtensions(home) // no-op
102}
103
104func TestUpdateAllExtensions_EmptyDirectory(t *testing.T) {
105 home := makeFakeZutHome(t)
106 updateAllExtensions(home) // no-op, no panic
107}
108
109func TestUpdateOneExtension_NoManifest(t *testing.T) {
110 home := makeFakeZutHome(t)
111 dir := filepath.Join(home, "extensions", "noman")
112 _ = os.MkdirAll(dir, 0o755)
113 got := updateOneExtension(dir, "noman")
114 if got != "skipped" {
115 t.Fatalf("want skipped, got %s", got)
116 }
117}
118
119func TestUpdateOneExtension_Disabled(t *testing.T) {
120 home := makeFakeZutHome(t)
121 dir := extWithManifest(t, home, "off",
122 `{"name":"off","version":"1.0.0","exec":"./x","enabled":false}`)
123 got := updateOneExtension(dir, "off")
124 if got != "skipped" {
125 t.Fatalf("want skipped, got %s", got)
126 }
127}
128
129func TestUpdateOneExtension_NotAGitCheckout(t *testing.T) {
130 home := makeFakeZutHome(t)
131 dir := extWithManifest(t, home, "plain", "")
132 got := updateOneExtension(dir, "plain")
133 if got != "skipped" {
134 t.Fatalf("want skipped, got %s", got)
135 }
136}
137
138func TestUpdateOneExtension_UpToDate(t *testing.T) {
139 gitAvailable(t)
140 tmp := t.TempDir()
141 home := makeFakeZutHome(t)
142
143 // Set up a remote and clone it as the "installed" extension.
144 remote := filepath.Join(tmp, "remote.git")
145 initBareRepo(t, remote)
146
147 source := filepath.Join(tmp, "source")
148 initWorkRepo(t, source)
149 mustRun(t, source, "git", "remote", "add", "origin", remote)
150 mustRun(t, source, "git", "push", "-u", "origin", "main")
151
152 extDir := filepath.Join(home, "extensions", "up2date")
153 mustRun(t, "", "git", "clone", "-q", remote, extDir)
154 configRepo(t, extDir)
155 // Overwrite the cloned README with a manifest in addition.
156 writeFile(t, filepath.Join(extDir, "extension.json"),
157 `{"name":"up2date","version":"1.0.0","exec":"./x","enabled":true}`)
158
159 got := updateOneExtension(extDir, "up2date")
160 if got != "up-to-date" {
161 t.Fatalf("want up-to-date, got %s", got)
162 }
163}
164
165func TestUpdateOneExtension_PullsNewCommit(t *testing.T) {
166 gitAvailable(t)
167 tmp := t.TempDir()
168 home := makeFakeZutHome(t)
169
170 remote := filepath.Join(tmp, "remote.git")
171 initBareRepo(t, remote)
172
173 source := filepath.Join(tmp, "source")
174 initWorkRepo(t, source)
175 writeFile(t, filepath.Join(source, "extension.json"),
176 `{"name":"pulls","version":"1.0.0","exec":"./x","enabled":true}`)
177 mustRun(t, source, "git", "add", "extension.json")
178 mustRun(t, source, "git", "commit", "-q", "-m", "add manifest")
179 mustRun(t, source, "git", "remote", "add", "origin", remote)
180 mustRun(t, source, "git", "push", "-u", "origin", "main")
181
182 extDir := filepath.Join(home, "extensions", "pulls")
183 mustRun(t, "", "git", "clone", "-q", remote, extDir)
184 configRepo(t, extDir)
185
186 // Push a new commit to the remote so pulling sees something new.
187 writeFile(t, filepath.Join(source, "NEW.md"), "added later\n")
188 mustRun(t, source, "git", "add", "NEW.md")
189 mustRun(t, source, "git", "commit", "-q", "-m", "add NEW")
190 mustRun(t, source, "git", "push", "origin", "main")
191
192 got := updateOneExtension(extDir, "pulls")
193 if got != "updated" {
194 t.Fatalf("want updated, got %s", got)
195 }
196 if _, err := os.Stat(filepath.Join(extDir, "NEW.md")); err != nil {
197 t.Fatalf("expected NEW.md to be pulled in: %v", err)
198 }
199}
200
201func TestUpdateOneExtension_StashesDirtyWorktree(t *testing.T) {
202 gitAvailable(t)
203 tmp := t.TempDir()
204 home := makeFakeZutHome(t)
205
206 remote := filepath.Join(tmp, "remote.git")
207 initBareRepo(t, remote)
208 source := filepath.Join(tmp, "source")
209 initWorkRepo(t, source)
210 writeFile(t, filepath.Join(source, "extension.json"),
211 `{"name":"dirty","version":"1.0.0","exec":"./x","enabled":true}`)
212 mustRun(t, source, "git", "add", "extension.json")
213 mustRun(t, source, "git", "commit", "-q", "-m", "add manifest")
214 mustRun(t, source, "git", "remote", "add", "origin", remote)
215 mustRun(t, source, "git", "push", "-u", "origin", "main")
216
217 extDir := filepath.Join(home, "extensions", "dirty")
218 mustRun(t, "", "git", "clone", "-q", remote, extDir)
219 configRepo(t, extDir)
220
221 // Simulate runtime state: an untracked file the extension wrote.
222 writeFile(t, filepath.Join(extDir, "runtime.json"), `{"state":"hi"}`)
223
224 // Advance the remote.
225 writeFile(t, filepath.Join(source, "NEW.md"), "added later\n")
226 mustRun(t, source, "git", "add", "NEW.md")
227 mustRun(t, source, "git", "commit", "-q", "-m", "add NEW")
228 mustRun(t, source, "git", "push", "origin", "main")
229
230 got := updateOneExtension(extDir, "dirty")
231 if got != "updated" {
232 t.Fatalf("want updated, got %s", got)
233 }
234 // Runtime file must still be there after the pull+stash cycle.
235 if _, err := os.Stat(filepath.Join(extDir, "runtime.json")); err != nil {
236 t.Fatalf("runtime.json was clobbered by the update: %v", err)
237 }
238 // And the pulled commit must be present too.
239 if _, err := os.Stat(filepath.Join(extDir, "NEW.md")); err != nil {
240 t.Fatalf("expected NEW.md to be pulled in: %v", err)
241 }
242}
243
244func TestUpdateOneExtension_DivergedFails(t *testing.T) {
245 gitAvailable(t)
246 tmp := t.TempDir()
247 home := makeFakeZutHome(t)
248
249 remote := filepath.Join(tmp, "remote.git")
250 initBareRepo(t, remote)
251 source := filepath.Join(tmp, "source")
252 initWorkRepo(t, source)
253 writeFile(t, filepath.Join(source, "extension.json"),
254 `{"name":"div","version":"1.0.0","exec":"./x","enabled":true}`)
255 mustRun(t, source, "git", "add", "extension.json")
256 mustRun(t, source, "git", "commit", "-q", "-m", "add manifest")
257 mustRun(t, source, "git", "remote", "add", "origin", remote)
258 mustRun(t, source, "git", "push", "-u", "origin", "main")
259
260 extDir := filepath.Join(home, "extensions", "div")
261 mustRun(t, "", "git", "clone", "-q", remote, extDir)
262 configRepo(t, extDir)
263
264 // Local diverging commit (committed, not just dirty).
265 writeFile(t, filepath.Join(extDir, "LOCAL.md"), "local commit\n")
266 mustRun(t, extDir, "git", "add", "LOCAL.md")
267 mustRun(t, extDir, "git", "commit", "-q", "-m", "local")
268
269 // Conflicting remote commit on the same path.
270 writeFile(t, filepath.Join(source, "LOCAL.md"), "remote commit\n")
271 mustRun(t, source, "git", "add", "LOCAL.md")
272 mustRun(t, source, "git", "commit", "-q", "-m", "remote")
273 mustRun(t, source, "git", "push", "origin", "main")
274
275 got := updateOneExtension(extDir, "div")
276 if got != "failed" {
277 t.Fatalf("want failed (diverged), got %s", got)
278 }
279}
280
281func TestUpdateOneExtension_BadRemoteFails(t *testing.T) {
282 gitAvailable(t)
283 tmp := t.TempDir()
284 home := makeFakeZutHome(t)
285
286 // Init a repo with an "origin" that points at a non-existent path.
287 extDir := filepath.Join(home, "extensions", "bad")
288 if err := os.MkdirAll(extDir, 0o755); err != nil {
289 t.Fatal(err)
290 }
291 mustRun(t, extDir, "git", "init", "--initial-branch=main")
292 configRepo(t, extDir)
293 writeFile(t, filepath.Join(extDir, "README.md"), "hi\n")
294 mustRun(t, extDir, "git", "add", ".")
295 mustRun(t, extDir, "git", "commit", "-q", "-m", "init")
296 writeFile(t, filepath.Join(extDir, "extension.json"),
297 `{"name":"bad","version":"1.0.0","exec":"./x","enabled":true}`)
298 mustRun(t, extDir, "git", "add", "extension.json")
299 mustRun(t, extDir, "git", "commit", "-q", "-m", "manifest")
300 mustRun(t, extDir, "git", "remote", "add", "origin",
301 filepath.Join(tmp, "does-not-exist.git"))
302
303 // Tracking branch is needed for `git pull` to even attempt fetch.
304 // Without one, `git pull` errors out with "no tracking info" which
305 // is also a perfectly valid failure for our purposes.
306 got := updateOneExtension(extDir, "bad")
307 if got != "failed" {
308 t.Fatalf("want failed, got %s", got)
309 }
310}
311
312func TestUpdateAllExtensions_MixedSet(t *testing.T) {
313 gitAvailable(t)
314 tmp := t.TempDir()
315 home := makeFakeZutHome(t)
316
317 // 1 plain non-git extension (skipped)
318 extWithManifest(t, home, "plain", "")
319 // 1 disabled
320 extWithManifest(t, home, "off",
321 `{"name":"off","version":"1.0.0","exec":"./x","enabled":false}`)
322 // 1 healthy git clone
323 remote := filepath.Join(tmp, "remote.git")
324 initBareRepo(t, remote)
325 source := filepath.Join(tmp, "source")
326 initWorkRepo(t, source)
327 writeFile(t, filepath.Join(source, "extension.json"),
328 `{"name":"ok","version":"1.0.0","exec":"./x","enabled":true}`)
329 mustRun(t, source, "git", "add", "extension.json")
330 mustRun(t, source, "git", "commit", "-q", "-m", "manifest")
331 mustRun(t, source, "git", "remote", "add", "origin", remote)
332 mustRun(t, source, "git", "push", "-u", "origin", "main")
333 extDir := filepath.Join(home, "extensions", "ok")
334 mustRun(t, "", "git", "clone", "-q", remote, extDir)
335 configRepo(t, extDir)
336
337 // Must not panic, must not propagate any error to the caller.
338 updateAllExtensions(home)
339}
340
341func TestSummariseGitError_PrefersErrorOverHint(t *testing.T) {
342 out := "hint: try git pull\nfatal: refusing to merge unrelated histories\nhint: see git-merge"
343 got := summariseGitError(out, exec.Command("false").Run())
344 if !strings.Contains(got, "fatal:") {
345 t.Fatalf("want fatal line, got %q", got)
346 }
347}
348
349func TestRunGit_HonoursContextTimeout(t *testing.T) {
350 gitAvailable(t)
351 ctx, cancel := context.WithTimeout(context.Background(), 50*time.Millisecond)
352 defer cancel()
353 // `git rev-parse HEAD` in a non-repo errors quickly; we just want
354 // to confirm we don't hang.
355 _, _ = runGit(ctx, t.TempDir(), "rev-parse", "HEAD")
356}