v0.27.1 131edba

create_worktree.go RawHistoryFolder

1package tools
2
3import (
4	"bytes"
5	"context"
6	"encoding/json"
7	"errors"
8	"fmt"
9	"io"
10	"os"
11	"os/exec"
12	"path/filepath"
13	"strings"
14	"sync"
15	"time"
16
17	"github.com/bnema/zut/packages/core"
18	"github.com/bnema/zut/packages/provider"
19)
20
21const (
22	worktreesIgnoreEntry           = "/.worktrees/"
23	worktreesUnanchoredIgnoreEntry = ".worktrees/"
24	defaultWorktreeRoot            = ".worktrees"
25	worktreeConfigKey              = "zut.worktrees.path"
26	maxCreateWorktreeGitOutput     = 64 * 1024
27	maxCreateWorktreeIgnoreBytes   = 1 << 20
28)
29
30var createWorktreeOperationMu sync.Mutex
31
32// CreateWorktreeTool creates a persistent branch checkout. A repository that
33// has neither a local worktree configuration nor an existing .worktrees
34// directory returns a bootstrap request without changing any files.
35type CreateWorktreeTool struct {
36	CWD     string
37	Sandbox *Sandbox
38
39	previewMu sync.Mutex
40	preview   *createWorktreePreview
41}
42
43type createWorktreeArgs struct {
44	Branch        string `json:"branch"`
45	BootstrapRoot string `json:"bootstrap_root,omitempty"`
46}
47
48const createWorktreeSchema = `{
49  "type":"object",
50  "properties":{
51    "branch":{
52      "type":"string",
53      "description":"Name of the new Git branch."
54    },
55    "bootstrap_root":{
56      "type":"string",
57      "description":"Only for an unconfigured repository: use .worktrees to initialize the repository-root default, or provide an absolute external worktree root. The choice is stored in local Git config. Omit it on the first call to request user guidance instead of changing files."
58    }
59  },
60  "required":["branch"]
61}`
62
63func (t *CreateWorktreeTool) Name() string { return "create_worktree" }
64func (t *CreateWorktreeTool) Description() string {
65	return "Create a persistent Git branch worktree. An unconfigured repository returns bootstrap guidance; ask the user for .worktrees or an absolute external root, then call again with bootstrap_root."
66}
67func (t *CreateWorktreeTool) Schema() json.RawMessage { return json.RawMessage(createWorktreeSchema) }
68
69// Preview reports the exact bootstrap request or checkout that Execute will
70// produce without modifying the repository.
71func (t *CreateWorktreeTool) Preview(ctx context.Context, raw json.RawMessage) (core.ToolResult, error) {
72	plan, err := t.plan(ctx, raw)
73	if err != nil {
74		return core.ToolResult{}, err
75	}
76	t.previewMu.Lock()
77	t.preview = &createWorktreePreview{raw: string(raw), plan: plan}
78	t.previewMu.Unlock()
79	return plan.preview, nil
80}
81
82func (t *CreateWorktreeTool) Execute(ctx context.Context, raw json.RawMessage, progress func(string)) (core.ToolResult, error) {
83	createWorktreeOperationMu.Lock()
84	defer createWorktreeOperationMu.Unlock()
85
86	plan, err := t.planForExecute(ctx, raw)
87	if err != nil {
88		return core.ToolResult{}, err
89	}
90	if plan.bootstrapRequired {
91		return plan.preview, nil
92	}
93	return executeCreateWorktreePlan(ctx, plan, progress)
94}
95
96func executeCreateWorktreePlan(ctx context.Context, plan createWorktreePlan, progress func(string)) (core.ToolResult, error) {
97	configSaved := false
98	var createdWorktreeParents []string
99	if plan.setConfig {
100		if _, err := createWorktreeGitOutput(ctx, plan.repoRoot, "config", "--local", "--replace-all", worktreeConfigKey, plan.configValue); err != nil {
101			return core.ToolResult{}, fmt.Errorf("create_worktree: save worktree root: %w", err)
102		}
103		configSaved = true
104	}
105	rollback := func(cause error) error {
106		cleanupCtx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
107		defer cancel()
108		if err := plan.cleanupFailedCheckout(cleanupCtx); err != nil {
109			cause = fmt.Errorf("%w; cleanup worktree: %v", cause, err)
110		}
111		if err := removeCreatedWorktreeParents(createdWorktreeParents); err != nil {
112			cause = fmt.Errorf("%w; remove created worktree directories: %v", cause, err)
113		}
114		if err := plan.ignore.restore(); err != nil {
115			cause = fmt.Errorf("%w; restore .gitignore: %v", cause, err)
116		}
117		if configSaved {
118			if err := plan.removeBootstrapConfig(cleanupCtx); err != nil {
119				cause = fmt.Errorf("%w; remove local worktree configuration: %v", cause, err)
120			}
121		}
122		return cause
123	}
124	if err := plan.ignore.apply(); err != nil {
125		return core.ToolResult{}, rollback(fmt.Errorf("create_worktree: update .gitignore: %w", err))
126	}
127
128	createdWorktreeParents, err := missingWorktreeParents(plan.worktreePath)
129	if err != nil {
130		return core.ToolResult{}, rollback(fmt.Errorf("create_worktree: inspect worktree directories: %w", err))
131	}
132	if err := os.MkdirAll(filepath.Dir(plan.worktreePath), 0o755); err != nil {
133		return core.ToolResult{}, rollback(fmt.Errorf("create_worktree: make worktree directory: %w", err))
134	}
135	if progress != nil {
136		progress("Creating Git worktree...\n")
137	}
138	if _, err := createWorktreeGitOutput(ctx, plan.repoRoot, "worktree", "add", "-b", plan.branch, plan.worktreePath, plan.base); err != nil {
139		return core.ToolResult{}, rollback(fmt.Errorf("create_worktree: create worktree: %w", err))
140	}
141
142	result := plan.preview
143	previewDetails, _ := plan.preview.Details.(map[string]any)
144	details := make(map[string]any, len(previewDetails)+1)
145	for key, value := range previewDetails {
146		details[key] = value
147	}
148	details["state"] = "created"
149	result.Details = details
150	result.Content = []provider.Content{provider.TextBlock{Text: strings.Replace(plan.previewText, "create worktree", "created worktree", 1)}}
151	return result, nil
152}
153
154type createWorktreePreview struct {
155	raw  string
156	plan createWorktreePlan
157}
158
159func (t *CreateWorktreeTool) planForExecute(ctx context.Context, raw json.RawMessage) (createWorktreePlan, error) {
160	t.previewMu.Lock()
161	cached := t.preview
162	t.preview = nil
163	t.previewMu.Unlock()
164
165	plan, err := t.plan(ctx, raw)
166	if err != nil {
167		return createWorktreePlan{}, err
168	}
169	if cached != nil && cached.raw == string(raw) && !sameCreateWorktreePlan(cached.plan, plan) {
170		return createWorktreePlan{}, errors.New("create_worktree: repository state changed since preview; inspect and retry")
171	}
172	return plan, nil
173}
174
175type createWorktreePlan struct {
176	repoRoot          string
177	branch            string
178	base              string
179	worktreeRoot      string
180	worktreePath      string
181	rootSource        string
182	configValue       string
183	setConfig         bool
184	bootstrapRequired bool
185	previewText       string
186	preview           core.ToolResult
187	ignore            worktreesIgnoreUpdate
188}
189
190func (t *CreateWorktreeTool) plan(ctx context.Context, raw json.RawMessage) (createWorktreePlan, error) {
191	var args createWorktreeArgs
192	if err := json.Unmarshal(raw, &args); err != nil {
193		return createWorktreePlan{}, fmt.Errorf("invalid args: %w", err)
194	}
195	branchInput := strings.TrimSpace(args.Branch)
196	if branchInput == "" {
197		return createWorktreePlan{}, errors.New("branch is required")
198	}
199	if strings.HasPrefix(branchInput, "-") || strings.Contains(branchInput, "@{") {
200		return createWorktreePlan{}, fmt.Errorf("create_worktree: invalid branch %q", branchInput)
201	}
202	if t.Sandbox == nil {
203		return createWorktreePlan{}, errors.New("create_worktree: sandbox is required")
204	}
205	if err := t.Sandbox.CheckBashPermission("git"); err != nil {
206		return createWorktreePlan{}, fmt.Errorf("create_worktree: %w", err)
207	}
208
209	cwd, err := t.accessibleCWD()
210	if err != nil {
211		return createWorktreePlan{}, err
212	}
213	candidateRoot, err := t.findGitRootCandidate(cwd)
214	if err != nil {
215		return createWorktreePlan{}, err
216	}
217	repoRoot, err := createWorktreeGitOutput(ctx, candidateRoot, "rev-parse", "--show-toplevel")
218	if err != nil {
219		return createWorktreePlan{}, fmt.Errorf("create_worktree: verify repository: %w", err)
220	}
221	repoRoot, err = canonical(repoRoot)
222	if err != nil {
223		return createWorktreePlan{}, fmt.Errorf("create_worktree: resolve repository root: %w", err)
224	}
225	if err := t.checkReadAccess(repoRoot); err != nil {
226		return createWorktreePlan{}, err
227	}
228	branch, err := createWorktreeGitOutput(ctx, repoRoot, "check-ref-format", "--branch", branchInput)
229	if err != nil {
230		return createWorktreePlan{}, fmt.Errorf("create_worktree: invalid branch %q: %w", branchInput, err)
231	}
232	if branch != branchInput {
233		return createWorktreePlan{}, fmt.Errorf("create_worktree: invalid branch %q", branchInput)
234	}
235	base, err := createWorktreeGitOutput(ctx, repoRoot, "rev-parse", "--verify", "HEAD^{commit}")
236	if err != nil {
237		return createWorktreePlan{}, fmt.Errorf("create_worktree: resolve current commit: %w", err)
238	}
239	commonDir, err := createWorktreeGitOutput(ctx, repoRoot, "rev-parse", "--git-common-dir")
240	if err != nil {
241		return createWorktreePlan{}, fmt.Errorf("create_worktree: resolve Git metadata: %w", err)
242	}
243	if !filepath.IsAbs(commonDir) {
244		commonDir = filepath.Join(repoRoot, commonDir)
245	}
246	commonDir, err = canonical(commonDir)
247	if err != nil {
248		return createWorktreePlan{}, fmt.Errorf("create_worktree: resolve Git metadata: %w", err)
249	}
250	if err := t.checkReadAccess(commonDir); err != nil {
251		return createWorktreePlan{}, err
252	}
253
254	configuredRoot, configured, err := createWorktreeGitConfigValue(ctx, repoRoot, worktreeConfigKey)
255	if err != nil {
256		return createWorktreePlan{}, fmt.Errorf("create_worktree: read local worktree configuration: %w", err)
257	}
258	bootstrapRoot := strings.TrimSpace(args.BootstrapRoot)
259
260	rootSource := "existing .worktrees directory"
261	configValue := ""
262	setConfig := false
263	defaultRoot := false
264	var worktreeRoot string
265	if configured {
266		worktreeRoot, defaultRoot, err = resolveConfiguredWorktreeRoot(repoRoot, configuredRoot)
267		if err != nil {
268			return createWorktreePlan{}, err
269		}
270		if bootstrapRoot != "" {
271			requestedRoot, requestedDefault, _, err := resolveBootstrapWorktreeRoot(repoRoot, bootstrapRoot)
272			if err != nil {
273				return createWorktreePlan{}, err
274			}
275			configuredCanonical, err := canonicalOrParent(worktreeRoot)
276			if err != nil {
277				return createWorktreePlan{}, fmt.Errorf("create_worktree: resolve configured worktree root: %w", err)
278			}
279			requestedCanonical, err := canonicalOrParent(requestedRoot)
280			if err != nil {
281				return createWorktreePlan{}, fmt.Errorf("create_worktree: resolve bootstrap_root: %w", err)
282			}
283			if defaultRoot != requestedDefault || configuredCanonical != requestedCanonical {
284				return createWorktreePlan{}, errors.New("create_worktree: worktree root is already configured; change the local Git configuration explicitly before bootstrapping again")
285			}
286		}
287		rootSource = "local Git config"
288	} else {
289		defaultPath := filepath.Join(repoRoot, defaultWorktreeRoot)
290		exists, err := inspectWorktreeRoot(defaultPath)
291		if err != nil {
292			return createWorktreePlan{}, err
293		}
294		if !exists && bootstrapRoot == "" {
295			return bootstrapRequiredPlan(branch, repoRoot), nil
296		}
297		if exists {
298			if bootstrapRoot != "" && bootstrapRoot != defaultWorktreeRoot {
299				return createWorktreePlan{}, errors.New("create_worktree: worktree root is already established; omit bootstrap_root")
300			}
301			worktreeRoot = defaultPath
302			defaultRoot = true
303		} else {
304			worktreeRoot, defaultRoot, configValue, err = resolveBootstrapWorktreeRoot(repoRoot, bootstrapRoot)
305			if err != nil {
306				return createWorktreePlan{}, err
307			}
308			setConfig = true
309			rootSource = "bootstrap selection"
310		}
311	}
312
313	worktreeRoot, err = validateWorktreeRoot(worktreeRoot, defaultRoot)
314	if err != nil {
315		return createWorktreePlan{}, err
316	}
317	worktreePath := filepath.Join(worktreeRoot, filepath.FromSlash(branch))
318	rootCanonical, err := canonicalOrParent(worktreeRoot)
319	if err != nil {
320		return createWorktreePlan{}, fmt.Errorf("create_worktree: resolve worktree root: %w", err)
321	}
322	if !defaultRoot && isUnder(repoRoot, rootCanonical) {
323		return createWorktreePlan{}, errors.New("create_worktree: external worktree root must be outside the repository")
324	}
325	pathCanonical, err := canonicalOrParent(worktreePath)
326	if err != nil {
327		return createWorktreePlan{}, fmt.Errorf("create_worktree: resolve worktree path: %w", err)
328	}
329	if !isUnder(rootCanonical, pathCanonical) {
330		return createWorktreePlan{}, errors.New("create_worktree: branch path escapes configured worktree root")
331	}
332
333	ignorePath := ""
334	accessPaths := []string{repoRoot, commonDir, worktreeRoot, worktreePath}
335	if defaultRoot {
336		ignorePath = filepath.Join(repoRoot, ".gitignore")
337		accessPaths = append(accessPaths, ignorePath)
338	}
339	if err := t.checkReadWriteAccess(accessPaths...); err != nil {
340		return createWorktreePlan{}, err
341	}
342	if _, err := os.Lstat(worktreePath); err == nil {
343		return createWorktreePlan{}, fmt.Errorf("create_worktree: worktree path %q already exists", filepath.ToSlash(worktreePath))
344	} else if !os.IsNotExist(err) {
345		return createWorktreePlan{}, fmt.Errorf("create_worktree: inspect worktree path: %w", err)
346	}
347	exists, err := createWorktreeBranchExists(ctx, repoRoot, branch)
348	if err != nil {
349		return createWorktreePlan{}, fmt.Errorf("create_worktree: check branch: %w", err)
350	}
351	if exists {
352		return createWorktreePlan{}, fmt.Errorf("create_worktree: branch %q already exists", branch)
353	}
354
355	ignore := worktreesIgnoreUpdate{}
356	ignoreAction := "not modified (external worktree root)"
357	if defaultRoot {
358		ignore, err = newWorktreesIgnoreUpdate(ignorePath)
359		if err != nil {
360			return createWorktreePlan{}, fmt.Errorf("create_worktree: read .gitignore: %w", err)
361		}
362		ignoreAction = "already ignores " + defaultWorktreeRoot + "/"
363		if ignore.changed {
364			ignoreAction = "add " + worktreesIgnoreEntry
365		}
366	}
367
368	displayRoot := worktreeRoot
369	if defaultRoot {
370		displayRoot = defaultWorktreeRoot
371	}
372	displayPath := displayWorktreePath(repoRoot, worktreePath, defaultRoot)
373	configAction := ""
374	if setConfig {
375		configAction = fmt.Sprintf("local config: set %s=%s\n", worktreeConfigKey, configValue)
376	}
377	previewText := fmt.Sprintf("create worktree\nbranch: %s\nbase: %s\nworktree root: %s\nroot source: %s\npath: %s\n%s.gitignore: %s\n", branch, base, filepath.ToSlash(displayRoot), rootSource, displayPath, configAction, ignoreAction)
378	return createWorktreePlan{
379		repoRoot:     repoRoot,
380		branch:       branch,
381		base:         base,
382		worktreeRoot: worktreeRoot,
383		worktreePath: worktreePath,
384		rootSource:   rootSource,
385		configValue:  configValue,
386		setConfig:    setConfig,
387		previewText:  previewText,
388		preview: core.ToolResult{
389			Content: []provider.Content{provider.TextBlock{Text: previewText}},
390			Details: map[string]any{
391				"state":           "ready",
392				"branch":          branch,
393				"base":            base,
394				"worktree_root":   displayRoot,
395				"root_source":     rootSource,
396				"path":            displayPath,
397				"bootstrap_saved": setConfig,
398			},
399		},
400		ignore: ignore,
401	}, nil
402}
403
404func bootstrapRequiredPlan(branch, repoRoot string) createWorktreePlan {
405	text := fmt.Sprintf("Worktree bootstrap is required.\nbranch: %s\nrepository: %s\nChoose a location with the user, then call create_worktree again with bootstrap_root set to .worktrees for the repository default or to an absolute external directory. This call made no changes.\n", branch, filepath.ToSlash(repoRoot))
406	return createWorktreePlan{
407		branch:            branch,
408		bootstrapRequired: true,
409		previewText:       text,
410		preview: core.ToolResult{
411			Content: []provider.Content{provider.TextBlock{Text: text}},
412			Details: map[string]any{
413				"state":                  "bootstrap_required",
414				"branch":                 branch,
415				"default_bootstrap_root": defaultWorktreeRoot,
416			},
417		},
418	}
419}
420
421func (t *CreateWorktreeTool) accessibleCWD() (string, error) {
422	cwd := t.CWD
423	if cwd == "" {
424		var err error
425		cwd, err = os.Getwd()
426		if err != nil {
427			return "", fmt.Errorf("create_worktree: working directory: %w", err)
428		}
429	}
430	cwd, err := canonical(cwd)
431	if err != nil {
432		return "", fmt.Errorf("create_worktree: resolve working directory: %w", err)
433	}
434	if err := t.checkReadAccess(cwd); err != nil {
435		return "", err
436	}
437	return cwd, nil
438}
439
440func (t *CreateWorktreeTool) findGitRootCandidate(cwd string) (string, error) {
441	for dir := cwd; ; dir = filepath.Dir(dir) {
442		if err := t.checkReadAccess(dir); err != nil {
443			return "", err
444		}
445		if _, err := os.Lstat(filepath.Join(dir, ".git")); err == nil {
446			return dir, nil
447		} else if !os.IsNotExist(err) {
448			return "", fmt.Errorf("create_worktree: inspect Git metadata: %w", err)
449		}
450		parent := filepath.Dir(dir)
451		if parent == dir {
452			break
453		}
454	}
455	return "", errors.New("create_worktree: working directory is not a Git repository")
456}
457
458func (t *CreateWorktreeTool) checkReadAccess(paths ...string) error {
459	for _, path := range paths {
460		if err := t.Sandbox.CheckReadPath(path); err != nil {
461			return fmt.Errorf("create_worktree: %w", err)
462		}
463	}
464	return nil
465}
466
467func (t *CreateWorktreeTool) checkReadWriteAccess(paths ...string) error {
468	for _, path := range paths {
469		if err := t.Sandbox.CheckReadPath(path); err != nil {
470			return fmt.Errorf("create_worktree: %w", err)
471		}
472		if err := t.Sandbox.CheckWritePath(path); err != nil {
473			return fmt.Errorf("create_worktree: %w", err)
474		}
475	}
476	return nil
477}
478
479func resolveConfiguredWorktreeRoot(repoRoot, value string) (string, bool, error) {
480	value = strings.TrimSpace(value)
481	if value == defaultWorktreeRoot {
482		return filepath.Join(repoRoot, defaultWorktreeRoot), true, nil
483	}
484	if !filepath.IsAbs(value) {
485		return "", false, errors.New("create_worktree: local worktree configuration must be .worktrees or an absolute external path")
486	}
487	return value, false, nil
488}
489
490func resolveBootstrapWorktreeRoot(repoRoot, value string) (string, bool, string, error) {
491	if value == defaultWorktreeRoot {
492		return filepath.Join(repoRoot, defaultWorktreeRoot), true, defaultWorktreeRoot, nil
493	}
494	if !filepath.IsAbs(value) {
495		return "", false, "", errors.New("create_worktree: bootstrap_root must be .worktrees or an absolute external path")
496	}
497	root, err := canonicalOrParent(value)
498	if err != nil {
499		return "", false, "", fmt.Errorf("create_worktree: resolve bootstrap_root: %w", err)
500	}
501	if isUnder(repoRoot, root) {
502		return "", false, "", errors.New("create_worktree: external bootstrap_root must be outside the repository")
503	}
504	return root, false, root, nil
505}
506
507func validateWorktreeRoot(path string, defaultRoot bool) (string, error) {
508	info, err := os.Lstat(path)
509	if os.IsNotExist(err) {
510		return path, nil
511	}
512	if err != nil {
513		return "", fmt.Errorf("create_worktree: inspect worktree root: %w", err)
514	}
515	if info.Mode()&os.ModeSymlink != 0 {
516		return "", errors.New("create_worktree: worktree root must not be a symbolic link")
517	}
518	if !info.IsDir() {
519		return "", errors.New("create_worktree: worktree root exists but is not a directory")
520	}
521	if defaultRoot && filepath.Base(path) != defaultWorktreeRoot {
522		return "", errors.New("create_worktree: invalid default worktree root")
523	}
524	return path, nil
525}
526
527func inspectWorktreeRoot(path string) (bool, error) {
528	info, err := os.Lstat(path)
529	if os.IsNotExist(err) {
530		return false, nil
531	}
532	if err != nil {
533		return false, fmt.Errorf("create_worktree: inspect .worktrees: %w", err)
534	}
535	if info.Mode()&os.ModeSymlink != 0 {
536		return false, errors.New("create_worktree: .worktrees must not be a symbolic link")
537	}
538	if !info.IsDir() {
539		return false, errors.New("create_worktree: .worktrees exists but is not a directory")
540	}
541	return true, nil
542}
543
544func displayWorktreePath(repoRoot, path string, defaultRoot bool) string {
545	if defaultRoot {
546		relative, err := filepath.Rel(repoRoot, path)
547		if err == nil {
548			return filepath.ToSlash(relative)
549		}
550	}
551	return filepath.ToSlash(path)
552}
553
554func sameCreateWorktreePlan(a, b createWorktreePlan) bool {
555	return a.bootstrapRequired == b.bootstrapRequired &&
556		a.repoRoot == b.repoRoot &&
557		a.branch == b.branch &&
558		a.base == b.base &&
559		a.worktreeRoot == b.worktreeRoot &&
560		a.worktreePath == b.worktreePath &&
561		a.rootSource == b.rootSource &&
562		a.configValue == b.configValue &&
563		a.setConfig == b.setConfig &&
564		a.ignore.path == b.ignore.path &&
565		a.ignore.existed == b.ignore.existed &&
566		a.ignore.changed == b.ignore.changed &&
567		bytes.Equal(a.ignore.before, b.ignore.before)
568}
569
570func missingWorktreeParents(worktreePath string) ([]string, error) {
571	var missing []string
572	for directory := filepath.Dir(worktreePath); ; directory = filepath.Dir(directory) {
573		info, err := os.Lstat(directory)
574		if err == nil {
575			if !info.IsDir() {
576				return nil, fmt.Errorf("%q is not a directory", directory)
577			}
578			break
579		}
580		if !os.IsNotExist(err) {
581			return nil, err
582		}
583		missing = append(missing, directory)
584		parent := filepath.Dir(directory)
585		if parent == directory {
586			return nil, errors.New("no existing parent directory")
587		}
588	}
589	for left, right := 0, len(missing)-1; left < right; left, right = left+1, right-1 {
590		missing[left], missing[right] = missing[right], missing[left]
591	}
592	return missing, nil
593}
594
595func removeCreatedWorktreeParents(parents []string) error {
596	for index := len(parents) - 1; index >= 0; index-- {
597		if err := os.Remove(parents[index]); err != nil && !os.IsNotExist(err) {
598			return err
599		}
600	}
601	return nil
602}
603
604func (p createWorktreePlan) removeBootstrapConfig(ctx context.Context) error {
605	value, configured, err := createWorktreeGitConfigValue(ctx, p.repoRoot, worktreeConfigKey)
606	if err != nil {
607		return err
608	}
609	if !configured {
610		return nil
611	}
612	if value != p.configValue {
613		return errors.New("local worktree configuration changed during creation")
614	}
615	_, err = createWorktreeGitOutput(ctx, p.repoRoot, "config", "--local", "--unset-all", worktreeConfigKey)
616	return err
617}
618
619func (p createWorktreePlan) cleanupFailedCheckout(ctx context.Context) error {
620	registered, err := createWorktreeRegistered(ctx, p.repoRoot, p.worktreePath)
621	if err != nil {
622		return err
623	}
624	var cleanupErr error
625	if registered {
626		if _, err := createWorktreeGitOutput(ctx, p.repoRoot, "worktree", "remove", "--force", p.worktreePath); err != nil {
627			cleanupErr = fmt.Errorf("remove registered worktree: %w", err)
628		}
629	}
630	branchHead, exists, err := createWorktreeGitRef(ctx, p.repoRoot, "refs/heads/"+p.branch)
631	if err == nil && exists && branchHead == p.base {
632		if _, err := createWorktreeGitOutput(ctx, p.repoRoot, "branch", "-D", p.branch); err != nil && cleanupErr == nil {
633			cleanupErr = fmt.Errorf("remove branch: %w", err)
634		}
635	}
636	if err != nil && cleanupErr == nil {
637		cleanupErr = err
638	}
639	return cleanupErr
640}
641
642func createWorktreeRegistered(ctx context.Context, repoRoot, worktreePath string) (bool, error) {
643	output, err := createWorktreeGitOutput(ctx, repoRoot, "worktree", "list", "--porcelain")
644	if err != nil {
645		return false, err
646	}
647	want, err := canonicalOrParent(worktreePath)
648	if err != nil {
649		return false, err
650	}
651	for _, line := range strings.Split(output, "\n") {
652		if !strings.HasPrefix(line, "worktree ") {
653			continue
654		}
655		path, err := canonicalOrParent(strings.TrimPrefix(line, "worktree "))
656		if err == nil && path == want {
657			return true, nil
658		}
659	}
660	return false, nil
661}
662
663func createWorktreeBranchExists(ctx context.Context, repoRoot, branch string) (bool, error) {
664	_, err := createWorktreeGitOutput(ctx, repoRoot, "show-ref", "--verify", "--quiet", "refs/heads/"+branch)
665	if err == nil {
666		return true, nil
667	}
668	if ctx.Err() != nil {
669		return false, ctx.Err()
670	}
671	if createWorktreeGitExitCode(err) == 1 {
672		return false, nil
673	}
674	return false, err
675}
676
677func createWorktreeGitRef(ctx context.Context, repoRoot, ref string) (string, bool, error) {
678	output, err := createWorktreeGitOutput(ctx, repoRoot, "rev-parse", "--verify", "--quiet", ref+"^{commit}")
679	if err == nil {
680		return output, true, nil
681	}
682	if createWorktreeGitExitCode(err) == 1 {
683		return "", false, nil
684	}
685	return "", false, err
686}
687
688func createWorktreeGitConfigValue(ctx context.Context, repoRoot, key string) (string, bool, error) {
689	output, err := createWorktreeGitOutput(ctx, repoRoot, "config", "--local", "--get", key)
690	if err == nil {
691		return output, true, nil
692	}
693	if createWorktreeGitExitCode(err) == 1 {
694		return "", false, nil
695	}
696	return "", false, err
697}
698
699type createWorktreeGitError struct {
700	args   []string
701	output string
702	err    error
703}
704
705func (e *createWorktreeGitError) Error() string {
706	message := strings.TrimSpace(e.output)
707	if message == "" {
708		message = e.err.Error()
709	}
710	return fmt.Sprintf("git %s: %s", strings.Join(e.args, " "), message)
711}
712
713func (e *createWorktreeGitError) Unwrap() error { return e.err }
714
715func createWorktreeGitExitCode(err error) int {
716	var exitErr *exec.ExitError
717	if errors.As(err, &exitErr) {
718		return exitErr.ExitCode()
719	}
720	return -1
721}
722
723func createWorktreeGitOutput(ctx context.Context, dir string, args ...string) (string, error) {
724	cmdArgs := append([]string{"-c", "core.hooksPath=" + os.DevNull, "-C", dir}, args...)
725	cmd := exec.CommandContext(ctx, "git", cmdArgs...)
726	cmd.Env = createWorktreeGitEnv()
727	setProcessGroup(cmd)
728
729	var stdout, output cappedGitOutput
730	cmd.Stdout = io.MultiWriter(&stdout, &output)
731	cmd.Stderr = &output
732	if err := cmd.Start(); err != nil {
733		return "", fmt.Errorf("start git %s: %w", strings.Join(args, " "), err)
734	}
735	watchDone := make(chan struct{})
736	watchStopped := make(chan struct{})
737	go func() {
738		defer close(watchStopped)
739		select {
740		case <-ctx.Done():
741			killProcessGroup(cmd)
742		case <-watchDone:
743		}
744	}()
745	waitErr := cmd.Wait()
746	close(watchDone)
747	<-watchStopped
748	if waitErr != nil {
749		if ctx.Err() != nil {
750			return "", ctx.Err()
751		}
752		return "", &createWorktreeGitError{args: args, output: output.String(), err: waitErr}
753	}
754	return strings.TrimSpace(stdout.String()), nil
755}
756
757type cappedGitOutput struct {
758	mu        sync.Mutex
759	buffer    bytes.Buffer
760	truncated bool
761}
762
763func (b *cappedGitOutput) Write(p []byte) (int, error) {
764	b.mu.Lock()
765	defer b.mu.Unlock()
766	if room := maxCreateWorktreeGitOutput - b.buffer.Len(); room > 0 {
767		if len(p) > room {
768			_, _ = b.buffer.Write(p[:room])
769			b.truncated = true
770		} else {
771			_, _ = b.buffer.Write(p)
772		}
773	} else {
774		b.truncated = true
775	}
776	return len(p), nil
777}
778
779func (b *cappedGitOutput) String() string {
780	b.mu.Lock()
781	defer b.mu.Unlock()
782	output := b.buffer.String()
783	if b.truncated {
784		output += "\n... [git output truncated]"
785	}
786	return output
787}
788
789func createWorktreeGitEnv() []string {
790	env := make([]string, 0, len(os.Environ())+5)
791	for _, entry := range os.Environ() {
792		key, _, _ := strings.Cut(entry, "=")
793		if strings.HasPrefix(key, "GIT_") || key == "SSH_ASKPASS" {
794			continue
795		}
796		env = append(env, entry)
797	}
798	return append(env,
799		"GIT_CONFIG_NOSYSTEM=1",
800		"GIT_CONFIG_GLOBAL="+os.DevNull,
801		"GIT_TERMINAL_PROMPT=0",
802		"GIT_ASKPASS=",
803		"SSH_ASKPASS=",
804	)
805}
806
807type worktreesIgnoreUpdate struct {
808	path    string
809	before  []byte
810	after   []byte
811	existed bool
812	mode    os.FileMode
813	changed bool
814}
815
816func newWorktreesIgnoreUpdate(path string) (worktreesIgnoreUpdate, error) {
817	contents, mode, existed, err := readWorktreesIgnore(path)
818	if err != nil {
819		return worktreesIgnoreUpdate{}, err
820	}
821	for _, line := range strings.Split(string(contents), "\n") {
822		switch strings.TrimSuffix(line, "\r") {
823		case worktreesIgnoreEntry, worktreesUnanchoredIgnoreEntry:
824			return worktreesIgnoreUpdate{path: path, before: contents, existed: existed, mode: mode}, nil
825		}
826	}
827	lineEnding := "\n"
828	if bytes.Contains(contents, []byte("\r\n")) {
829		lineEnding = "\r\n"
830	}
831	after := append([]byte(nil), contents...)
832	if len(after) > 0 && !strings.HasSuffix(string(after), "\n") {
833		after = append(after, lineEnding...)
834	}
835	after = append(after, []byte(worktreesIgnoreEntry+lineEnding)...)
836	return worktreesIgnoreUpdate{path: path, before: contents, after: after, existed: existed, mode: mode, changed: true}, nil
837}
838
839func (u worktreesIgnoreUpdate) apply() error {
840	if !u.changed {
841		return nil
842	}
843	current, _, existed, err := readWorktreesIgnore(u.path)
844	if err != nil {
845		return err
846	}
847	if existed != u.existed || !bytes.Equal(current, u.before) {
848		return errors.New(".gitignore changed since preview; inspect it and retry")
849	}
850	return writeWorktreesIgnore(u.path, u.after, u.mode)
851}
852
853func (u worktreesIgnoreUpdate) restore() error {
854	if !u.changed {
855		return nil
856	}
857	current, _, existed, err := readWorktreesIgnore(u.path)
858	if err != nil {
859		return err
860	}
861	if !existed || !bytes.Equal(current, u.after) {
862		return nil
863	}
864	if !u.existed {
865		if err := os.Remove(u.path); err != nil && !os.IsNotExist(err) {
866			return err
867		}
868		return nil
869	}
870	return writeWorktreesIgnore(u.path, u.before, u.mode)
871}
872
873func readWorktreesIgnore(path string) ([]byte, os.FileMode, bool, error) {
874	info, err := os.Lstat(path)
875	if os.IsNotExist(err) {
876		return nil, 0o644, false, nil
877	}
878	if err != nil {
879		return nil, 0, false, err
880	}
881	if info.Mode()&os.ModeSymlink != 0 {
882		return nil, 0, false, errors.New(".gitignore must not be a symbolic link")
883	}
884	if !info.Mode().IsRegular() {
885		return nil, 0, false, errors.New(".gitignore must be a regular file")
886	}
887	if info.Size() > maxCreateWorktreeIgnoreBytes {
888		return nil, 0, false, fmt.Errorf(".gitignore exceeds %d bytes", maxCreateWorktreeIgnoreBytes)
889	}
890	file, err := os.Open(path)
891	if err != nil {
892		return nil, 0, false, err
893	}
894	defer file.Close()
895	contents, err := io.ReadAll(io.LimitReader(file, maxCreateWorktreeIgnoreBytes+1))
896	if err != nil {
897		return nil, 0, false, err
898	}
899	if len(contents) > maxCreateWorktreeIgnoreBytes {
900		return nil, 0, false, fmt.Errorf(".gitignore exceeds %d bytes", maxCreateWorktreeIgnoreBytes)
901	}
902	return contents, info.Mode().Perm(), true, nil
903}
904
905func writeWorktreesIgnore(path string, contents []byte, mode os.FileMode) error {
906	file, err := os.CreateTemp(filepath.Dir(path), ".zut-gitignore-*")
907	if err != nil {
908		return err
909	}
910	tempPath := file.Name()
911	defer os.Remove(tempPath)
912	if err := file.Chmod(mode); err != nil {
913		_ = file.Close()
914		return err
915	}
916	if _, err := file.Write(contents); err != nil {
917		_ = file.Close()
918		return err
919	}
920	if err := file.Close(); err != nil {
921		return err
922	}
923	return os.Rename(tempPath, path)
924}

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