1package agent
2
3import (
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).
41func 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.
96func 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.
180func 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.
212func 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.
227func 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.
243func 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.
263func 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.)
285var _ = io.Discard