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}