v0.2.41 1a24a20

extupdate.go RawHistoryFolder

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

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