v0.10.0 8a8e2b3

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

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