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