v0.2.28 4685a30

extupdate_test.go RawHistoryFolder

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// makeFakeZotHome scaffolds $ZOT_HOME/extensions/ and returns the path.
70func makeFakeZotHome(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 $ZOT_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 := makeFakeZotHome(t)
106	updateAllExtensions(home) // no-op, no panic
107}
108
109func TestUpdateOneExtension_NoManifest(t *testing.T) {
110	home := makeFakeZotHome(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 := makeFakeZotHome(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 := makeFakeZotHome(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 := makeFakeZotHome(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 := makeFakeZotHome(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 := makeFakeZotHome(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 := makeFakeZotHome(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 := makeFakeZotHome(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 := makeFakeZotHome(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}

Keyboard shortcuts

Single-key shortcuts are on. ctrl k and esc work whatever this says.

ctrl k :
go to a section, branch, tag, file or repository
?
this list
j k
move the row cursor down / up
enter
open the row under the cursor; on Files, a directory opens or closes in place
↑ ↓ ← → home end
in the file tree (focus on a row): previous / next row, close / open a directory or go to its parent / first entry, first / last row. They never open a file
l h
on Files: open a directory or a file / close a directory or go up to its parent, then to the parent page. Elsewhere: into the side panel (on a merge request, its files) / back to the content; there j k move between its controls
g g G
first / last row
/
filter the list; on Files, find a file anywhere in the repository (esc clears, enter opens the best match)
b
switch branch or tag
g s g f g c g b g m g i g r g a
tabs: summary, files, commits, branches & tags, merge requests, issues, releases, actions (when available)
g h
home
w
wide mode: use the whole width of the screen, or not (remembered)
y
copy a permalink pinned to the commit
d u x
diff: split / unified / collapse or expand all
e E
diff: the whole file in view / every file, with the lines the hunks hide
n
merge request: next note in the diff
esc
close a dialog, leave a field

Branches 1

Tags 476

↑↓ moveenter openesc close