v0.42.0 82578de

create_worktree.go RawHistoryFolder

1package tools
2
3import (
4	"bytes"
5	"context"
6	"crypto/sha256"
7	"encoding/hex"
8	"encoding/json"
9	"errors"
10	"fmt"
11	"io"
12	"os"
13	"os/exec"
14	"path/filepath"
15	"strings"
16	"sync"
17	"time"
18
19	"github.com/bnema/zut/packages/core"
20	"github.com/bnema/zut/packages/provider"
21)
22
23const (
24	worktreeConfigKey          = "zut.worktrees.path"
25	maxCreateWorktreeGitOutput = 64 * 1024
26	// worktreeRepoIDHashLength keeps the hash short enough to read but wide
27	// enough that distinct repositories do not collide in practice.
28	worktreeRepoIDHashLength = 12
29	worktreeRepoSlugMaxLen   = 40
30)
31
32var createWorktreeOperationMu sync.Mutex
33
34// CreateWorktreeTool creates a persistent branch checkout. An unconfigured
35// repository checks out under a stable per-repository directory beneath
36// DefaultRoot. A local zut.worktrees.path Git configuration entry overrides
37// that root for the repository.
38type CreateWorktreeTool struct {
39	CWD         string
40	Sandbox     *Sandbox
41	DefaultRoot string
42
43	previewMu sync.Mutex
44	preview   *createWorktreePreview
45}
46
47type createWorktreeArgs struct {
48	Branch string `json:"branch"`
49}
50
51const createWorktreeSchema = `{
52  "type":"object",
53  "properties":{
54    "branch":{
55      "type":"string",
56      "description":"Name of the new Git branch."
57    }
58  },
59  "required":["branch"]
60}`
61
62func (t *CreateWorktreeTool) Name() string { return "create_worktree" }
63func (t *CreateWorktreeTool) Description() string {
64	return "Create a persistent Git branch worktree under the repository's configured worktree root or zut's global worktrees root."
65}
66func (t *CreateWorktreeTool) Schema() json.RawMessage { return json.RawMessage(createWorktreeSchema) }
67
68// Preview reports the checkout that Execute will produce without modifying the
69// repository or filesystem.
70func (t *CreateWorktreeTool) Preview(ctx context.Context, raw json.RawMessage) (core.ToolResult, error) {
71	plan, err := t.plan(ctx, raw)
72	if err != nil {
73		return core.ToolResult{}, err
74	}
75	t.previewMu.Lock()
76	t.preview = &createWorktreePreview{raw: string(raw), plan: plan}
77	t.previewMu.Unlock()
78	return plan.preview, nil
79}
80
81func (t *CreateWorktreeTool) Execute(ctx context.Context, raw json.RawMessage, progress func(string)) (core.ToolResult, error) {
82	createWorktreeOperationMu.Lock()
83	defer createWorktreeOperationMu.Unlock()
84
85	plan, err := t.planForExecute(ctx, raw)
86	if err != nil {
87		return core.ToolResult{}, err
88	}
89	return executeCreateWorktreePlan(ctx, plan, progress)
90}
91
92func executeCreateWorktreePlan(ctx context.Context, plan createWorktreePlan, progress func(string)) (core.ToolResult, error) {
93	createdWorktreeParents, err := missingWorktreeParents(plan.worktreePath)
94	if err != nil {
95		return core.ToolResult{}, fmt.Errorf("create_worktree: inspect worktree directories: %w", err)
96	}
97	rollback := func(cause error) error {
98		cleanupCtx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
99		defer cancel()
100		if err := plan.cleanupFailedCheckout(cleanupCtx); err != nil {
101			cause = fmt.Errorf("%w; cleanup worktree: %v", cause, err)
102		}
103		if err := removeCreatedWorktreeParents(createdWorktreeParents); err != nil {
104			cause = fmt.Errorf("%w; remove created worktree directories: %v", cause, err)
105		}
106		return cause
107	}
108
109	if err := os.MkdirAll(filepath.Dir(plan.worktreePath), 0o755); err != nil {
110		return core.ToolResult{}, rollback(fmt.Errorf("create_worktree: make worktree directory: %w", err))
111	}
112	if progress != nil {
113		progress("Creating Git worktree...\n")
114	}
115	if _, err := createWorktreeGitOutput(ctx, plan.repoRoot, "worktree", "add", "-b", plan.branch, plan.worktreePath, plan.base); err != nil {
116		return core.ToolResult{}, rollback(fmt.Errorf("create_worktree: create worktree: %w", err))
117	}
118
119	result := plan.preview
120	previewDetails, _ := plan.preview.Details.(map[string]any)
121	details := make(map[string]any, len(previewDetails)+1)
122	for key, value := range previewDetails {
123		details[key] = value
124	}
125	details["state"] = "created"
126	result.Details = details
127	result.Content = []provider.Content{provider.TextBlock{Text: strings.Replace(plan.previewText, "create worktree", "created worktree", 1)}}
128	return result, nil
129}
130
131type createWorktreePreview struct {
132	raw  string
133	plan createWorktreePlan
134}
135
136func (t *CreateWorktreeTool) planForExecute(ctx context.Context, raw json.RawMessage) (createWorktreePlan, error) {
137	t.previewMu.Lock()
138	cached := t.preview
139	t.preview = nil
140	t.previewMu.Unlock()
141
142	plan, err := t.plan(ctx, raw)
143	if err != nil {
144		return createWorktreePlan{}, err
145	}
146	if cached != nil && cached.raw == string(raw) && !sameCreateWorktreePlan(cached.plan, plan) {
147		return createWorktreePlan{}, errors.New("create_worktree: repository state changed since preview; inspect and retry")
148	}
149	return plan, nil
150}
151
152type createWorktreePlan struct {
153	repoRoot     string
154	branch       string
155	base         string
156	worktreeRoot string
157	worktreePath string
158	rootSource   string
159	repoID       string
160	previewText  string
161	preview      core.ToolResult
162}
163
164func (t *CreateWorktreeTool) plan(ctx context.Context, raw json.RawMessage) (createWorktreePlan, error) {
165	var args createWorktreeArgs
166	if err := json.Unmarshal(raw, &args); err != nil {
167		return createWorktreePlan{}, fmt.Errorf("invalid args: %w", err)
168	}
169	branchInput := strings.TrimSpace(args.Branch)
170	if branchInput == "" {
171		return createWorktreePlan{}, errors.New("branch is required")
172	}
173	if strings.HasPrefix(branchInput, "-") || strings.Contains(branchInput, "@{") {
174		return createWorktreePlan{}, fmt.Errorf("create_worktree: invalid branch %q", branchInput)
175	}
176	if t.Sandbox == nil {
177		return createWorktreePlan{}, errors.New("create_worktree: sandbox is required")
178	}
179	if err := t.Sandbox.CheckBashPermission("git"); err != nil {
180		return createWorktreePlan{}, fmt.Errorf("create_worktree: %w", err)
181	}
182
183	cwd, err := t.accessibleCWD()
184	if err != nil {
185		return createWorktreePlan{}, err
186	}
187	candidateRoot, err := t.findGitRootCandidate(cwd)
188	if err != nil {
189		return createWorktreePlan{}, err
190	}
191	repoRoot, err := createWorktreeGitOutput(ctx, candidateRoot, "rev-parse", "--show-toplevel")
192	if err != nil {
193		return createWorktreePlan{}, fmt.Errorf("create_worktree: verify repository: %w", err)
194	}
195	repoRoot, err = canonical(repoRoot)
196	if err != nil {
197		return createWorktreePlan{}, fmt.Errorf("create_worktree: resolve repository root: %w", err)
198	}
199	if err := t.checkReadAccess(repoRoot); err != nil {
200		return createWorktreePlan{}, err
201	}
202	branch, err := createWorktreeGitOutput(ctx, repoRoot, "check-ref-format", "--branch", branchInput)
203	if err != nil {
204		return createWorktreePlan{}, fmt.Errorf("create_worktree: invalid branch %q: %w", branchInput, err)
205	}
206	if branch != branchInput {
207		return createWorktreePlan{}, fmt.Errorf("create_worktree: invalid branch %q", branchInput)
208	}
209	base, err := createWorktreeGitOutput(ctx, repoRoot, "rev-parse", "--verify", "HEAD^{commit}")
210	if err != nil {
211		return createWorktreePlan{}, fmt.Errorf("create_worktree: resolve current commit: %w", err)
212	}
213	commonDir, err := createWorktreeGitOutput(ctx, repoRoot, "rev-parse", "--git-common-dir")
214	if err != nil {
215		return createWorktreePlan{}, fmt.Errorf("create_worktree: resolve Git metadata: %w", err)
216	}
217	if !filepath.IsAbs(commonDir) {
218		commonDir = filepath.Join(repoRoot, commonDir)
219	}
220	commonDir, err = canonical(commonDir)
221	if err != nil {
222		return createWorktreePlan{}, fmt.Errorf("create_worktree: resolve Git metadata: %w", err)
223	}
224	if err := t.checkReadAccess(commonDir); err != nil {
225		return createWorktreePlan{}, err
226	}
227
228	repoID := worktreeRepoID(commonDir)
229	configuredRoot, configured, err := createWorktreeGitConfigValue(ctx, repoRoot, worktreeConfigKey)
230	if err != nil {
231		return createWorktreePlan{}, fmt.Errorf("create_worktree: read local worktree configuration: %w", err)
232	}
233	var worktreeRoot string
234	rootSource := "global default root"
235	if configured {
236		worktreeRoot, err = resolveConfiguredWorktreeRoot(configuredRoot)
237		if err != nil {
238			return createWorktreePlan{}, err
239		}
240		rootSource = "local Git config"
241	} else {
242		defaultRoot, err := t.defaultRoot()
243		if err != nil {
244			return createWorktreePlan{}, err
245		}
246		worktreeRoot = filepath.Join(defaultRoot, repoID)
247	}
248
249	if err := validateWorktreeRoot(worktreeRoot); err != nil {
250		return createWorktreePlan{}, err
251	}
252	worktreePath := filepath.Join(worktreeRoot, filepath.FromSlash(branch))
253	rootCanonical, err := canonicalOrParent(worktreeRoot)
254	if err != nil {
255		return createWorktreePlan{}, fmt.Errorf("create_worktree: resolve worktree root: %w", err)
256	}
257	if isUnder(repoRoot, rootCanonical) {
258		return createWorktreePlan{}, errors.New("create_worktree: worktree root must be outside the repository")
259	}
260	pathCanonical, err := canonicalOrParent(worktreePath)
261	if err != nil {
262		return createWorktreePlan{}, fmt.Errorf("create_worktree: resolve worktree path: %w", err)
263	}
264	if !isUnder(rootCanonical, pathCanonical) {
265		return createWorktreePlan{}, errors.New("create_worktree: branch path escapes the worktree root")
266	}
267
268	if err := t.checkReadWriteAccess(repoRoot, commonDir, worktreeRoot, worktreePath); err != nil {
269		return createWorktreePlan{}, err
270	}
271	if _, err := os.Lstat(worktreePath); err == nil {
272		return createWorktreePlan{}, fmt.Errorf("create_worktree: worktree path %q already exists", filepath.ToSlash(worktreePath))
273	} else if !os.IsNotExist(err) {
274		return createWorktreePlan{}, fmt.Errorf("create_worktree: inspect worktree path: %w", err)
275	}
276	exists, err := createWorktreeBranchExists(ctx, repoRoot, branch)
277	if err != nil {
278		return createWorktreePlan{}, fmt.Errorf("create_worktree: check branch: %w", err)
279	}
280	if exists {
281		return createWorktreePlan{}, fmt.Errorf("create_worktree: branch %q already exists", branch)
282	}
283
284	displayRoot := filepath.ToSlash(worktreeRoot)
285	displayPath := filepath.ToSlash(worktreePath)
286	previewText := fmt.Sprintf("create worktree\nbranch: %s\nbase: %s\nworktree root: %s\nroot source: %s\npath: %s\n", branch, base, displayRoot, rootSource, displayPath)
287	return createWorktreePlan{
288		repoRoot:     repoRoot,
289		branch:       branch,
290		base:         base,
291		worktreeRoot: worktreeRoot,
292		worktreePath: worktreePath,
293		rootSource:   rootSource,
294		repoID:       repoID,
295		previewText:  previewText,
296		preview: core.ToolResult{
297			Content: []provider.Content{provider.TextBlock{Text: previewText}},
298			Details: map[string]any{
299				"state":         "ready",
300				"branch":        branch,
301				"base":          base,
302				"worktree_root": displayRoot,
303				"root_source":   rootSource,
304				"repo_id":       repoID,
305				"path":          displayPath,
306			},
307		},
308	}, nil
309}
310
311func (t *CreateWorktreeTool) defaultRoot() (string, error) {
312	root := strings.TrimSpace(t.DefaultRoot)
313	if root == "" {
314		return "", errors.New("create_worktree: default worktree root is not configured")
315	}
316	return canonicalOrParent(root)
317}
318
319// worktreeRepoID derives a stable, collision-resistant directory name for the
320// repository that owns commonDir. The name pairs a readable slug with a hash of
321// the canonical common directory so repositories sharing a base name still get
322// distinct roots while one repository keeps the same directory across sessions.
323func worktreeRepoID(commonDir string) string {
324	sum := sha256.Sum256([]byte(filepath.ToSlash(commonDir)))
325	return worktreeRepoSlug(commonDir) + "-" + hex.EncodeToString(sum[:])[:worktreeRepoIDHashLength]
326}
327
328func worktreeRepoSlug(commonDir string) string {
329	base := filepath.Base(commonDir)
330	if trimmed := strings.TrimSuffix(base, ".git"); trimmed != "" {
331		base = trimmed
332	} else {
333		base = filepath.Base(filepath.Dir(commonDir))
334	}
335	var builder strings.Builder
336	dashed := false
337	for _, r := range base {
338		switch {
339		case r >= 'a' && r <= 'z', r >= '0' && r <= '9', r == '.', r == '_', r == '-':
340			builder.WriteRune(r)
341			dashed = false
342		case r >= 'A' && r <= 'Z':
343			builder.WriteRune(r + ('a' - 'A'))
344			dashed = false
345		default:
346			if !dashed {
347				builder.WriteByte('-')
348				dashed = true
349			}
350		}
351	}
352	slug := strings.Trim(builder.String(), "-._")
353	if len(slug) > worktreeRepoSlugMaxLen {
354		slug = strings.Trim(slug[:worktreeRepoSlugMaxLen], "-._")
355	}
356	if slug == "" {
357		slug = "repo"
358	}
359	return slug
360}
361
362func (t *CreateWorktreeTool) accessibleCWD() (string, error) {
363	cwd := t.CWD
364	if cwd == "" {
365		var err error
366		cwd, err = os.Getwd()
367		if err != nil {
368			return "", fmt.Errorf("create_worktree: working directory: %w", err)
369		}
370	}
371	cwd, err := canonical(cwd)
372	if err != nil {
373		return "", fmt.Errorf("create_worktree: resolve working directory: %w", err)
374	}
375	if err := t.checkReadAccess(cwd); err != nil {
376		return "", err
377	}
378	return cwd, nil
379}
380
381func (t *CreateWorktreeTool) findGitRootCandidate(cwd string) (string, error) {
382	for dir := cwd; ; dir = filepath.Dir(dir) {
383		if err := t.checkReadAccess(dir); err != nil {
384			return "", err
385		}
386		if _, err := os.Lstat(filepath.Join(dir, ".git")); err == nil {
387			return dir, nil
388		} else if !os.IsNotExist(err) {
389			return "", fmt.Errorf("create_worktree: inspect Git metadata: %w", err)
390		}
391		parent := filepath.Dir(dir)
392		if parent == dir {
393			break
394		}
395	}
396	return "", errors.New("create_worktree: working directory is not a Git repository")
397}
398
399func (t *CreateWorktreeTool) checkReadAccess(paths ...string) error {
400	for _, path := range paths {
401		if err := t.Sandbox.CheckReadPath(path); err != nil {
402			return fmt.Errorf("create_worktree: %w", err)
403		}
404	}
405	return nil
406}
407
408func (t *CreateWorktreeTool) checkReadWriteAccess(paths ...string) error {
409	for _, path := range paths {
410		if err := t.Sandbox.CheckReadPath(path); err != nil {
411			return fmt.Errorf("create_worktree: %w", err)
412		}
413		if err := t.Sandbox.CheckWritePath(path); err != nil {
414			return fmt.Errorf("create_worktree: %w", err)
415		}
416	}
417	return nil
418}
419
420func resolveConfiguredWorktreeRoot(value string) (string, error) {
421	value = strings.TrimSpace(value)
422	if value == "" {
423		return "", errors.New("create_worktree: local worktree configuration is empty")
424	}
425	if !filepath.IsAbs(value) {
426		return "", errors.New("create_worktree: local worktree configuration must be an absolute path; remove a legacy relative value with `git config --local --unset zut.worktrees.path`")
427	}
428	return filepath.Clean(value), nil
429}
430
431func validateWorktreeRoot(path string) error {
432	info, err := os.Lstat(path)
433	if os.IsNotExist(err) {
434		return nil
435	}
436	if err != nil {
437		return fmt.Errorf("create_worktree: inspect worktree root: %w", err)
438	}
439	if info.Mode()&os.ModeSymlink != 0 {
440		return errors.New("create_worktree: worktree root must not be a symbolic link")
441	}
442	if !info.IsDir() {
443		return errors.New("create_worktree: worktree root exists but is not a directory")
444	}
445	return nil
446}
447
448func sameCreateWorktreePlan(a, b createWorktreePlan) bool {
449	return a.repoRoot == b.repoRoot &&
450		a.branch == b.branch &&
451		a.base == b.base &&
452		a.worktreeRoot == b.worktreeRoot &&
453		a.worktreePath == b.worktreePath &&
454		a.rootSource == b.rootSource &&
455		a.repoID == b.repoID
456}
457
458func missingWorktreeParents(worktreePath string) ([]string, error) {
459	var missing []string
460	for directory := filepath.Dir(worktreePath); ; directory = filepath.Dir(directory) {
461		info, err := os.Lstat(directory)
462		if err == nil {
463			if !info.IsDir() {
464				return nil, fmt.Errorf("%q is not a directory", directory)
465			}
466			break
467		}
468		if !os.IsNotExist(err) {
469			return nil, err
470		}
471		missing = append(missing, directory)
472		parent := filepath.Dir(directory)
473		if parent == directory {
474			return nil, errors.New("no existing parent directory")
475		}
476	}
477	for left, right := 0, len(missing)-1; left < right; left, right = left+1, right-1 {
478		missing[left], missing[right] = missing[right], missing[left]
479	}
480	return missing, nil
481}
482
483func removeCreatedWorktreeParents(parents []string) error {
484	for index := len(parents) - 1; index >= 0; index-- {
485		if err := os.Remove(parents[index]); err != nil && !os.IsNotExist(err) {
486			return err
487		}
488	}
489	return nil
490}
491
492func (p createWorktreePlan) cleanupFailedCheckout(ctx context.Context) error {
493	registered, err := createWorktreeRegistered(ctx, p.repoRoot, p.worktreePath)
494	if err != nil {
495		return err
496	}
497	var cleanupErr error
498	if registered {
499		if _, err := createWorktreeGitOutput(ctx, p.repoRoot, "worktree", "remove", "--force", p.worktreePath); err != nil {
500			cleanupErr = fmt.Errorf("remove registered worktree: %w", err)
501		}
502	}
503	branchHead, exists, err := createWorktreeGitRef(ctx, p.repoRoot, "refs/heads/"+p.branch)
504	if err == nil && exists && branchHead == p.base {
505		if _, err := createWorktreeGitOutput(ctx, p.repoRoot, "branch", "-D", p.branch); err != nil && cleanupErr == nil {
506			cleanupErr = fmt.Errorf("remove branch: %w", err)
507		}
508	}
509	if err != nil && cleanupErr == nil {
510		cleanupErr = err
511	}
512	return cleanupErr
513}
514
515func createWorktreeRegistered(ctx context.Context, repoRoot, worktreePath string) (bool, error) {
516	output, err := createWorktreeGitOutput(ctx, repoRoot, "worktree", "list", "--porcelain")
517	if err != nil {
518		return false, err
519	}
520	want, err := canonicalOrParent(worktreePath)
521	if err != nil {
522		return false, err
523	}
524	for _, line := range strings.Split(output, "\n") {
525		if !strings.HasPrefix(line, "worktree ") {
526			continue
527		}
528		path, err := canonicalOrParent(strings.TrimPrefix(line, "worktree "))
529		if err == nil && path == want {
530			return true, nil
531		}
532	}
533	return false, nil
534}
535
536func createWorktreeBranchExists(ctx context.Context, repoRoot, branch string) (bool, error) {
537	_, err := createWorktreeGitOutput(ctx, repoRoot, "show-ref", "--verify", "--quiet", "refs/heads/"+branch)
538	if err == nil {
539		return true, nil
540	}
541	if ctx.Err() != nil {
542		return false, ctx.Err()
543	}
544	if createWorktreeGitExitCode(err) == 1 {
545		return false, nil
546	}
547	return false, err
548}
549
550func createWorktreeGitRef(ctx context.Context, repoRoot, ref string) (string, bool, error) {
551	output, err := createWorktreeGitOutput(ctx, repoRoot, "rev-parse", "--verify", "--quiet", ref+"^{commit}")
552	if err == nil {
553		return output, true, nil
554	}
555	if createWorktreeGitExitCode(err) == 1 {
556		return "", false, nil
557	}
558	return "", false, err
559}
560
561func createWorktreeGitConfigValue(ctx context.Context, repoRoot, key string) (string, bool, error) {
562	output, err := createWorktreeGitOutput(ctx, repoRoot, "config", "--local", "--get", key)
563	if err == nil {
564		return output, true, nil
565	}
566	if createWorktreeGitExitCode(err) == 1 {
567		return "", false, nil
568	}
569	return "", false, err
570}
571
572type createWorktreeGitError struct {
573	args   []string
574	output string
575	err    error
576}
577
578func (e *createWorktreeGitError) Error() string {
579	message := strings.TrimSpace(e.output)
580	if message == "" {
581		message = e.err.Error()
582	}
583	return fmt.Sprintf("git %s: %s", strings.Join(e.args, " "), message)
584}
585
586func (e *createWorktreeGitError) Unwrap() error { return e.err }
587
588func createWorktreeGitExitCode(err error) int {
589	var exitErr *exec.ExitError
590	if errors.As(err, &exitErr) {
591		return exitErr.ExitCode()
592	}
593	return -1
594}
595
596func createWorktreeGitOutput(ctx context.Context, dir string, args ...string) (string, error) {
597	cmdArgs := append([]string{"-c", "core.hooksPath=" + os.DevNull, "-C", dir}, args...)
598	cmd := exec.CommandContext(ctx, "git", cmdArgs...)
599	cmd.Env = createWorktreeGitEnv()
600	setProcessGroup(cmd)
601
602	var stdout, output cappedGitOutput
603	cmd.Stdout = io.MultiWriter(&stdout, &output)
604	cmd.Stderr = &output
605	if err := cmd.Start(); err != nil {
606		return "", fmt.Errorf("start git %s: %w", strings.Join(args, " "), err)
607	}
608	watchDone := make(chan struct{})
609	watchStopped := make(chan struct{})
610	go func() {
611		defer close(watchStopped)
612		select {
613		case <-ctx.Done():
614			killProcessGroup(cmd)
615		case <-watchDone:
616		}
617	}()
618	waitErr := cmd.Wait()
619	close(watchDone)
620	<-watchStopped
621	if waitErr != nil {
622		if ctx.Err() != nil {
623			return "", ctx.Err()
624		}
625		return "", &createWorktreeGitError{args: args, output: output.String(), err: waitErr}
626	}
627	return strings.TrimSpace(stdout.String()), nil
628}
629
630type cappedGitOutput struct {
631	mu        sync.Mutex
632	buffer    bytes.Buffer
633	truncated bool
634}
635
636func (b *cappedGitOutput) Write(p []byte) (int, error) {
637	b.mu.Lock()
638	defer b.mu.Unlock()
639	if room := maxCreateWorktreeGitOutput - b.buffer.Len(); room > 0 {
640		if len(p) > room {
641			_, _ = b.buffer.Write(p[:room])
642			b.truncated = true
643		} else {
644			_, _ = b.buffer.Write(p)
645		}
646	} else {
647		b.truncated = true
648	}
649	return len(p), nil
650}
651
652func (b *cappedGitOutput) String() string {
653	b.mu.Lock()
654	defer b.mu.Unlock()
655	output := b.buffer.String()
656	if b.truncated {
657		output += "\n... [git output truncated]"
658	}
659	return output
660}
661
662func createWorktreeGitEnv() []string {
663	env := make([]string, 0, len(os.Environ())+5)
664	for _, entry := range os.Environ() {
665		key, _, _ := strings.Cut(entry, "=")
666		if strings.HasPrefix(key, "GIT_") || key == "SSH_ASKPASS" {
667			continue
668		}
669		env = append(env, entry)
670	}
671	return append(env,
672		"GIT_CONFIG_NOSYSTEM=1",
673		"GIT_CONFIG_GLOBAL="+os.DevNull,
674		"GIT_TERMINAL_PROMPT=0",
675		"GIT_ASKPASS=",
676		"SSH_ASKPASS=",
677	)
678}

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