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}