v0.8.0 a28c5f2

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	if configured && bootstrapRoot != "" {
259		return createWorktreePlan{}, errors.New("create_worktree: worktree root is already configured; change the local Git configuration explicitly before bootstrapping again")
260	}
261
262	rootSource := "existing .worktrees directory"
263	configValue := ""
264	setConfig := false
265	defaultRoot := false
266	var worktreeRoot string
267	if configured {
268		worktreeRoot, defaultRoot, err = resolveConfiguredWorktreeRoot(repoRoot, configuredRoot)
269		if err != nil {
270			return createWorktreePlan{}, err
271		}
272		rootSource = "local Git config"
273	} else {
274		defaultPath := filepath.Join(repoRoot, defaultWorktreeRoot)
275		exists, err := inspectWorktreeRoot(defaultPath)
276		if err != nil {
277			return createWorktreePlan{}, err
278		}
279		if !exists && bootstrapRoot == "" {
280			return bootstrapRequiredPlan(branch, repoRoot), nil
281		}
282		if exists {
283			if bootstrapRoot != "" {
284				return createWorktreePlan{}, errors.New("create_worktree: worktree root is already established; omit bootstrap_root")
285			}
286			worktreeRoot = defaultPath
287			defaultRoot = true
288		} else {
289			worktreeRoot, defaultRoot, configValue, err = resolveBootstrapWorktreeRoot(repoRoot, bootstrapRoot)
290			if err != nil {
291				return createWorktreePlan{}, err
292			}
293			setConfig = true
294			rootSource = "bootstrap selection"
295		}
296	}
297
298	worktreeRoot, err = validateWorktreeRoot(worktreeRoot, defaultRoot)
299	if err != nil {
300		return createWorktreePlan{}, err
301	}
302	worktreePath := filepath.Join(worktreeRoot, filepath.FromSlash(branch))
303	rootCanonical, err := canonicalOrParent(worktreeRoot)
304	if err != nil {
305		return createWorktreePlan{}, fmt.Errorf("create_worktree: resolve worktree root: %w", err)
306	}
307	if !defaultRoot && isUnder(repoRoot, rootCanonical) {
308		return createWorktreePlan{}, errors.New("create_worktree: external worktree root must be outside the repository")
309	}
310	pathCanonical, err := canonicalOrParent(worktreePath)
311	if err != nil {
312		return createWorktreePlan{}, fmt.Errorf("create_worktree: resolve worktree path: %w", err)
313	}
314	if !isUnder(rootCanonical, pathCanonical) {
315		return createWorktreePlan{}, errors.New("create_worktree: branch path escapes configured worktree root")
316	}
317
318	ignorePath := ""
319	accessPaths := []string{repoRoot, commonDir, worktreeRoot, worktreePath}
320	if defaultRoot {
321		ignorePath = filepath.Join(repoRoot, ".gitignore")
322		accessPaths = append(accessPaths, ignorePath)
323	}
324	if err := t.checkReadWriteAccess(accessPaths...); err != nil {
325		return createWorktreePlan{}, err
326	}
327	if _, err := os.Lstat(worktreePath); err == nil {
328		return createWorktreePlan{}, fmt.Errorf("create_worktree: worktree path %q already exists", filepath.ToSlash(worktreePath))
329	} else if !os.IsNotExist(err) {
330		return createWorktreePlan{}, fmt.Errorf("create_worktree: inspect worktree path: %w", err)
331	}
332	exists, err := createWorktreeBranchExists(ctx, repoRoot, branch)
333	if err != nil {
334		return createWorktreePlan{}, fmt.Errorf("create_worktree: check branch: %w", err)
335	}
336	if exists {
337		return createWorktreePlan{}, fmt.Errorf("create_worktree: branch %q already exists", branch)
338	}
339
340	ignore := worktreesIgnoreUpdate{}
341	ignoreAction := "not modified (external worktree root)"
342	if defaultRoot {
343		ignore, err = newWorktreesIgnoreUpdate(ignorePath)
344		if err != nil {
345			return createWorktreePlan{}, fmt.Errorf("create_worktree: read .gitignore: %w", err)
346		}
347		ignoreAction = "already contains " + worktreesIgnoreEntry
348		if ignore.changed {
349			ignoreAction = "add " + worktreesIgnoreEntry
350		}
351	}
352
353	displayRoot := worktreeRoot
354	if defaultRoot {
355		displayRoot = defaultWorktreeRoot
356	}
357	displayPath := displayWorktreePath(repoRoot, worktreePath, defaultRoot)
358	configAction := ""
359	if setConfig {
360		configAction = fmt.Sprintf("local config: set %s=%s\n", worktreeConfigKey, configValue)
361	}
362	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)
363	return createWorktreePlan{
364		repoRoot:     repoRoot,
365		branch:       branch,
366		base:         base,
367		worktreeRoot: worktreeRoot,
368		worktreePath: worktreePath,
369		rootSource:   rootSource,
370		configValue:  configValue,
371		setConfig:    setConfig,
372		previewText:  previewText,
373		preview: core.ToolResult{
374			Content: []provider.Content{provider.TextBlock{Text: previewText}},
375			Details: map[string]any{
376				"state":           "ready",
377				"branch":          branch,
378				"base":            base,
379				"worktree_root":   displayRoot,
380				"root_source":     rootSource,
381				"path":            displayPath,
382				"bootstrap_saved": setConfig,
383			},
384		},
385		ignore: ignore,
386	}, nil
387}
388
389func bootstrapRequiredPlan(branch, repoRoot string) createWorktreePlan {
390	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))
391	return createWorktreePlan{
392		branch:            branch,
393		bootstrapRequired: true,
394		previewText:       text,
395		preview: core.ToolResult{
396			Content: []provider.Content{provider.TextBlock{Text: text}},
397			Details: map[string]any{
398				"state":                  "bootstrap_required",
399				"branch":                 branch,
400				"default_bootstrap_root": defaultWorktreeRoot,
401			},
402		},
403	}
404}
405
406func (t *CreateWorktreeTool) accessibleCWD() (string, error) {
407	cwd := t.CWD
408	if cwd == "" {
409		var err error
410		cwd, err = os.Getwd()
411		if err != nil {
412			return "", fmt.Errorf("create_worktree: working directory: %w", err)
413		}
414	}
415	cwd, err := canonical(cwd)
416	if err != nil {
417		return "", fmt.Errorf("create_worktree: resolve working directory: %w", err)
418	}
419	if err := t.checkReadAccess(cwd); err != nil {
420		return "", err
421	}
422	return cwd, nil
423}
424
425func (t *CreateWorktreeTool) findGitRootCandidate(cwd string) (string, error) {
426	for dir := cwd; ; dir = filepath.Dir(dir) {
427		if err := t.checkReadAccess(dir); err != nil {
428			return "", err
429		}
430		if _, err := os.Lstat(filepath.Join(dir, ".git")); err == nil {
431			return dir, nil
432		} else if !os.IsNotExist(err) {
433			return "", fmt.Errorf("create_worktree: inspect Git metadata: %w", err)
434		}
435		parent := filepath.Dir(dir)
436		if parent == dir {
437			break
438		}
439	}
440	return "", errors.New("create_worktree: working directory is not a Git repository")
441}
442
443func (t *CreateWorktreeTool) checkReadAccess(paths ...string) error {
444	for _, path := range paths {
445		if err := t.Sandbox.CheckReadPath(path); err != nil {
446			return fmt.Errorf("create_worktree: %w", err)
447		}
448	}
449	return nil
450}
451
452func (t *CreateWorktreeTool) checkReadWriteAccess(paths ...string) error {
453	for _, path := range paths {
454		if err := t.Sandbox.CheckReadPath(path); err != nil {
455			return fmt.Errorf("create_worktree: %w", err)
456		}
457		if err := t.Sandbox.CheckWritePath(path); err != nil {
458			return fmt.Errorf("create_worktree: %w", err)
459		}
460	}
461	return nil
462}
463
464func resolveConfiguredWorktreeRoot(repoRoot, value string) (string, bool, error) {
465	value = strings.TrimSpace(value)
466	if value == defaultWorktreeRoot {
467		return filepath.Join(repoRoot, defaultWorktreeRoot), true, nil
468	}
469	if !filepath.IsAbs(value) {
470		return "", false, errors.New("create_worktree: local worktree configuration must be .worktrees or an absolute external path")
471	}
472	return value, false, nil
473}
474
475func resolveBootstrapWorktreeRoot(repoRoot, value string) (string, bool, string, error) {
476	if value == defaultWorktreeRoot {
477		return filepath.Join(repoRoot, defaultWorktreeRoot), true, defaultWorktreeRoot, nil
478	}
479	if !filepath.IsAbs(value) {
480		return "", false, "", errors.New("create_worktree: bootstrap_root must be .worktrees or an absolute external path")
481	}
482	root, err := canonicalOrParent(value)
483	if err != nil {
484		return "", false, "", fmt.Errorf("create_worktree: resolve bootstrap_root: %w", err)
485	}
486	if isUnder(repoRoot, root) {
487		return "", false, "", errors.New("create_worktree: external bootstrap_root must be outside the repository")
488	}
489	return root, false, root, nil
490}
491
492func validateWorktreeRoot(path string, defaultRoot bool) (string, error) {
493	info, err := os.Lstat(path)
494	if os.IsNotExist(err) {
495		return path, nil
496	}
497	if err != nil {
498		return "", fmt.Errorf("create_worktree: inspect worktree root: %w", err)
499	}
500	if info.Mode()&os.ModeSymlink != 0 {
501		return "", errors.New("create_worktree: worktree root must not be a symbolic link")
502	}
503	if !info.IsDir() {
504		return "", errors.New("create_worktree: worktree root exists but is not a directory")
505	}
506	if defaultRoot && filepath.Base(path) != defaultWorktreeRoot {
507		return "", errors.New("create_worktree: invalid default worktree root")
508	}
509	return path, nil
510}
511
512func inspectWorktreeRoot(path string) (bool, error) {
513	info, err := os.Lstat(path)
514	if os.IsNotExist(err) {
515		return false, nil
516	}
517	if err != nil {
518		return false, fmt.Errorf("create_worktree: inspect .worktrees: %w", err)
519	}
520	if info.Mode()&os.ModeSymlink != 0 {
521		return false, errors.New("create_worktree: .worktrees must not be a symbolic link")
522	}
523	if !info.IsDir() {
524		return false, errors.New("create_worktree: .worktrees exists but is not a directory")
525	}
526	return true, nil
527}
528
529func displayWorktreePath(repoRoot, path string, defaultRoot bool) string {
530	if defaultRoot {
531		relative, err := filepath.Rel(repoRoot, path)
532		if err == nil {
533			return filepath.ToSlash(relative)
534		}
535	}
536	return filepath.ToSlash(path)
537}
538
539func sameCreateWorktreePlan(a, b createWorktreePlan) bool {
540	return a.bootstrapRequired == b.bootstrapRequired &&
541		a.repoRoot == b.repoRoot &&
542		a.branch == b.branch &&
543		a.base == b.base &&
544		a.worktreeRoot == b.worktreeRoot &&
545		a.worktreePath == b.worktreePath &&
546		a.rootSource == b.rootSource &&
547		a.configValue == b.configValue &&
548		a.setConfig == b.setConfig &&
549		a.ignore.path == b.ignore.path &&
550		a.ignore.existed == b.ignore.existed &&
551		a.ignore.changed == b.ignore.changed &&
552		bytes.Equal(a.ignore.before, b.ignore.before)
553}
554
555func missingWorktreeParents(worktreePath string) ([]string, error) {
556	var missing []string
557	for directory := filepath.Dir(worktreePath); ; directory = filepath.Dir(directory) {
558		info, err := os.Lstat(directory)
559		if err == nil {
560			if !info.IsDir() {
561				return nil, fmt.Errorf("%q is not a directory", directory)
562			}
563			break
564		}
565		if !os.IsNotExist(err) {
566			return nil, err
567		}
568		missing = append(missing, directory)
569		parent := filepath.Dir(directory)
570		if parent == directory {
571			return nil, errors.New("no existing parent directory")
572		}
573	}
574	for left, right := 0, len(missing)-1; left < right; left, right = left+1, right-1 {
575		missing[left], missing[right] = missing[right], missing[left]
576	}
577	return missing, nil
578}
579
580func removeCreatedWorktreeParents(parents []string) error {
581	for index := len(parents) - 1; index >= 0; index-- {
582		if err := os.Remove(parents[index]); err != nil && !os.IsNotExist(err) {
583			return err
584		}
585	}
586	return nil
587}
588
589func (p createWorktreePlan) removeBootstrapConfig(ctx context.Context) error {
590	value, configured, err := createWorktreeGitConfigValue(ctx, p.repoRoot, worktreeConfigKey)
591	if err != nil {
592		return err
593	}
594	if !configured {
595		return nil
596	}
597	if value != p.configValue {
598		return errors.New("local worktree configuration changed during creation")
599	}
600	_, err = createWorktreeGitOutput(ctx, p.repoRoot, "config", "--local", "--unset-all", worktreeConfigKey)
601	return err
602}
603
604func (p createWorktreePlan) cleanupFailedCheckout(ctx context.Context) error {
605	registered, err := createWorktreeRegistered(ctx, p.repoRoot, p.worktreePath)
606	if err != nil {
607		return err
608	}
609	var cleanupErr error
610	if registered {
611		if _, err := createWorktreeGitOutput(ctx, p.repoRoot, "worktree", "remove", "--force", p.worktreePath); err != nil {
612			cleanupErr = fmt.Errorf("remove registered worktree: %w", err)
613		}
614	}
615	branchHead, exists, err := createWorktreeGitRef(ctx, p.repoRoot, "refs/heads/"+p.branch)
616	if err == nil && exists && branchHead == p.base {
617		if _, err := createWorktreeGitOutput(ctx, p.repoRoot, "branch", "-D", p.branch); err != nil && cleanupErr == nil {
618			cleanupErr = fmt.Errorf("remove branch: %w", err)
619		}
620	}
621	if err != nil && cleanupErr == nil {
622		cleanupErr = err
623	}
624	return cleanupErr
625}
626
627func createWorktreeRegistered(ctx context.Context, repoRoot, worktreePath string) (bool, error) {
628	output, err := createWorktreeGitOutput(ctx, repoRoot, "worktree", "list", "--porcelain")
629	if err != nil {
630		return false, err
631	}
632	want, err := canonicalOrParent(worktreePath)
633	if err != nil {
634		return false, err
635	}
636	for _, line := range strings.Split(output, "\n") {
637		if !strings.HasPrefix(line, "worktree ") {
638			continue
639		}
640		path, err := canonicalOrParent(strings.TrimPrefix(line, "worktree "))
641		if err == nil && path == want {
642			return true, nil
643		}
644	}
645	return false, nil
646}
647
648func createWorktreeBranchExists(ctx context.Context, repoRoot, branch string) (bool, error) {
649	_, err := createWorktreeGitOutput(ctx, repoRoot, "show-ref", "--verify", "--quiet", "refs/heads/"+branch)
650	if err == nil {
651		return true, nil
652	}
653	if ctx.Err() != nil {
654		return false, ctx.Err()
655	}
656	if createWorktreeGitExitCode(err) == 1 {
657		return false, nil
658	}
659	return false, err
660}
661
662func createWorktreeGitRef(ctx context.Context, repoRoot, ref string) (string, bool, error) {
663	output, err := createWorktreeGitOutput(ctx, repoRoot, "rev-parse", "--verify", "--quiet", ref+"^{commit}")
664	if err == nil {
665		return output, true, nil
666	}
667	if createWorktreeGitExitCode(err) == 1 {
668		return "", false, nil
669	}
670	return "", false, err
671}
672
673func createWorktreeGitConfigValue(ctx context.Context, repoRoot, key string) (string, bool, error) {
674	output, err := createWorktreeGitOutput(ctx, repoRoot, "config", "--local", "--get", key)
675	if err == nil {
676		return output, true, nil
677	}
678	if createWorktreeGitExitCode(err) == 1 {
679		return "", false, nil
680	}
681	return "", false, err
682}
683
684type createWorktreeGitError struct {
685	args   []string
686	output string
687	err    error
688}
689
690func (e *createWorktreeGitError) Error() string {
691	message := strings.TrimSpace(e.output)
692	if message == "" {
693		message = e.err.Error()
694	}
695	return fmt.Sprintf("git %s: %s", strings.Join(e.args, " "), message)
696}
697
698func (e *createWorktreeGitError) Unwrap() error { return e.err }
699
700func createWorktreeGitExitCode(err error) int {
701	var exitErr *exec.ExitError
702	if errors.As(err, &exitErr) {
703		return exitErr.ExitCode()
704	}
705	return -1
706}
707
708func createWorktreeGitOutput(ctx context.Context, dir string, args ...string) (string, error) {
709	cmdArgs := append([]string{"-c", "core.hooksPath=" + os.DevNull, "-C", dir}, args...)
710	cmd := exec.CommandContext(ctx, "git", cmdArgs...)
711	cmd.Env = createWorktreeGitEnv()
712	setProcessGroup(cmd)
713
714	var stdout, output cappedGitOutput
715	cmd.Stdout = io.MultiWriter(&stdout, &output)
716	cmd.Stderr = &output
717	if err := cmd.Start(); err != nil {
718		return "", fmt.Errorf("start git %s: %w", strings.Join(args, " "), err)
719	}
720	watchDone := make(chan struct{})
721	watchStopped := make(chan struct{})
722	go func() {
723		defer close(watchStopped)
724		select {
725		case <-ctx.Done():
726			killProcessGroup(cmd)
727		case <-watchDone:
728		}
729	}()
730	waitErr := cmd.Wait()
731	close(watchDone)
732	<-watchStopped
733	if waitErr != nil {
734		if ctx.Err() != nil {
735			return "", ctx.Err()
736		}
737		return "", &createWorktreeGitError{args: args, output: output.String(), err: waitErr}
738	}
739	return strings.TrimSpace(stdout.String()), nil
740}
741
742type cappedGitOutput struct {
743	mu        sync.Mutex
744	buffer    bytes.Buffer
745	truncated bool
746}
747
748func (b *cappedGitOutput) Write(p []byte) (int, error) {
749	b.mu.Lock()
750	defer b.mu.Unlock()
751	if room := maxCreateWorktreeGitOutput - b.buffer.Len(); room > 0 {
752		if len(p) > room {
753			_, _ = b.buffer.Write(p[:room])
754			b.truncated = true
755		} else {
756			_, _ = b.buffer.Write(p)
757		}
758	} else {
759		b.truncated = true
760	}
761	return len(p), nil
762}
763
764func (b *cappedGitOutput) String() string {
765	b.mu.Lock()
766	defer b.mu.Unlock()
767	output := b.buffer.String()
768	if b.truncated {
769		output += "\n... [git output truncated]"
770	}
771	return output
772}
773
774func createWorktreeGitEnv() []string {
775	env := make([]string, 0, len(os.Environ())+5)
776	for _, entry := range os.Environ() {
777		key, _, _ := strings.Cut(entry, "=")
778		if strings.HasPrefix(key, "GIT_") || key == "SSH_ASKPASS" {
779			continue
780		}
781		env = append(env, entry)
782	}
783	return append(env,
784		"GIT_CONFIG_NOSYSTEM=1",
785		"GIT_CONFIG_GLOBAL="+os.DevNull,
786		"GIT_TERMINAL_PROMPT=0",
787		"GIT_ASKPASS=",
788		"SSH_ASKPASS=",
789	)
790}
791
792type worktreesIgnoreUpdate struct {
793	path    string
794	before  []byte
795	after   []byte
796	existed bool
797	mode    os.FileMode
798	changed bool
799}
800
801func newWorktreesIgnoreUpdate(path string) (worktreesIgnoreUpdate, error) {
802	contents, mode, existed, err := readWorktreesIgnore(path)
803	if err != nil {
804		return worktreesIgnoreUpdate{}, err
805	}
806	for _, line := range strings.Split(string(contents), "\n") {
807		if strings.TrimSuffix(line, "\r") == worktreesIgnoreEntry {
808			return worktreesIgnoreUpdate{path: path, before: contents, existed: existed, mode: mode}, nil
809		}
810	}
811	lineEnding := "\n"
812	if bytes.Contains(contents, []byte("\r\n")) {
813		lineEnding = "\r\n"
814	}
815	after := append([]byte(nil), contents...)
816	if len(after) > 0 && !strings.HasSuffix(string(after), "\n") {
817		after = append(after, lineEnding...)
818	}
819	after = append(after, []byte(worktreesIgnoreEntry+lineEnding)...)
820	return worktreesIgnoreUpdate{path: path, before: contents, after: after, existed: existed, mode: mode, changed: true}, nil
821}
822
823func (u worktreesIgnoreUpdate) apply() error {
824	if !u.changed {
825		return nil
826	}
827	current, _, existed, err := readWorktreesIgnore(u.path)
828	if err != nil {
829		return err
830	}
831	if existed != u.existed || !bytes.Equal(current, u.before) {
832		return errors.New(".gitignore changed since preview; inspect it and retry")
833	}
834	return writeWorktreesIgnore(u.path, u.after, u.mode)
835}
836
837func (u worktreesIgnoreUpdate) restore() 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 || !bytes.Equal(current, u.after) {
846		return nil
847	}
848	if !u.existed {
849		if err := os.Remove(u.path); err != nil && !os.IsNotExist(err) {
850			return err
851		}
852		return nil
853	}
854	return writeWorktreesIgnore(u.path, u.before, u.mode)
855}
856
857func readWorktreesIgnore(path string) ([]byte, os.FileMode, bool, error) {
858	info, err := os.Lstat(path)
859	if os.IsNotExist(err) {
860		return nil, 0o644, false, nil
861	}
862	if err != nil {
863		return nil, 0, false, err
864	}
865	if info.Mode()&os.ModeSymlink != 0 {
866		return nil, 0, false, errors.New(".gitignore must not be a symbolic link")
867	}
868	if !info.Mode().IsRegular() {
869		return nil, 0, false, errors.New(".gitignore must be a regular file")
870	}
871	if info.Size() > maxCreateWorktreeIgnoreBytes {
872		return nil, 0, false, fmt.Errorf(".gitignore exceeds %d bytes", maxCreateWorktreeIgnoreBytes)
873	}
874	file, err := os.Open(path)
875	if err != nil {
876		return nil, 0, false, err
877	}
878	defer file.Close()
879	contents, err := io.ReadAll(io.LimitReader(file, maxCreateWorktreeIgnoreBytes+1))
880	if err != nil {
881		return nil, 0, false, err
882	}
883	if len(contents) > maxCreateWorktreeIgnoreBytes {
884		return nil, 0, false, fmt.Errorf(".gitignore exceeds %d bytes", maxCreateWorktreeIgnoreBytes)
885	}
886	return contents, info.Mode().Perm(), true, nil
887}
888
889func writeWorktreesIgnore(path string, contents []byte, mode os.FileMode) error {
890	file, err := os.CreateTemp(filepath.Dir(path), ".zut-gitignore-*")
891	if err != nil {
892		return err
893	}
894	tempPath := file.Name()
895	defer os.Remove(tempPath)
896	if err := file.Chmod(mode); err != nil {
897		_ = file.Close()
898		return err
899	}
900	if _, err := file.Write(contents); err != nil {
901		_ = file.Close()
902		return err
903	}
904	if err := file.Close(); err != nil {
905		return err
906	}
907	return os.Rename(tempPath, path)
908}

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 477

↑↓ moveenter openesc close