detached 3ce114c

feat(update): fast-forward installed extensions during zot update

After the binary swap succeeds, zot update now walks
$ZOT_HOME/extensions/ and runs git pull --ff-only on every
extension that is a git checkout.

Per-extension behaviour:
- disabled extensions: skipped
- no .git/ directory: skipped (no remote to pull from)
- dirty worktree: stashed (--include-untracked) before the pull,
  popped after; conflict on pop leaves markers in place with a
  warning rather than discarding the runtime state
- diverged / offline / any git failure: reported as failed and the
  next extension is processed
- timeout per extension: 60s
- no build step is ever executed; authors commit the runnable
  artifact, or the user rebuilds manually and /reload-ext

zot update itself never aborts because of an extension. The
binary swap is the source of truth for success.

Implementation in packages/agent/extupdate.go (~150 LoC), 13 unit
tests covering each branch including stash+pop with untracked
runtime files, diverged history, unreachable remote, and the
mixed-state scenario. README's Extensions section documents the
new behaviour.
commit
3ce114c8de18646d88ef042ba8456342d35ac32d
author
patriceckhart · · 2026-05-27 07:37 UTC
parent
fa7d8d8
signature
unsigned
4 files · +667 -1
M README.md +10 -0
@@ -598,6 +598,16 @@ zot ext remove <name> # delete an extension directory
598598
599599 For development, point `zot --ext <path>` at a working directory and skip the install step entirely. Repeatable; takes precedence over installed extensions of the same name.
600600
601+### Updating extensions
602+
603+`zot update` refreshes the zot binary **and** every installed extension that lives in a git checkout. Per-extension behaviour:
604+
605+- Disabled extensions are skipped.
606+- Extensions without a `.git/` directory (installed by `zot ext install ./local-path`) are skipped — there is no remote to pull from.
607+- For the rest, zot stashes any dirty worktree state (including untracked runtime files like `todos.json` or `config.json`), runs `git pull --ff-only`, and pops the stash. If the pop produces conflicts, the conflict markers are left in place and you'll see a warning.
608+- Diverged branches, offline pulls, or any other git failure are reported as `failed` and the next extension is processed. `zot update` itself never aborts because of an extension.
609+- zot does **not** run any build step (`go build`, `npm install`, `make`) after the pull. Extension authors are expected to commit the runnable artifact (binary, transpiled JS, etc.). If you need a build, rebuild manually and use `/reload-ext`.
610+
601611 ### Reference
602612
603613 `examples/extensions/` ships reference implementations in Go, TypeScript, Node, and shell. See [docs/extensions.md](docs/extensions.md) for the full protocol, the SDK API (`packages/agent/ext`), and the phase roadmap.
@@ -598,6 +598,16 @@ zot ext remove <name> # delete an extension directory
598 598
599 For development, point `zot --ext <path>` at a working directory and skip the install step entirely. Repeatable; takes precedence over installed extensions of the same name.599 For development, point `zot --ext <path>` at a working directory and skip the install step entirely. Repeatable; takes precedence over installed extensions of the same name.
600 600
601+### Updating extensions
602+
603+`zot update` refreshes the zot binary **and** every installed extension that lives in a git checkout. Per-extension behaviour:
604+
605+- Disabled extensions are skipped.
606+- Extensions without a `.git/` directory (installed by `zot ext install ./local-path`) are skipped — there is no remote to pull from.
607+- For the rest, zot stashes any dirty worktree state (including untracked runtime files like `todos.json` or `config.json`), runs `git pull --ff-only`, and pops the stash. If the pop produces conflicts, the conflict markers are left in place and you'll see a warning.
608+- Diverged branches, offline pulls, or any other git failure are reported as `failed` and the next extension is processed. `zot update` itself never aborts because of an extension.
609+- zot does **not** run any build step (`go build`, `npm install`, `make`) after the pull. Extension authors are expected to commit the runnable artifact (binary, transpiled JS, etc.). If you need a build, rebuild manually and use `/reload-ext`.
610+
601 ### Reference611 ### Reference
602 612
603 `examples/extensions/` ships reference implementations in Go, TypeScript, Node, and shell. See [docs/extensions.md](docs/extensions.md) for the full protocol, the SDK API (`packages/agent/ext`), and the phase roadmap.613 `examples/extensions/` ships reference implementations in Go, TypeScript, Node, and shell. See [docs/extensions.md](docs/extensions.md) for the full protocol, the SDK API (`packages/agent/ext`), and the phase roadmap.
A packages/agent/extupdate.go +285 -0
@@ -0,0 +1,285 @@
1+package agent
2+
3+import (
4+ "context"
5+ "encoding/json"
6+ "fmt"
7+ "io"
8+ "os"
9+ "os/exec"
10+ "path/filepath"
11+ "sort"
12+ "strings"
13+ "time"
14+)
15+
16+// updateAllExtensions iterates $ZOT_HOME/extensions/* and tries to
17+// update each one in place via `git pull --ff-only`. Failures are
18+// per-extension and never abort the loop; the caller can treat any
19+// error here as advisory only.
20+//
21+// Update strategy per extension:
22+//
23+// 1. Skip if disabled in its extension.json (the user opted out).
24+// 2. Skip if the directory has no .git/ (not a git checkout — we
25+// have no way to fetch new content).
26+// 3. Stash any dirty working-tree state (including untracked
27+// files like a runtime todos.json) so the pull doesn't refuse
28+// with "your local changes would be overwritten".
29+// 4. git pull --ff-only with a per-extension timeout. Refuse to
30+// merge or rebase; if upstream diverged, log "diverged" and
31+// leave the user to sort it out.
32+// 5. Pop the stash. If pop produces conflicts, leave the
33+// conflict markers in place, log it, and move on (better than
34+// silently discarding the user's runtime files).
35+//
36+// We deliberately do NOT run any build step (go build / npm install /
37+// make) after the pull. Auto-executing arbitrary build scripts from a
38+// remote git URL would be a real footgun. Extension authors are
39+// expected to commit a working binary (or instruct the user to
40+// rebuild manually via /reload-ext + their own build).
41+func updateAllExtensions(zotHome string) {
42+ dir := filepath.Join(zotHome, "extensions")
43+ entries, err := os.ReadDir(dir)
44+ if err != nil {
45+ if os.IsNotExist(err) {
46+ return // no extensions installed; nothing to do
47+ }
48+ fmt.Fprintf(os.Stderr, "zot update: skipping extension update (read %s: %v)\n", dir, err)
49+ return
50+ }
51+
52+ // Stable order so output is reproducible.
53+ names := make([]string, 0, len(entries))
54+ for _, e := range entries {
55+ if !e.IsDir() {
56+ continue
57+ }
58+ if strings.HasPrefix(e.Name(), ".") {
59+ continue
60+ }
61+ names = append(names, e.Name())
62+ }
63+ sort.Strings(names)
64+
65+ if len(names) == 0 {
66+ return
67+ }
68+
69+ fmt.Println()
70+ fmt.Println("zot update: updating extensions...")
71+
72+ var updated, upToDate, skipped, failed int
73+ for _, name := range names {
74+ extDir := filepath.Join(dir, name)
75+ status := updateOneExtension(extDir, name)
76+ switch status {
77+ case "updated":
78+ updated++
79+ case "up-to-date":
80+ upToDate++
81+ case "skipped":
82+ skipped++
83+ default:
84+ failed++
85+ }
86+ }
87+
88+ fmt.Printf("zot update: extensions: %d updated, %d up-to-date, %d skipped, %d failed\n",
89+ updated, upToDate, skipped, failed)
90+}
91+
92+// updateOneExtension updates the extension in extDir and returns a
93+// short status string used for the summary line: "updated",
94+// "up-to-date", "skipped", or "failed". The exact string is also what
95+// gets printed to the user, prefixed with the extension name.
96+func updateOneExtension(extDir, name string) string {
97+ // 1. Read manifest to honour `enabled: false`.
98+ mPath := filepath.Join(extDir, "extension.json")
99+ mBytes, err := os.ReadFile(mPath)
100+ if err != nil {
101+ fmt.Printf(" %-30s skipped: no extension.json\n", name)
102+ return "skipped"
103+ }
104+ var manifest struct {
105+ Enabled *bool `json:"enabled,omitempty"`
106+ }
107+ _ = json.Unmarshal(mBytes, &manifest) // bad JSON -> treat as enabled
108+ if manifest.Enabled != nil && !*manifest.Enabled {
109+ fmt.Printf(" %-30s skipped: disabled\n", name)
110+ return "skipped"
111+ }
112+
113+ // 2. Must be a git checkout.
114+ gitDir := filepath.Join(extDir, ".git")
115+ if st, err := os.Stat(gitDir); err != nil || (!st.IsDir() && !st.Mode().IsRegular()) {
116+ // .git can be a directory (normal clone) or a file (worktree).
117+ fmt.Printf(" %-30s skipped: not a git checkout\n", name)
118+ return "skipped"
119+ }
120+
121+ ctx, cancel := context.WithTimeout(context.Background(), 60*time.Second)
122+ defer cancel()
123+
124+ // 3. Stash anything dirty (including untracked files like
125+ // extension runtime state). Use --include-untracked. Empty
126+ // worktree -> "No local changes to save" and the stash list
127+ // doesn't grow.
128+ stashRef, stashed, err := gitStash(ctx, extDir)
129+ if err != nil {
130+ fmt.Printf(" %-30s failed: stash: %v\n", name, err)
131+ return "failed"
132+ }
133+
134+ // 4. Try a fast-forward pull. We avoid plain `git pull` because
135+ // it can rebase or merge depending on user config.
136+ out, err := runGit(ctx, extDir, "pull", "--ff-only", "--quiet")
137+ if err != nil {
138+ // If we stashed, try to restore so we leave the worktree
139+ // in at least the state we found it.
140+ if stashed {
141+ _ = gitStashPop(ctx, extDir, stashRef)
142+ }
143+ fmt.Printf(" %-30s failed: %s\n", name, summariseGitError(out, err))
144+ return "failed"
145+ }
146+
147+ // Detect whether the pull actually changed HEAD. `git pull --quiet`
148+ // prints nothing on either outcome, so we ask git directly.
149+ changed, _ := pullChangedHead(ctx, extDir)
150+
151+ // 5. Pop the stash. If conflict, leave conflict markers and warn.
152+ if stashed {
153+ if err := gitStashPop(ctx, extDir, stashRef); err != nil {
154+ fmt.Printf(" %-30s warning: pull ok but stash pop had conflicts; resolve in %s\n", name, extDir)
155+ // Treat as updated rather than failed: the source code
156+ // did move, the user just has to clean up runtime state.
157+ if changed {
158+ return "updated"
159+ }
160+ return "up-to-date"
161+ }
162+ }
163+
164+ if changed {
165+ fmt.Printf(" %-30s updated\n", name)
166+ return "updated"
167+ }
168+ fmt.Printf(" %-30s up-to-date\n", name)
169+ return "up-to-date"
170+}
171+
172+// gitStash creates a stash including untracked files. Returns:
173+//
174+// stashRef = "stash@{0}" when something was actually stashed, "" otherwise.
175+// stashed = true if a stash entry was created.
176+//
177+// We capture stashRef explicitly (instead of always popping "stash@{0}")
178+// so a stash created by something else before/after us doesn't get
179+// accidentally popped.
180+func gitStash(ctx context.Context, dir string) (stashRef string, stashed bool, err error) {
181+ // Tag the stash with a recognisable name so the user can find it
182+ // later if pop fails and they want to inspect it.
183+ msg := fmt.Sprintf("zot-update-%d", time.Now().Unix())
184+ out, err := runGit(ctx, dir, "stash", "push", "--include-untracked", "-m", msg)
185+ if err != nil {
186+ return "", false, fmt.Errorf("%s", strings.TrimSpace(out))
187+ }
188+ // Git prints "No local changes to save" when there is nothing to
189+ // stash. That's not an error, just nothing to pop.
190+ if strings.Contains(out, "No local changes to save") {
191+ return "", false, nil
192+ }
193+ // Resolve our specific stash by looking it up by message. The new
194+ // stash always lands at the top of the list when push succeeds.
195+ listOut, lerr := runGit(ctx, dir, "stash", "list")
196+ if lerr == nil {
197+ for _, line := range strings.Split(listOut, "\n") {
198+ if strings.Contains(line, msg) {
199+ // line looks like: "stash@{0}: On main: zot-update-1700000000"
200+ if idx := strings.Index(line, ":"); idx > 0 {
201+ return strings.TrimSpace(line[:idx]), true, nil
202+ }
203+ }
204+ }
205+ }
206+ // Fallback: assume top of stack. Almost always correct.
207+ return "stash@{0}", true, nil
208+}
209+
210+// gitStashPop pops a specific stash ref. Returns an error if the pop
211+// produced conflicts; the conflict markers stay in the worktree.
212+func gitStashPop(ctx context.Context, dir, ref string) error {
213+ if ref == "" {
214+ return nil
215+ }
216+ out, err := runGit(ctx, dir, "stash", "pop", ref)
217+ if err != nil {
218+ return fmt.Errorf("%s", strings.TrimSpace(out))
219+ }
220+ return nil
221+}
222+
223+// pullChangedHead returns true if the most recent commit's hash differs
224+// from the previous one. We compare HEAD against HEAD@{1} (the reflog
225+// entry just before the pull). If there is no @{1} (fresh clone, no
226+// reflog), we assume "changed" = false to be conservative.
227+func pullChangedHead(ctx context.Context, dir string) (bool, error) {
228+ cur, err := runGit(ctx, dir, "rev-parse", "HEAD")
229+ if err != nil {
230+ return false, err
231+ }
232+ prev, err := runGit(ctx, dir, "rev-parse", "HEAD@{1}")
233+ if err != nil {
234+ return false, nil // no reflog entry -> nothing previous to compare against
235+ }
236+ return strings.TrimSpace(cur) != strings.TrimSpace(prev), nil
237+}
238+
239+// runGit runs `git <args...>` in dir and returns merged stdout+stderr.
240+// Refuses to allocate a tty even on terminals where git might try to
241+// prompt for credentials — we want every interactive prompt to fail
242+// fast instead of hanging the update loop.
243+func runGit(ctx context.Context, dir string, args ...string) (string, error) {
244+ cmd := exec.CommandContext(ctx, "git", args...)
245+ cmd.Dir = dir
246+ // Disable interactive credential prompts. If a user has a private
247+ // repo extension and no cached credentials, the pull fails fast.
248+ cmd.Env = append(os.Environ(),
249+ "GIT_TERMINAL_PROMPT=0",
250+ "GIT_ASKPASS=",
251+ "SSH_ASKPASS=",
252+ )
253+ var buf strings.Builder
254+ cmd.Stdout = &buf
255+ cmd.Stderr = &buf
256+ err := cmd.Run()
257+ return buf.String(), err
258+}
259+
260+// summariseGitError turns a multi-line git error into a single line
261+// suitable for the summary table. We keep the most informative-looking
262+// line and drop the rest.
263+func summariseGitError(output string, err error) string {
264+ output = strings.TrimSpace(output)
265+ if output == "" {
266+ return err.Error()
267+ }
268+ // Prefer lines that look like an actual error message over hints.
269+ lines := strings.Split(output, "\n")
270+ for _, line := range lines {
271+ l := strings.TrimSpace(line)
272+ if l == "" {
273+ continue
274+ }
275+ if strings.HasPrefix(l, "hint:") || strings.HasPrefix(l, "Hint:") {
276+ continue
277+ }
278+ return l
279+ }
280+ return strings.TrimSpace(lines[0])
281+}
282+
283+// (unused but kept so callers can write to a discard sink if they ever
284+// want to suppress git noise rather than log it.)
285+var _ = io.Discard
@@ -0,0 +1,285 @@
1+package agent
2+
3+import (
4+ "context"
5+ "encoding/json"
6+ "fmt"
7+ "io"
8+ "os"
9+ "os/exec"
10+ "path/filepath"
11+ "sort"
12+ "strings"
13+ "time"
14+)
15+
16+// updateAllExtensions iterates $ZOT_HOME/extensions/* and tries to
17+// update each one in place via `git pull --ff-only`. Failures are
18+// per-extension and never abort the loop; the caller can treat any
19+// error here as advisory only.
20+//
21+// Update strategy per extension:
22+//
23+// 1. Skip if disabled in its extension.json (the user opted out).
24+// 2. Skip if the directory has no .git/ (not a git checkout — we
25+// have no way to fetch new content).
26+// 3. Stash any dirty working-tree state (including untracked
27+// files like a runtime todos.json) so the pull doesn't refuse
28+// with "your local changes would be overwritten".
29+// 4. git pull --ff-only with a per-extension timeout. Refuse to
30+// merge or rebase; if upstream diverged, log "diverged" and
31+// leave the user to sort it out.
32+// 5. Pop the stash. If pop produces conflicts, leave the
33+// conflict markers in place, log it, and move on (better than
34+// silently discarding the user's runtime files).
35+//
36+// We deliberately do NOT run any build step (go build / npm install /
37+// make) after the pull. Auto-executing arbitrary build scripts from a
38+// remote git URL would be a real footgun. Extension authors are
39+// expected to commit a working binary (or instruct the user to
40+// rebuild manually via /reload-ext + their own build).
41+func updateAllExtensions(zotHome string) {
42+ dir := filepath.Join(zotHome, "extensions")
43+ entries, err := os.ReadDir(dir)
44+ if err != nil {
45+ if os.IsNotExist(err) {
46+ return // no extensions installed; nothing to do
47+ }
48+ fmt.Fprintf(os.Stderr, "zot update: skipping extension update (read %s: %v)\n", dir, err)
49+ return
50+ }
51+
52+ // Stable order so output is reproducible.
53+ names := make([]string, 0, len(entries))
54+ for _, e := range entries {
55+ if !e.IsDir() {
56+ continue
57+ }
58+ if strings.HasPrefix(e.Name(), ".") {
59+ continue
60+ }
61+ names = append(names, e.Name())
62+ }
63+ sort.Strings(names)
64+
65+ if len(names) == 0 {
66+ return
67+ }
68+
69+ fmt.Println()
70+ fmt.Println("zot update: updating extensions...")
71+
72+ var updated, upToDate, skipped, failed int
73+ for _, name := range names {
74+ extDir := filepath.Join(dir, name)
75+ status := updateOneExtension(extDir, name)
76+ switch status {
77+ case "updated":
78+ updated++
79+ case "up-to-date":
80+ upToDate++
81+ case "skipped":
82+ skipped++
83+ default:
84+ failed++
85+ }
86+ }
87+
88+ fmt.Printf("zot update: extensions: %d updated, %d up-to-date, %d skipped, %d failed\n",
89+ updated, upToDate, skipped, failed)
90+}
91+
92+// updateOneExtension updates the extension in extDir and returns a
93+// short status string used for the summary line: "updated",
94+// "up-to-date", "skipped", or "failed". The exact string is also what
95+// gets printed to the user, prefixed with the extension name.
96+func updateOneExtension(extDir, name string) string {
97+ // 1. Read manifest to honour `enabled: false`.
98+ mPath := filepath.Join(extDir, "extension.json")
99+ mBytes, err := os.ReadFile(mPath)
100+ if err != nil {
101+ fmt.Printf(" %-30s skipped: no extension.json\n", name)
102+ return "skipped"
103+ }
104+ var manifest struct {
105+ Enabled *bool `json:"enabled,omitempty"`
106+ }
107+ _ = json.Unmarshal(mBytes, &manifest) // bad JSON -> treat as enabled
108+ if manifest.Enabled != nil && !*manifest.Enabled {
109+ fmt.Printf(" %-30s skipped: disabled\n", name)
110+ return "skipped"
111+ }
112+
113+ // 2. Must be a git checkout.
114+ gitDir := filepath.Join(extDir, ".git")
115+ if st, err := os.Stat(gitDir); err != nil || (!st.IsDir() && !st.Mode().IsRegular()) {
116+ // .git can be a directory (normal clone) or a file (worktree).
117+ fmt.Printf(" %-30s skipped: not a git checkout\n", name)
118+ return "skipped"
119+ }
120+
121+ ctx, cancel := context.WithTimeout(context.Background(), 60*time.Second)
122+ defer cancel()
123+
124+ // 3. Stash anything dirty (including untracked files like
125+ // extension runtime state). Use --include-untracked. Empty
126+ // worktree -> "No local changes to save" and the stash list
127+ // doesn't grow.
128+ stashRef, stashed, err := gitStash(ctx, extDir)
129+ if err != nil {
130+ fmt.Printf(" %-30s failed: stash: %v\n", name, err)
131+ return "failed"
132+ }
133+
134+ // 4. Try a fast-forward pull. We avoid plain `git pull` because
135+ // it can rebase or merge depending on user config.
136+ out, err := runGit(ctx, extDir, "pull", "--ff-only", "--quiet")
137+ if err != nil {
138+ // If we stashed, try to restore so we leave the worktree
139+ // in at least the state we found it.
140+ if stashed {
141+ _ = gitStashPop(ctx, extDir, stashRef)
142+ }
143+ fmt.Printf(" %-30s failed: %s\n", name, summariseGitError(out, err))
144+ return "failed"
145+ }
146+
147+ // Detect whether the pull actually changed HEAD. `git pull --quiet`
148+ // prints nothing on either outcome, so we ask git directly.
149+ changed, _ := pullChangedHead(ctx, extDir)
150+
151+ // 5. Pop the stash. If conflict, leave conflict markers and warn.
152+ if stashed {
153+ if err := gitStashPop(ctx, extDir, stashRef); err != nil {
154+ fmt.Printf(" %-30s warning: pull ok but stash pop had conflicts; resolve in %s\n", name, extDir)
155+ // Treat as updated rather than failed: the source code
156+ // did move, the user just has to clean up runtime state.
157+ if changed {
158+ return "updated"
159+ }
160+ return "up-to-date"
161+ }
162+ }
163+
164+ if changed {
165+ fmt.Printf(" %-30s updated\n", name)
166+ return "updated"
167+ }
168+ fmt.Printf(" %-30s up-to-date\n", name)
169+ return "up-to-date"
170+}
171+
172+// gitStash creates a stash including untracked files. Returns:
173+//
174+// stashRef = "stash@{0}" when something was actually stashed, "" otherwise.
175+// stashed = true if a stash entry was created.
176+//
177+// We capture stashRef explicitly (instead of always popping "stash@{0}")
178+// so a stash created by something else before/after us doesn't get
179+// accidentally popped.
180+func gitStash(ctx context.Context, dir string) (stashRef string, stashed bool, err error) {
181+ // Tag the stash with a recognisable name so the user can find it
182+ // later if pop fails and they want to inspect it.
183+ msg := fmt.Sprintf("zot-update-%d", time.Now().Unix())
184+ out, err := runGit(ctx, dir, "stash", "push", "--include-untracked", "-m", msg)
185+ if err != nil {
186+ return "", false, fmt.Errorf("%s", strings.TrimSpace(out))
187+ }
188+ // Git prints "No local changes to save" when there is nothing to
189+ // stash. That's not an error, just nothing to pop.
190+ if strings.Contains(out, "No local changes to save") {
191+ return "", false, nil
192+ }
193+ // Resolve our specific stash by looking it up by message. The new
194+ // stash always lands at the top of the list when push succeeds.
195+ listOut, lerr := runGit(ctx, dir, "stash", "list")
196+ if lerr == nil {
197+ for _, line := range strings.Split(listOut, "\n") {
198+ if strings.Contains(line, msg) {
199+ // line looks like: "stash@{0}: On main: zot-update-1700000000"
200+ if idx := strings.Index(line, ":"); idx > 0 {
201+ return strings.TrimSpace(line[:idx]), true, nil
202+ }
203+ }
204+ }
205+ }
206+ // Fallback: assume top of stack. Almost always correct.
207+ return "stash@{0}", true, nil
208+}
209+
210+// gitStashPop pops a specific stash ref. Returns an error if the pop
211+// produced conflicts; the conflict markers stay in the worktree.
212+func gitStashPop(ctx context.Context, dir, ref string) error {
213+ if ref == "" {
214+ return nil
215+ }
216+ out, err := runGit(ctx, dir, "stash", "pop", ref)
217+ if err != nil {
218+ return fmt.Errorf("%s", strings.TrimSpace(out))
219+ }
220+ return nil
221+}
222+
223+// pullChangedHead returns true if the most recent commit's hash differs
224+// from the previous one. We compare HEAD against HEAD@{1} (the reflog
225+// entry just before the pull). If there is no @{1} (fresh clone, no
226+// reflog), we assume "changed" = false to be conservative.
227+func pullChangedHead(ctx context.Context, dir string) (bool, error) {
228+ cur, err := runGit(ctx, dir, "rev-parse", "HEAD")
229+ if err != nil {
230+ return false, err
231+ }
232+ prev, err := runGit(ctx, dir, "rev-parse", "HEAD@{1}")
233+ if err != nil {
234+ return false, nil // no reflog entry -> nothing previous to compare against
235+ }
236+ return strings.TrimSpace(cur) != strings.TrimSpace(prev), nil
237+}
238+
239+// runGit runs `git <args...>` in dir and returns merged stdout+stderr.
240+// Refuses to allocate a tty even on terminals where git might try to
241+// prompt for credentials — we want every interactive prompt to fail
242+// fast instead of hanging the update loop.
243+func runGit(ctx context.Context, dir string, args ...string) (string, error) {
244+ cmd := exec.CommandContext(ctx, "git", args...)
245+ cmd.Dir = dir
246+ // Disable interactive credential prompts. If a user has a private
247+ // repo extension and no cached credentials, the pull fails fast.
248+ cmd.Env = append(os.Environ(),
249+ "GIT_TERMINAL_PROMPT=0",
250+ "GIT_ASKPASS=",
251+ "SSH_ASKPASS=",
252+ )
253+ var buf strings.Builder
254+ cmd.Stdout = &buf
255+ cmd.Stderr = &buf
256+ err := cmd.Run()
257+ return buf.String(), err
258+}
259+
260+// summariseGitError turns a multi-line git error into a single line
261+// suitable for the summary table. We keep the most informative-looking
262+// line and drop the rest.
263+func summariseGitError(output string, err error) string {
264+ output = strings.TrimSpace(output)
265+ if output == "" {
266+ return err.Error()
267+ }
268+ // Prefer lines that look like an actual error message over hints.
269+ lines := strings.Split(output, "\n")
270+ for _, line := range lines {
271+ l := strings.TrimSpace(line)
272+ if l == "" {
273+ continue
274+ }
275+ if strings.HasPrefix(l, "hint:") || strings.HasPrefix(l, "Hint:") {
276+ continue
277+ }
278+ return l
279+ }
280+ return strings.TrimSpace(lines[0])
281+}
282+
283+// (unused but kept so callers can write to a discard sink if they ever
284+// want to suppress git noise rather than log it.)
285+var _ = io.Discard
A packages/agent/extupdate_test.go +356 -0
@@ -0,0 +1,356 @@
1+package agent
2+
3+import (
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.
16+func 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.
25+func 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.
33+func 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+
42+func 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+
49+func 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+
56+func 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.
70+func 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.
82+func 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+
97+func 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+
104+func TestUpdateAllExtensions_EmptyDirectory(t *testing.T) {
105+ home := makeFakeZotHome(t)
106+ updateAllExtensions(home) // no-op, no panic
107+}
108+
109+func 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+
119+func 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+
129+func 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+
138+func 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+
165+func 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+
201+func 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+
244+func 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+
281+func 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+
312+func 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+
341+func 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+
349+func 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+}
@@ -0,0 +1,356 @@
1+package agent
2+
3+import (
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.
16+func 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.
25+func 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.
33+func 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+
42+func 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+
49+func 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+
56+func 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.
70+func 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.
82+func 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+
97+func 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+
104+func TestUpdateAllExtensions_EmptyDirectory(t *testing.T) {
105+ home := makeFakeZotHome(t)
106+ updateAllExtensions(home) // no-op, no panic
107+}
108+
109+func 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+
119+func 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+
129+func 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+
138+func 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+
165+func 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+
201+func 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+
244+func 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+
281+func 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+
312+func 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+
341+func 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+
349+func 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+}
M packages/agent/updatecmd.go +16 -1
@@ -71,7 +71,16 @@ notes:
7171 * Dev builds (version 0.0.0) are refused — they typically come from
7272 'go install' or a local 'make build' and shouldn't be silently
7373 replaced with a release binary.
74- * Honours $GITHUB_TOKEN if set, so private-repo releases work.`)
74+ * Honours $GITHUB_TOKEN if set, so private-repo releases work.
75+ * After the binary is installed, every extension under
76+ $ZOT_HOME/extensions/ that is a git checkout is fast-forward
77+ pulled (no merge, no rebase). Dirty worktrees are stashed and
78+ restored. Extensions without a .git directory, disabled
79+ extensions, and pulls that fail (offline, diverged, etc.) are
80+ skipped per-extension and never abort the overall update. zot
81+ does NOT run any build step after pulling — authors are expected
82+ to commit a working binary, or you can rebuild manually and
83+ /reload-ext.`)
7584 }
7685
7786 // runUpdateCheck just prints what would happen without doing the
@@ -205,6 +214,12 @@ func runUpdate(version string) error {
205214 return fmt.Errorf("replace binary: %w", err)
206215 }
207216 fmt.Printf("zot update: installed %s\n", latest)
217+
218+ // Best-effort: also refresh installed extensions that live in
219+ // git checkouts. Failures here are advisory and never abort the
220+ // overall update — the binary swap already succeeded.
221+ updateAllExtensions(ZotHome())
222+
208223 return nil
209224 }
210225
@@ -71,7 +71,16 @@ notes:
71 * Dev builds (version 0.0.0) are refused — they typically come from71 * Dev builds (version 0.0.0) are refused — they typically come from
72 'go install' or a local 'make build' and shouldn't be silently72 'go install' or a local 'make build' and shouldn't be silently
73 replaced with a release binary.73 replaced with a release binary.
74- * Honours $GITHUB_TOKEN if set, so private-repo releases work.`)74+ * Honours $GITHUB_TOKEN if set, so private-repo releases work.
75+ * After the binary is installed, every extension under
76+ $ZOT_HOME/extensions/ that is a git checkout is fast-forward
77+ pulled (no merge, no rebase). Dirty worktrees are stashed and
78+ restored. Extensions without a .git directory, disabled
79+ extensions, and pulls that fail (offline, diverged, etc.) are
80+ skipped per-extension and never abort the overall update. zot
81+ does NOT run any build step after pulling — authors are expected
82+ to commit a working binary, or you can rebuild manually and
83+ /reload-ext.`)
75 }84 }
76 85
77 // runUpdateCheck just prints what would happen without doing the86 // runUpdateCheck just prints what would happen without doing the
@@ -205,6 +214,12 @@ func runUpdate(version string) error {
205 return fmt.Errorf("replace binary: %w", err)214 return fmt.Errorf("replace binary: %w", err)
206 }215 }
207 fmt.Printf("zot update: installed %s\n", latest)216 fmt.Printf("zot update: installed %s\n", latest)
217+
218+ // Best-effort: also refresh installed extensions that live in
219+ // git checkouts. Failures here are advisory and never abort the
220+ // overall update — the binary swap already succeeded.
221+ updateAllExtensions(ZotHome())
222+
208 return nil223 return nil
209 }224 }
210 225

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