v0.51.0 a9d674e

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

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