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 | |||
| 598 | 598 | ||
| 599 | 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 | 611 | ### Reference | |
| 602 | 612 | ||
| 603 | 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. | |
| @@ -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 | ### Reference | 611 | ### 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: | |||
| 71 | 71 | * Dev builds (version 0.0.0) are refused — they typically come from | |
| 72 | 72 | 'go install' or a local 'make build' and shouldn't be silently | |
| 73 | 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 | 86 | // runUpdateCheck just prints what would happen without doing the | |
| @@ -205,6 +214,12 @@ func runUpdate(version string) error { | |||
| 205 | 214 | return fmt.Errorf("replace binary: %w", err) | |
| 206 | 215 | } | |
| 207 | 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 | 223 | return nil | |
| 209 | 224 | } | |
| 210 | 225 | ||
| @@ -71,7 +71,16 @@ notes: | |||||
| 71 | * Dev builds (version 0.0.0) are refused — they typically come from | 71 | * Dev builds (version 0.0.0) are refused — they typically come from | ||
| 72 | 'go install' or a local 'make build' and shouldn't be silently | 72 | '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 the | 86 | // 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 nil | 223 | return nil | ||
| 209 | } | 224 | } | ||
| 210 | 225 | ||||