v0.2.76 9559c57

zotfile.go RawHistoryFolder

1package agent
2
3import (
4	"archive/tar"
5	"bytes"
6	"compress/gzip"
7	"crypto/sha256"
8	"encoding/hex"
9	"encoding/json"
10	"fmt"
11	"io"
12	"os"
13	"os/exec"
14	"path/filepath"
15	"runtime"
16	"sort"
17	"strings"
18	"time"
19
20	"github.com/klauspost/compress/zstd"
21	"github.com/patriceckhart/zot/packages/agent/tools"
22	"github.com/patriceckhart/zot/packages/provider"
23	"github.com/patriceckhart/zot/packages/provider/auth"
24	"golang.org/x/term"
25)
26
27type ZotfileManifest struct {
28	Zotfile     int    `json:"zotfile"`
29	Name        string `json:"name"`
30	Version     string `json:"version"`
31	Description string `json:"description"`
32	License     string `json:"license"`
33	Runtime     struct {
34		MinZot string `json:"min_zot"`
35	} `json:"runtime"`
36	Model struct {
37		Requires   []string `json:"requires"`
38		MinContext int      `json:"min_context"`
39		Preferred  []string `json:"preferred"`
40		MinTier    string   `json:"min_tier"`
41	} `json:"model"`
42	Permissions  tools.PermissionSet `json:"permissions"`
43	Requirements struct {
44		Bin []string `json:"bin"`
45		OS  []string `json:"os"`
46	} `json:"requirements"`
47	Entry struct {
48		Greeting      string  `json:"greeting"`
49		DefaultPrompt *string `json:"default_prompt"`
50	} `json:"entry"`
51	ReplaceSystemPrompt bool `json:"replace_system_prompt"`
52}
53
54type zotfileLoaded struct {
55	Dir      string
56	Temp     bool
57	Digest   string
58	Manifest ZotfileManifest
59}
60
61func runZotfileCommand(rawArgs []string, version string) (bool, error) {
62	if len(rawArgs) == 0 {
63		return false, nil
64	}
65	switch rawArgs[0] {
66	case "pack":
67		dir := "."
68		out := ""
69		if len(rawArgs) > 1 {
70			dir = rawArgs[1]
71		}
72		if len(rawArgs) > 2 {
73			out = rawArgs[2]
74		}
75		return true, zotPack(dir, out)
76	case "inspect":
77		if len(rawArgs) < 2 {
78			return true, fmt.Errorf("zot inspect requires a .zot file or directory")
79		}
80		return true, zotInspect(rawArgs[1])
81	case "verify":
82		if len(rawArgs) < 2 {
83			return true, fmt.Errorf("zot verify requires a .zot file or directory")
84		}
85		zf, cleanup, err := loadZotfile(rawArgs[1])
86		if cleanup != nil {
87			defer cleanup()
88		}
89		if err != nil {
90			return true, err
91		}
92		fmt.Printf("ok  digest sha256:%s\n", zf.Digest)
93		return true, nil
94	case "run":
95		if len(rawArgs) < 2 {
96			return true, fmt.Errorf("zot run requires a .zot file or directory")
97		}
98		ref := rawArgs[1]
99		rest := rawArgs[2:]
100		args, err := ParseArgs(rest)
101		if err != nil {
102			PrintHelp(version)
103			return true, err
104		}
105		return true, runLocalZotfile(ref, args, version)
106	default:
107		return false, nil
108	}
109}
110
111func runLocalZotfile(ref string, args Args, version string) error {
112	prepareRuntimeCatalog()
113	zf, cleanup, err := loadZotfile(ref)
114	if cleanup != nil {
115		defer cleanup()
116	}
117	if err != nil {
118		return err
119	}
120	if err := checkZotfileRequirements(zf, version); err != nil {
121		return err
122	}
123	agentData := filepath.Join(ZotHome(), "agents", safeAgentName(zf.Manifest.Name), "data")
124	if err := os.MkdirAll(agentData, 0o755); err != nil {
125		return err
126	}
127	perms := zf.Manifest.Permissions.Expand(args.CWD, agentData)
128	if err := consentZotfile(zf, perms); err != nil {
129		return err
130	}
131	agentPath := filepath.Join(zf.Dir, "AGENT.md")
132	agentPrompt, err := os.ReadFile(agentPath)
133	if err != nil {
134		return fmt.Errorf("read AGENT.md: %w", err)
135	}
136	if err := applyZotfileModelRequirements(&args, zf.Manifest); err != nil {
137		return err
138	}
139	if zf.Manifest.ReplaceSystemPrompt {
140		args.SystemPrompt = strings.TrimSpace(string(agentPrompt))
141	} else {
142		args.AppendSystemPrompt = append(args.AppendSystemPrompt, strings.TrimSpace(string(agentPrompt)))
143	}
144	if args.Prompt == "" && zf.Manifest.Entry.DefaultPrompt != nil {
145		args.Prompt = *zf.Manifest.Entry.DefaultPrompt
146	}
147	if dirExists(filepath.Join(zf.Dir, "skills")) {
148		old := os.Getenv("ZOT_AGENT_SKILLS")
149		v := filepath.Join(zf.Dir, "skills")
150		if old != "" {
151			v += string(os.PathListSeparator) + old
152		}
153		_ = os.Setenv("ZOT_AGENT_SKILLS", v)
154		defer os.Setenv("ZOT_AGENT_SKILLS", old)
155	}
156	args.AgentName = zf.Manifest.Name
157	args.AgentDataDir = agentData
158	args.PermissionSet = &perms
159	return runWithArgs(args, version)
160}
161
162func zotInspect(ref string) error {
163	zf, cleanup, err := loadZotfile(ref)
164	if cleanup != nil {
165		defer cleanup()
166	}
167	if err != nil {
168		return err
169	}
170	m := zf.Manifest
171	fmt.Printf("name: %s\nversion: %s\ndescription: %s\ndigest: sha256:%s\n", m.Name, m.Version, m.Description, zf.Digest)
172	fmt.Println("\npermissions:")
173	fmt.Print(permissionSummary(m.Permissions))
174	fmt.Println("\nfiles:")
175	return filepath.WalkDir(zf.Dir, func(path string, d os.DirEntry, err error) error {
176		if err != nil || path == zf.Dir {
177			return err
178		}
179		rel, _ := filepath.Rel(zf.Dir, path)
180		if d.IsDir() {
181			rel += "/"
182		}
183		fmt.Println("  " + filepath.ToSlash(rel))
184		return nil
185	})
186}
187
188func applyZotfileModelRequirements(args *Args, m ZotfileManifest) error {
189	minCtx := m.Model.MinContext
190	requires := map[string]bool{}
191	for _, capability := range m.Model.Requires {
192		requires[strings.ToLower(strings.TrimSpace(capability))] = true
193	}
194	for capability := range requires {
195		if capability != "tools" && capability != "vision" && capability != "reasoning" {
196			return fmt.Errorf("unsupported model requirement %q", capability)
197		}
198	}
199	if m.Model.MinTier != "" {
200		return fmt.Errorf("model.min_tier is not supported by this zot version")
201	}
202	compatible := func(model provider.Model) bool {
203		if model.ContextWindow < minCtx {
204			return false
205		}
206		if requires["reasoning"] && !model.Reasoning {
207			return false
208		}
209		// Every model exposed by the current catalog supports text and tools.
210		// Vision support is not represented in Model yet, so fail closed.
211		return !requires["vision"]
212	}
213	if minCtx <= 0 && len(requires) == 0 {
214		return nil
215	}
216	if args.Model != "" {
217		model, err := provider.FindModel(args.Provider, args.Model)
218		if err != nil {
219			model, err = provider.FindModel("", args.Model)
220		}
221		if err != nil {
222			return nil
223		}
224		if !compatible(model) {
225			return fmt.Errorf("model %s does not satisfy the agent requirements", model.ID)
226		}
227		return nil
228	}
229	cfg, _ := LoadConfig()
230	if cfg.Model != "" {
231		model, err := provider.FindModel(cfg.Provider, cfg.Model)
232		if err == nil && compatible(model) {
233			return nil
234		}
235	}
236	for _, id := range m.Model.Preferred {
237		model, err := provider.FindModel("", id)
238		if err == nil && compatible(model) {
239			args.Provider = model.Provider
240			args.Model = model.ID
241			return nil
242		}
243	}
244	for _, model := range provider.Active() {
245		if compatible(model) {
246			args.Provider = model.Provider
247			args.Model = model.ID
248			return nil
249		}
250	}
251	return fmt.Errorf("no catalog model satisfies the agent requirements")
252}
253
254func prepareRuntimeCatalog() {
255	LoadCachedModels()
256	LoadUserModels()
257	if cps := provider.CustomProviders(); len(cps) > 0 {
258		var names []string
259		for name := range cps {
260			if !isBuiltinProvider(name) {
261				names = append(names, name)
262			}
263		}
264		auth.SetExtraAPIKeyProviders(names)
265	}
266	ValidateAndRepairConfig()
267	RefreshModelsAsync()
268}
269
270func zotPack(dir, out string) error {
271	abs, err := filepath.Abs(dir)
272	if err != nil {
273		return err
274	}
275	if _, err := readZotManifest(abs); err != nil {
276		return err
277	}
278	if err := validateZotfileDir(abs); err != nil {
279		return err
280	}
281	if out == "" {
282		m, _ := readZotManifest(abs)
283		base := safeAgentName(m.Name)
284		if base == "" {
285			base = filepath.Base(abs)
286		}
287		out = base + ".zot"
288	}
289	if filepath.Ext(out) != ".zot" {
290		out += ".zot"
291	}
292	outAbs, err := filepath.Abs(out)
293	if err != nil {
294		return err
295	}
296	f, err := os.Create(out)
297	if err != nil {
298		return err
299	}
300	defer f.Close()
301	h := sha256.New()
302	mw := io.MultiWriter(f, h)
303	enc, err := zstd.NewWriter(mw)
304	if err != nil {
305		return err
306	}
307	if err := writeCanonicalTar(abs, enc, outAbs); err != nil {
308		enc.Close()
309		return err
310	}
311	if err := enc.Close(); err != nil {
312		return err
313	}
314	fmt.Printf("wrote %s\ndigest sha256:%s\n", out, hex.EncodeToString(h.Sum(nil)))
315	return nil
316}
317
318func loadZotfile(ref string) (zotfileLoaded, func(), error) {
319	info, err := os.Stat(ref)
320	if err != nil {
321		return zotfileLoaded{}, nil, err
322	}
323	if info.IsDir() {
324		abs, _ := filepath.Abs(ref)
325		m, err := readZotManifest(abs)
326		if err != nil {
327			return zotfileLoaded{}, nil, err
328		}
329		if err := validateZotfileDir(abs); err != nil {
330			return zotfileLoaded{}, nil, err
331		}
332		return zotfileLoaded{Dir: abs, Manifest: m, Digest: digestDirectory(abs)}, nil, nil
333	}
334	tmp, err := os.MkdirTemp("", "zotfile-*")
335	if err != nil {
336		return zotfileLoaded{}, nil, err
337	}
338	cleanup := func() { _ = os.RemoveAll(tmp) }
339	digest, err := unpackZotArchive(ref, tmp)
340	if err != nil {
341		cleanup()
342		return zotfileLoaded{}, nil, err
343	}
344	m, err := readZotManifest(tmp)
345	if err != nil {
346		cleanup()
347		return zotfileLoaded{}, nil, err
348	}
349	if err := validateZotfileDir(tmp); err != nil {
350		cleanup()
351		return zotfileLoaded{}, nil, err
352	}
353	return zotfileLoaded{Dir: tmp, Temp: true, Digest: digest, Manifest: m}, cleanup, nil
354}
355
356func readZotManifest(dir string) (ZotfileManifest, error) {
357	var m ZotfileManifest
358	b, err := os.ReadFile(filepath.Join(dir, "manifest.json"))
359	if err != nil {
360		return m, fmt.Errorf("manifest.json is required: %w", err)
361	}
362	if err := json.Unmarshal(b, &m); err != nil {
363		return m, fmt.Errorf("manifest.json: %w", err)
364	}
365	if m.Zotfile != 1 {
366		return m, fmt.Errorf("unsupported zotfile version %d", m.Zotfile)
367	}
368	name := strings.TrimSpace(m.Name)
369	if name == "" {
370		return m, fmt.Errorf("manifest name is required")
371	}
372	if name != strings.ToLower(name) || safeAgentName(name) != name {
373		return m, fmt.Errorf("manifest name must contain only lowercase letters, digits, dots, hyphens, or underscores")
374	}
375	if len(m.Permissions.Net.Allow) > 0 {
376		return m, fmt.Errorf("permissions.net is not supported by the local runtime yet")
377	}
378	if len(m.Permissions.Env.Read) > 0 {
379		return m, fmt.Errorf("permissions.env is not supported by the local runtime yet")
380	}
381	mode := strings.ToLower(strings.TrimSpace(m.Permissions.Bash.Mode))
382	if mode != "" && mode != "none" && mode != "ask" && mode != "allowlist" {
383		return m, fmt.Errorf("unsupported bash permission mode %q", m.Permissions.Bash.Mode)
384	}
385	if mode == "allowlist" && len(m.Permissions.Bash.Allow) == 0 {
386		return m, fmt.Errorf("bash allowlist mode requires at least one command")
387	}
388	return m, nil
389}
390
391func validateZotfileDir(dir string) error {
392	st, err := os.Stat(filepath.Join(dir, "AGENT.md"))
393	if err != nil || !st.Mode().IsRegular() {
394		return fmt.Errorf("AGENT.md is required")
395	}
396	if exts := bundledExtensionDirs(filepath.Join(dir, "extensions")); len(exts) > 0 {
397		return fmt.Errorf("bundled executable extensions are not supported by the local runtime: they cannot yet be confined to manifest permissions")
398	}
399	return nil
400}
401
402const (
403	maxZotfileCompressedSize = 100 << 20
404	maxZotfileEntrySize      = 64 << 20
405	maxZotfileExpandedSize   = 256 << 20
406)
407
408func unpackZotArchive(path, dst string) (string, error) {
409	info, err := os.Stat(path)
410	if err != nil {
411		return "", err
412	}
413	if info.Size() > maxZotfileCompressedSize {
414		return "", fmt.Errorf("zotfile exceeds %d MiB compressed size limit", maxZotfileCompressedSize>>20)
415	}
416	b, err := os.ReadFile(path)
417	if err != nil {
418		return "", err
419	}
420	digest := sha256.Sum256(b)
421	r, err := zstd.NewReader(bytes.NewReader(b), zstd.WithDecoderMaxMemory(maxZotfileExpandedSize))
422	if err != nil {
423		// Development fallback for older experiments.
424		gr, gerr := gzip.NewReader(bytes.NewReader(b))
425		if gerr != nil {
426			return "", err
427		}
428		defer gr.Close()
429		return hex.EncodeToString(digest[:]), untar(gr, dst)
430	}
431	defer r.Close()
432	return hex.EncodeToString(digest[:]), untar(r, dst)
433}
434
435func untar(r io.Reader, dst string) error {
436	tr := tar.NewReader(r)
437	var expanded int64
438	for {
439		h, err := tr.Next()
440		if err == io.EOF {
441			return nil
442		}
443		if err != nil {
444			return err
445		}
446		name := filepath.Clean(h.Name)
447		if name == "." || strings.HasPrefix(name, "..") || filepath.IsAbs(name) {
448			return fmt.Errorf("unsafe path in archive: %s", h.Name)
449		}
450		path := filepath.Join(dst, name)
451		if h.Size < 0 || h.Size > maxZotfileEntrySize || expanded+h.Size > maxZotfileExpandedSize {
452			return fmt.Errorf("archive content exceeds extraction size limit: %s", h.Name)
453		}
454		expanded += h.Size
455		switch h.Typeflag {
456		case tar.TypeDir:
457			if err := os.MkdirAll(path, 0o755); err != nil {
458				return err
459			}
460		case tar.TypeReg:
461			if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
462				return err
463			}
464			f, err := os.OpenFile(path, os.O_CREATE|os.O_WRONLY|os.O_TRUNC, os.FileMode(h.Mode)&0o777)
465			if err != nil {
466				return err
467			}
468			_, cerr := io.Copy(f, tr)
469			if err := f.Close(); err != nil && cerr == nil {
470				cerr = err
471			}
472			if cerr != nil {
473				return cerr
474			}
475		}
476	}
477}
478
479func writeCanonicalTar(root string, w io.Writer, exclude ...string) error {
480	excluded := map[string]bool{}
481	for _, p := range exclude {
482		if p == "" {
483			continue
484		}
485		abs, err := filepath.Abs(p)
486		if err == nil {
487			excluded[abs] = true
488		}
489	}
490	var files []string
491	if err := filepath.WalkDir(root, func(path string, d os.DirEntry, err error) error {
492		if err != nil || path == root {
493			return err
494		}
495		absPath, _ := filepath.Abs(path)
496		if excluded[absPath] {
497			return nil
498		}
499		if d.Type()&os.ModeSymlink != 0 {
500			return fmt.Errorf("symlinks are not supported in zotfiles: %s", path)
501		}
502		files = append(files, path)
503		return nil
504	}); err != nil {
505		return err
506	}
507	sort.Strings(files)
508	tw := tar.NewWriter(w)
509	defer tw.Close()
510	fixed := time.Unix(0, 0)
511	for _, path := range files {
512		info, err := os.Stat(path)
513		if err != nil {
514			return err
515		}
516		rel, _ := filepath.Rel(root, path)
517		rel = filepath.ToSlash(rel)
518		h, err := tar.FileInfoHeader(info, "")
519		if err != nil {
520			return err
521		}
522		h.Name = rel
523		h.ModTime = fixed
524		h.AccessTime = fixed
525		h.ChangeTime = fixed
526		h.Uid, h.Gid, h.Uname, h.Gname = 0, 0, "", ""
527		if info.IsDir() && !strings.HasSuffix(h.Name, "/") {
528			h.Name += "/"
529		}
530		if err := tw.WriteHeader(h); err != nil {
531			return err
532		}
533		if info.Mode().IsRegular() {
534			f, err := os.Open(path)
535			if err != nil {
536				return err
537			}
538			_, cerr := io.Copy(tw, f)
539			_ = f.Close()
540			if cerr != nil {
541				return cerr
542			}
543		}
544	}
545	return nil
546}
547
548func checkZotfileRequirements(zf zotfileLoaded, version string) error {
549	if min := strings.TrimSpace(zf.Manifest.Runtime.MinZot); min != "" {
550		if versionOnly(version) == "0.0.0" {
551			return fmt.Errorf("agent requires zot %s or newer; unversioned development builds cannot satisfy min_zot", min)
552		}
553		if versionLess(version, min) {
554			return fmt.Errorf("agent requires zot %s or newer; running %s", min, versionOnly(version))
555		}
556	}
557	if len(zf.Manifest.Requirements.OS) > 0 {
558		ok := false
559		for _, osName := range zf.Manifest.Requirements.OS {
560			if osName == runtime.GOOS {
561				ok = true
562				break
563			}
564		}
565		if !ok {
566			return fmt.Errorf("agent does not support %s", runtime.GOOS)
567		}
568	}
569	if len(zf.Manifest.Requirements.Bin) > 0 {
570		for _, b := range zf.Manifest.Requirements.Bin {
571			if _, err := execLookPath(b); err != nil {
572				return fmt.Errorf("agent requires binary %q", b)
573			}
574		}
575	}
576	return nil
577}
578
579var execLookPath = exec.LookPath
580
581func consentZotfile(zf zotfileLoaded, perms tools.PermissionSet) error {
582	if os.Getenv("ZOT_AGENT_CONSENT") == "1" {
583		return nil
584	}
585	// "ask" deliberately requires approval on every launch. Other consent
586	// is durable only for this exact artifact digest, so any package change
587	// causes a fresh prompt.
588	consentPath := filepath.Join(ZotHome(), "agents", safeAgentName(zf.Manifest.Name), "consents", zf.Digest+".json")
589	if strings.ToLower(strings.TrimSpace(perms.Bash.Mode)) != "ask" {
590		if _, err := os.Stat(consentPath); err == nil {
591			return nil
592		}
593	}
594	fmt.Printf("Agent %s@%s wants to run.\n\n", zf.Manifest.Name, zf.Manifest.Version)
595	fmt.Print(permissionSummary(perms))
596	if !term.IsTerminal(int(os.Stdin.Fd())) {
597		return fmt.Errorf("refusing to run without interactive consent; set ZOT_AGENT_CONSENT=1 to allow")
598	}
599	fmt.Print("\nAllow? [y/N] ")
600	var answer string
601	_, _ = fmt.Fscanln(os.Stdin, &answer)
602	answer = strings.ToLower(strings.TrimSpace(answer))
603	if answer != "y" && answer != "yes" {
604		return fmt.Errorf("declined")
605	}
606	if strings.ToLower(strings.TrimSpace(perms.Bash.Mode)) == "ask" {
607		return nil
608	}
609	if err := os.MkdirAll(filepath.Dir(consentPath), 0o700); err != nil {
610		return fmt.Errorf("save agent consent: %w", err)
611	}
612	receipt := map[string]string{"digest": zf.Digest, "name": zf.Manifest.Name, "version": zf.Manifest.Version}
613	data, _ := json.MarshalIndent(receipt, "", "  ")
614	if err := os.WriteFile(consentPath, append(data, '\n'), 0o600); err != nil {
615		return fmt.Errorf("save agent consent: %w", err)
616	}
617	return nil
618}
619
620func permissionSummary(p tools.PermissionSet) string {
621	var sb strings.Builder
622	if len(p.FS.Read) > 0 {
623		fmt.Fprintf(&sb, "  fs read: %s\n", strings.Join(p.FS.Read, ", "))
624	} else {
625		fmt.Fprintln(&sb, "  fs read: none")
626	}
627	if len(p.FS.Write) > 0 {
628		fmt.Fprintf(&sb, "  fs write: %s\n", strings.Join(p.FS.Write, ", "))
629	} else {
630		fmt.Fprintln(&sb, "  fs write: none")
631	}
632	mode := p.Bash.Mode
633	if mode == "" {
634		mode = "none"
635	}
636	fmt.Fprintf(&sb, "  bash: %s", mode)
637	if len(p.Bash.Allow) > 0 {
638		fmt.Fprintf(&sb, " (%s)", strings.Join(p.Bash.Allow, ", "))
639	}
640	fmt.Fprintln(&sb)
641	if len(p.Net.Allow) > 0 {
642		fmt.Fprintf(&sb, "  net: %s (declared, not enforced in this build)\n", strings.Join(p.Net.Allow, ", "))
643	}
644	if len(p.Env.Read) > 0 {
645		fmt.Fprintf(&sb, "  env read: %s (declared, not enforced in this build)\n", strings.Join(p.Env.Read, ", "))
646	}
647	return sb.String()
648}
649
650func bundledExtensionDirs(root string) []string {
651	entries, err := os.ReadDir(root)
652	if err != nil {
653		return nil
654	}
655	var out []string
656	for _, e := range entries {
657		if e.IsDir() && fileExists(filepath.Join(root, e.Name(), "extension.json")) {
658			out = append(out, filepath.Join(root, e.Name()))
659		}
660	}
661	return out
662}
663
664func fileExists(p string) bool { _, err := os.Stat(p); return err == nil }
665func dirExists(p string) bool  { st, err := os.Stat(p); return err == nil && st.IsDir() }
666
667func safeAgentName(s string) string {
668	s = strings.TrimSpace(strings.ToLower(s))
669	s = strings.ReplaceAll(s, "/", "_")
670	s = strings.ReplaceAll(s, "\\", "_")
671	s = strings.Map(func(r rune) rune {
672		if r >= 'a' && r <= 'z' || r >= '0' && r <= '9' || r == '-' || r == '_' || r == '.' {
673			return r
674		}
675		return '-'
676	}, s)
677	return strings.Trim(s, "-._")
678}
679
680func digestDirectory(dir string) string {
681	h := sha256.New()
682	_ = writeCanonicalTar(dir, h)
683	return hex.EncodeToString(h.Sum(nil))
684}

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