1package tools
2
3import (
4 "bytes"
5 "context"
6 "crypto/sha256"
7 "encoding/hex"
8 "encoding/json"
9 "errors"
10 "fmt"
11 "io"
12 "os"
13 "os/exec"
14 "path/filepath"
15 "strings"
16 "sync"
17 "time"
18
19 "github.com/bnema/zut/packages/core"
20 "github.com/bnema/zut/packages/provider"
21)
22
23const (
24 worktreeConfigKey = "zut.worktrees.path"
25 maxCreateWorktreeGitOutput = 64 * 1024
26 // worktreeRepoIDHashLength keeps the hash short enough to read but wide
27 // enough that distinct repositories do not collide in practice.
28 worktreeRepoIDHashLength = 12
29 worktreeRepoSlugMaxLen = 40
30)
31
32var createWorktreeOperationMu sync.Mutex
33
34// CreateWorktreeTool creates a persistent branch checkout. An unconfigured
35// repository checks out under a stable per-repository directory beneath
36// DefaultRoot. A local zut.worktrees.path Git configuration entry overrides
37// that root for the repository.
38type CreateWorktreeTool struct {
39 CWD string
40 Sandbox *Sandbox
41 DefaultRoot string
42
43 previewMu sync.Mutex
44 preview *createWorktreePreview
45}
46
47type createWorktreeArgs struct {
48 Branch string `json:"branch"`
49 Base string `json:"base,omitempty"`
50}
51
52const createWorktreeSchema = `{
53 "type":"object",
54 "properties":{
55 "branch":{
56 "type":"string",
57 "description":"Name of the new Git branch."
58 },
59 "base":{
60 "type":"string",
61 "description":"Branch name, tag, or commit to start the new branch from. Defaults to the current HEAD. Use a feature branch to create a sub-worktree based on it."
62 }
63 },
64 "required":["branch"]
65}`
66
67func (t *CreateWorktreeTool) Name() string { return "create_worktree" }
68func (t *CreateWorktreeTool) Description() string {
69 return "Create a persistent Git branch worktree under the repository's configured worktree root or zut's global worktrees root."
70}
71func (t *CreateWorktreeTool) Schema() json.RawMessage { return json.RawMessage(createWorktreeSchema) }
72
73// Preview reports the checkout that Execute will produce without modifying the
74// repository or filesystem.
75func (t *CreateWorktreeTool) Preview(ctx context.Context, raw json.RawMessage) (core.ToolResult, error) {
76 plan, err := t.plan(ctx, raw)
77 if err != nil {
78 return core.ToolResult{}, err
79 }
80 t.previewMu.Lock()
81 t.preview = &createWorktreePreview{raw: string(raw), plan: plan}
82 t.previewMu.Unlock()
83 return plan.preview, nil
84}
85
86func (t *CreateWorktreeTool) Execute(ctx context.Context, raw json.RawMessage, progress func(string)) (core.ToolResult, error) {
87 createWorktreeOperationMu.Lock()
88 defer createWorktreeOperationMu.Unlock()
89
90 plan, err := t.planForExecute(ctx, raw)
91 if err != nil {
92 return core.ToolResult{}, err
93 }
94 return executeCreateWorktreePlan(ctx, plan, progress)
95}
96
97func executeCreateWorktreePlan(ctx context.Context, plan createWorktreePlan, progress func(string)) (core.ToolResult, error) {
98 createdWorktreeParents, err := missingWorktreeParents(plan.worktreePath)
99 if err != nil {
100 return core.ToolResult{}, fmt.Errorf("create_worktree: inspect worktree directories: %w", err)
101 }
102 rollback := func(cause error) error {
103 cleanupCtx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
104 defer cancel()
105 if err := plan.cleanupFailedCheckout(cleanupCtx); err != nil {
106 cause = fmt.Errorf("%w; cleanup worktree: %v", cause, err)
107 }
108 if err := removeCreatedWorktreeParents(createdWorktreeParents); err != nil {
109 cause = fmt.Errorf("%w; remove created worktree directories: %v", cause, err)
110 }
111 return cause
112 }
113
114 if err := os.MkdirAll(filepath.Dir(plan.worktreePath), 0o755); err != nil {
115 return core.ToolResult{}, rollback(fmt.Errorf("create_worktree: make worktree directory: %w", err))
116 }
117 if progress != nil {
118 progress("Creating Git worktree...\n")
119 }
120 if _, err := createWorktreeGitOutput(ctx, plan.repoRoot, "worktree", "add", "-b", plan.branch, plan.worktreePath, plan.base); err != nil {
121 return core.ToolResult{}, rollback(fmt.Errorf("create_worktree: create worktree: %w", err))
122 }
123
124 result := plan.preview
125 previewDetails, _ := plan.preview.Details.(map[string]any)
126 details := make(map[string]any, len(previewDetails)+1)
127 for key, value := range previewDetails {
128 details[key] = value
129 }
130 details["state"] = "created"
131 result.Details = details
132 result.Content = []provider.Content{provider.TextBlock{Text: strings.Replace(plan.previewText, "create worktree", "created worktree", 1)}}
133 return result, nil
134}
135
136type createWorktreePreview struct {
137 raw string
138 plan createWorktreePlan
139}
140
141func (t *CreateWorktreeTool) planForExecute(ctx context.Context, raw json.RawMessage) (createWorktreePlan, error) {
142 t.previewMu.Lock()
143 cached := t.preview
144 t.preview = nil
145 t.previewMu.Unlock()
146
147 plan, err := t.plan(ctx, raw)
148 if err != nil {
149 return createWorktreePlan{}, err
150 }
151 if cached != nil && cached.raw == string(raw) && !sameCreateWorktreePlan(cached.plan, plan) {
152 return createWorktreePlan{}, errors.New("create_worktree: repository state changed since preview; inspect and retry")
153 }
154 return plan, nil
155}
156
157type createWorktreePlan struct {
158 repoRoot string
159 branch string
160 base string // resolved commit
161 baseRef string // requested base ref; empty for the current HEAD
162 worktreeRoot string
163 worktreePath string
164 rootSource string
165 repoID string
166 previewText string
167 preview core.ToolResult
168}
169
170func (t *CreateWorktreeTool) plan(ctx context.Context, raw json.RawMessage) (createWorktreePlan, error) {
171 var args createWorktreeArgs
172 if err := json.Unmarshal(raw, &args); err != nil {
173 return createWorktreePlan{}, fmt.Errorf("invalid args: %w", err)
174 }
175 branchInput := strings.TrimSpace(args.Branch)
176 if branchInput == "" {
177 return createWorktreePlan{}, errors.New("branch is required")
178 }
179 if strings.HasPrefix(branchInput, "-") || strings.Contains(branchInput, "@{") {
180 return createWorktreePlan{}, fmt.Errorf("create_worktree: invalid branch %q", branchInput)
181 }
182 if t.Sandbox == nil {
183 return createWorktreePlan{}, errors.New("create_worktree: sandbox is required")
184 }
185 if err := t.Sandbox.CheckBashPermission("git"); err != nil {
186 return createWorktreePlan{}, fmt.Errorf("create_worktree: %w", err)
187 }
188
189 cwd, err := t.accessibleCWD()
190 if err != nil {
191 return createWorktreePlan{}, err
192 }
193 candidateRoot, err := t.findGitRootCandidate(cwd)
194 if err != nil {
195 return createWorktreePlan{}, err
196 }
197 repoRoot, err := createWorktreeGitOutput(ctx, candidateRoot, "rev-parse", "--show-toplevel")
198 if err != nil {
199 return createWorktreePlan{}, fmt.Errorf("create_worktree: verify repository: %w", err)
200 }
201 repoRoot, err = canonical(repoRoot)
202 if err != nil {
203 return createWorktreePlan{}, fmt.Errorf("create_worktree: resolve repository root: %w", err)
204 }
205 if err := t.checkReadAccess(repoRoot); err != nil {
206 return createWorktreePlan{}, err
207 }
208 branch, err := createWorktreeGitOutput(ctx, repoRoot, "check-ref-format", "--branch", branchInput)
209 if err != nil {
210 return createWorktreePlan{}, fmt.Errorf("create_worktree: invalid branch %q: %w", branchInput, err)
211 }
212 if branch != branchInput {
213 return createWorktreePlan{}, fmt.Errorf("create_worktree: invalid branch %q", branchInput)
214 }
215 baseRef := strings.TrimSpace(args.Base)
216 if strings.HasPrefix(baseRef, "-") {
217 return createWorktreePlan{}, fmt.Errorf("create_worktree: invalid base %q", baseRef)
218 }
219 base, err := resolveWorktreeCommit(ctx, repoRoot, baseRef)
220 if err != nil {
221 if baseRef == "" {
222 return createWorktreePlan{}, fmt.Errorf("create_worktree: resolve current commit: %w", err)
223 }
224 return createWorktreePlan{}, fmt.Errorf("create_worktree: invalid base %q: %w", baseRef, err)
225 }
226 commonDir, err := createWorktreeGitOutput(ctx, repoRoot, "rev-parse", "--git-common-dir")
227 if err != nil {
228 return createWorktreePlan{}, fmt.Errorf("create_worktree: resolve Git metadata: %w", err)
229 }
230 if !filepath.IsAbs(commonDir) {
231 commonDir = filepath.Join(repoRoot, commonDir)
232 }
233 commonDir, err = canonical(commonDir)
234 if err != nil {
235 return createWorktreePlan{}, fmt.Errorf("create_worktree: resolve Git metadata: %w", err)
236 }
237 if err := t.checkReadAccess(commonDir); err != nil {
238 return createWorktreePlan{}, err
239 }
240
241 repoID := worktreeRepoID(commonDir)
242 configuredRoot, configured, err := createWorktreeGitConfigValue(ctx, repoRoot, worktreeConfigKey)
243 if err != nil {
244 return createWorktreePlan{}, fmt.Errorf("create_worktree: read local worktree configuration: %w", err)
245 }
246 var worktreeRoot string
247 rootSource := "global default root"
248 if configured {
249 worktreeRoot, err = resolveConfiguredWorktreeRoot(configuredRoot)
250 if err != nil {
251 return createWorktreePlan{}, err
252 }
253 rootSource = "local Git config"
254 } else {
255 defaultRoot, err := t.defaultRoot()
256 if err != nil {
257 return createWorktreePlan{}, err
258 }
259 worktreeRoot = filepath.Join(defaultRoot, repoID)
260 }
261
262 if err := validateWorktreeRoot(worktreeRoot); err != nil {
263 return createWorktreePlan{}, err
264 }
265 worktreePath := filepath.Join(worktreeRoot, filepath.FromSlash(branch))
266 rootCanonical, err := canonicalOrParent(worktreeRoot)
267 if err != nil {
268 return createWorktreePlan{}, fmt.Errorf("create_worktree: resolve worktree root: %w", err)
269 }
270 if isUnder(repoRoot, rootCanonical) {
271 return createWorktreePlan{}, errors.New("create_worktree: worktree root must be outside the repository")
272 }
273 pathCanonical, err := canonicalOrParent(worktreePath)
274 if err != nil {
275 return createWorktreePlan{}, fmt.Errorf("create_worktree: resolve worktree path: %w", err)
276 }
277 if !isUnder(rootCanonical, pathCanonical) {
278 return createWorktreePlan{}, errors.New("create_worktree: branch path escapes the worktree root")
279 }
280
281 if err := t.checkReadWriteAccess(repoRoot, commonDir, worktreeRoot, worktreePath); err != nil {
282 return createWorktreePlan{}, err
283 }
284 if _, err := os.Lstat(worktreePath); err == nil {
285 return createWorktreePlan{}, fmt.Errorf("create_worktree: worktree path %q already exists", filepath.ToSlash(worktreePath))
286 } else if !os.IsNotExist(err) {
287 return createWorktreePlan{}, fmt.Errorf("create_worktree: inspect worktree path: %w", err)
288 }
289 exists, err := createWorktreeBranchExists(ctx, repoRoot, branch)
290 if err != nil {
291 return createWorktreePlan{}, fmt.Errorf("create_worktree: check branch: %w", err)
292 }
293 if exists {
294 return createWorktreePlan{}, fmt.Errorf("create_worktree: branch %q already exists", branch)
295 }
296
297 displayRoot := filepath.ToSlash(worktreeRoot)
298 displayPath := filepath.ToSlash(worktreePath)
299 baseDisplay := base
300 if baseRef != "" {
301 baseDisplay = fmt.Sprintf("%s (%s)", baseRef, base)
302 }
303 previewText := fmt.Sprintf("create worktree\nbranch: %s\nbase: %s\nworktree root: %s\nroot source: %s\npath: %s\n", branch, baseDisplay, displayRoot, rootSource, displayPath)
304 return createWorktreePlan{
305 repoRoot: repoRoot,
306 branch: branch,
307 base: base,
308 baseRef: baseRef,
309 worktreeRoot: worktreeRoot,
310 worktreePath: worktreePath,
311 rootSource: rootSource,
312 repoID: repoID,
313 previewText: previewText,
314 preview: core.ToolResult{
315 Content: []provider.Content{provider.TextBlock{Text: previewText}},
316 Details: map[string]any{
317 "state": "ready",
318 "branch": branch,
319 "base": base,
320 "base_ref": baseRef,
321 "worktree_root": displayRoot,
322 "root_source": rootSource,
323 "repo_id": repoID,
324 "path": displayPath,
325 },
326 },
327 }, nil
328}
329
330// resolveWorktreeCommit resolves ref (HEAD when empty) to a commit id. Callers
331// must reject refs that begin with "-" first so a ref cannot become an option.
332func resolveWorktreeCommit(ctx context.Context, repoRoot, ref string) (string, error) {
333 if ref == "" {
334 ref = "HEAD"
335 }
336 return createWorktreeGitOutput(ctx, repoRoot, "rev-parse", "--verify", ref+"^{commit}")
337}
338
339func (t *CreateWorktreeTool) defaultRoot() (string, error) {
340 root := strings.TrimSpace(t.DefaultRoot)
341 if root == "" {
342 return "", errors.New("create_worktree: default worktree root is not configured")
343 }
344 return canonicalOrParent(root)
345}
346
347// worktreeRepoID derives a stable, collision-resistant directory name for the
348// repository that owns commonDir. The name pairs a readable slug with a hash of
349// the canonical common directory so repositories sharing a base name still get
350// distinct roots while one repository keeps the same directory across sessions.
351func worktreeRepoID(commonDir string) string {
352 sum := sha256.Sum256([]byte(filepath.ToSlash(commonDir)))
353 return worktreeRepoSlug(commonDir) + "-" + hex.EncodeToString(sum[:])[:worktreeRepoIDHashLength]
354}
355
356func worktreeRepoSlug(commonDir string) string {
357 base := filepath.Base(commonDir)
358 if trimmed := strings.TrimSuffix(base, ".git"); trimmed != "" {
359 base = trimmed
360 } else {
361 base = filepath.Base(filepath.Dir(commonDir))
362 }
363 var builder strings.Builder
364 dashed := false
365 for _, r := range base {
366 switch {
367 case r >= 'a' && r <= 'z', r >= '0' && r <= '9', r == '.', r == '_', r == '-':
368 builder.WriteRune(r)
369 dashed = false
370 case r >= 'A' && r <= 'Z':
371 builder.WriteRune(r + ('a' - 'A'))
372 dashed = false
373 default:
374 if !dashed {
375 builder.WriteByte('-')
376 dashed = true
377 }
378 }
379 }
380 slug := strings.Trim(builder.String(), "-._")
381 if len(slug) > worktreeRepoSlugMaxLen {
382 slug = strings.Trim(slug[:worktreeRepoSlugMaxLen], "-._")
383 }
384 if slug == "" {
385 slug = "repo"
386 }
387 return slug
388}
389
390func (t *CreateWorktreeTool) accessibleCWD() (string, error) {
391 cwd := t.CWD
392 if cwd == "" {
393 var err error
394 cwd, err = os.Getwd()
395 if err != nil {
396 return "", fmt.Errorf("create_worktree: working directory: %w", err)
397 }
398 }
399 cwd, err := canonical(cwd)
400 if err != nil {
401 return "", fmt.Errorf("create_worktree: resolve working directory: %w", err)
402 }
403 if err := t.checkReadAccess(cwd); err != nil {
404 return "", err
405 }
406 return cwd, nil
407}
408
409func (t *CreateWorktreeTool) findGitRootCandidate(cwd string) (string, error) {
410 for dir := cwd; ; dir = filepath.Dir(dir) {
411 if err := t.checkReadAccess(dir); err != nil {
412 return "", err
413 }
414 if _, err := os.Lstat(filepath.Join(dir, ".git")); err == nil {
415 return dir, nil
416 } else if !os.IsNotExist(err) {
417 return "", fmt.Errorf("create_worktree: inspect Git metadata: %w", err)
418 }
419 parent := filepath.Dir(dir)
420 if parent == dir {
421 break
422 }
423 }
424 return "", errors.New("create_worktree: working directory is not a Git repository")
425}
426
427func (t *CreateWorktreeTool) checkReadAccess(paths ...string) error {
428 for _, path := range paths {
429 if err := t.Sandbox.CheckReadPath(path); err != nil {
430 return fmt.Errorf("create_worktree: %w", err)
431 }
432 }
433 return nil
434}
435
436func (t *CreateWorktreeTool) checkReadWriteAccess(paths ...string) error {
437 for _, path := range paths {
438 if err := t.Sandbox.CheckReadPath(path); err != nil {
439 return fmt.Errorf("create_worktree: %w", err)
440 }
441 if err := t.Sandbox.CheckWritePath(path); err != nil {
442 return fmt.Errorf("create_worktree: %w", err)
443 }
444 }
445 return nil
446}
447
448func resolveConfiguredWorktreeRoot(value string) (string, error) {
449 value = strings.TrimSpace(value)
450 if value == "" {
451 return "", errors.New("create_worktree: local worktree configuration is empty")
452 }
453 if !filepath.IsAbs(value) {
454 return "", errors.New("create_worktree: local worktree configuration must be an absolute path; remove a legacy relative value with `git config --local --unset zut.worktrees.path`")
455 }
456 return filepath.Clean(value), nil
457}
458
459func validateWorktreeRoot(path string) error {
460 info, err := os.Lstat(path)
461 if os.IsNotExist(err) {
462 return nil
463 }
464 if err != nil {
465 return fmt.Errorf("create_worktree: inspect worktree root: %w", err)
466 }
467 if info.Mode()&os.ModeSymlink != 0 {
468 return errors.New("create_worktree: worktree root must not be a symbolic link")
469 }
470 if !info.IsDir() {
471 return errors.New("create_worktree: worktree root exists but is not a directory")
472 }
473 return nil
474}
475
476func sameCreateWorktreePlan(a, b createWorktreePlan) bool {
477 return a.repoRoot == b.repoRoot &&
478 a.branch == b.branch &&
479 a.base == b.base &&
480 a.baseRef == b.baseRef &&
481 a.worktreeRoot == b.worktreeRoot &&
482 a.worktreePath == b.worktreePath &&
483 a.rootSource == b.rootSource &&
484 a.repoID == b.repoID
485}
486
487func missingWorktreeParents(worktreePath string) ([]string, error) {
488 var missing []string
489 for directory := filepath.Dir(worktreePath); ; directory = filepath.Dir(directory) {
490 info, err := os.Lstat(directory)
491 if err == nil {
492 if !info.IsDir() {
493 return nil, fmt.Errorf("%q is not a directory", directory)
494 }
495 break
496 }
497 if !os.IsNotExist(err) {
498 return nil, err
499 }
500 missing = append(missing, directory)
501 parent := filepath.Dir(directory)
502 if parent == directory {
503 return nil, errors.New("no existing parent directory")
504 }
505 }
506 for left, right := 0, len(missing)-1; left < right; left, right = left+1, right-1 {
507 missing[left], missing[right] = missing[right], missing[left]
508 }
509 return missing, nil
510}
511
512func removeCreatedWorktreeParents(parents []string) error {
513 for index := len(parents) - 1; index >= 0; index-- {
514 if err := os.Remove(parents[index]); err != nil && !os.IsNotExist(err) {
515 return err
516 }
517 }
518 return nil
519}
520
521func (p createWorktreePlan) cleanupFailedCheckout(ctx context.Context) error {
522 registered, err := createWorktreeRegistered(ctx, p.repoRoot, p.worktreePath)
523 if err != nil {
524 return err
525 }
526 var cleanupErr error
527 if registered {
528 if _, err := createWorktreeGitOutput(ctx, p.repoRoot, "worktree", "remove", "--force", p.worktreePath); err != nil {
529 cleanupErr = fmt.Errorf("remove registered worktree: %w", err)
530 }
531 }
532 branchHead, exists, err := createWorktreeGitRef(ctx, p.repoRoot, "refs/heads/"+p.branch)
533 if err == nil && exists && branchHead == p.base {
534 if _, err := createWorktreeGitOutput(ctx, p.repoRoot, "branch", "-D", p.branch); err != nil && cleanupErr == nil {
535 cleanupErr = fmt.Errorf("remove branch: %w", err)
536 }
537 }
538 if err != nil && cleanupErr == nil {
539 cleanupErr = err
540 }
541 return cleanupErr
542}
543
544func createWorktreeRegistered(ctx context.Context, repoRoot, worktreePath string) (bool, error) {
545 output, err := createWorktreeGitOutput(ctx, repoRoot, "worktree", "list", "--porcelain")
546 if err != nil {
547 return false, err
548 }
549 want, err := canonicalOrParent(worktreePath)
550 if err != nil {
551 return false, err
552 }
553 for _, line := range strings.Split(output, "\n") {
554 if !strings.HasPrefix(line, "worktree ") {
555 continue
556 }
557 path, err := canonicalOrParent(strings.TrimPrefix(line, "worktree "))
558 if err == nil && path == want {
559 return true, nil
560 }
561 }
562 return false, nil
563}
564
565func createWorktreeBranchExists(ctx context.Context, repoRoot, branch string) (bool, error) {
566 _, err := createWorktreeGitOutput(ctx, repoRoot, "show-ref", "--verify", "--quiet", "refs/heads/"+branch)
567 if err == nil {
568 return true, nil
569 }
570 if ctx.Err() != nil {
571 return false, ctx.Err()
572 }
573 if createWorktreeGitExitCode(err) == 1 {
574 return false, nil
575 }
576 return false, err
577}
578
579func createWorktreeGitRef(ctx context.Context, repoRoot, ref string) (string, bool, error) {
580 output, err := createWorktreeGitOutput(ctx, repoRoot, "rev-parse", "--verify", ref+"^{commit}")
581 if err == nil {
582 return output, true, nil
583 }
584 if createWorktreeGitExitCode(err) == 1 {
585 return "", false, nil
586 }
587 return "", false, err
588}
589
590func createWorktreeGitConfigValue(ctx context.Context, repoRoot, key string) (string, bool, error) {
591 output, err := createWorktreeGitOutput(ctx, repoRoot, "config", "--local", "--get", key)
592 if err == nil {
593 return output, true, nil
594 }
595 if createWorktreeGitExitCode(err) == 1 {
596 return "", false, nil
597 }
598 return "", false, err
599}
600
601type createWorktreeGitError struct {
602 args []string
603 output string
604 err error
605}
606
607func (e *createWorktreeGitError) Error() string {
608 message := strings.TrimSpace(e.output)
609 if message == "" {
610 message = e.err.Error()
611 }
612 return fmt.Sprintf("git %s: %s", strings.Join(e.args, " "), message)
613}
614
615func (e *createWorktreeGitError) Unwrap() error { return e.err }
616
617func createWorktreeGitExitCode(err error) int {
618 var exitErr *exec.ExitError
619 if errors.As(err, &exitErr) {
620 return exitErr.ExitCode()
621 }
622 return -1
623}
624
625func createWorktreeGitOutput(ctx context.Context, dir string, args ...string) (string, error) {
626 cmdArgs := append([]string{"-c", "core.hooksPath=" + os.DevNull, "-C", dir}, args...)
627 cmd := exec.CommandContext(ctx, "git", cmdArgs...)
628 cmd.Env = createWorktreeGitEnv()
629 setProcessGroup(cmd)
630
631 var stdout, output cappedGitOutput
632 cmd.Stdout = io.MultiWriter(&stdout, &output)
633 cmd.Stderr = &output
634 if err := cmd.Start(); err != nil {
635 return "", fmt.Errorf("start git %s: %w", strings.Join(args, " "), err)
636 }
637 watchDone := make(chan struct{})
638 watchStopped := make(chan struct{})
639 go func() {
640 defer close(watchStopped)
641 select {
642 case <-ctx.Done():
643 killProcessGroup(cmd)
644 case <-watchDone:
645 }
646 }()
647 waitErr := cmd.Wait()
648 close(watchDone)
649 <-watchStopped
650 if waitErr != nil {
651 if ctx.Err() != nil {
652 return "", ctx.Err()
653 }
654 return "", &createWorktreeGitError{args: args, output: output.String(), err: waitErr}
655 }
656 return strings.TrimSpace(stdout.String()), nil
657}
658
659type cappedGitOutput struct {
660 mu sync.Mutex
661 buffer bytes.Buffer
662 truncated bool
663}
664
665func (b *cappedGitOutput) Write(p []byte) (int, error) {
666 b.mu.Lock()
667 defer b.mu.Unlock()
668 if room := maxCreateWorktreeGitOutput - b.buffer.Len(); room > 0 {
669 if len(p) > room {
670 _, _ = b.buffer.Write(p[:room])
671 b.truncated = true
672 } else {
673 _, _ = b.buffer.Write(p)
674 }
675 } else {
676 b.truncated = true
677 }
678 return len(p), nil
679}
680
681func (b *cappedGitOutput) String() string {
682 b.mu.Lock()
683 defer b.mu.Unlock()
684 output := b.buffer.String()
685 if b.truncated {
686 output += "\n... [git output truncated]"
687 }
688 return output
689}
690
691func createWorktreeGitEnv() []string {
692 env := make([]string, 0, len(os.Environ())+5)
693 for _, entry := range os.Environ() {
694 key, _, _ := strings.Cut(entry, "=")
695 if strings.HasPrefix(key, "GIT_") || key == "SSH_ASKPASS" {
696 continue
697 }
698 env = append(env, entry)
699 }
700 return append(env,
701 "GIT_CONFIG_NOSYSTEM=1",
702 "GIT_CONFIG_GLOBAL="+os.DevNull,
703 "GIT_TERMINAL_PROMPT=0",
704 "GIT_ASKPASS=",
705 "SSH_ASKPASS=",
706 )
707}