返回 DeepSeek-Reasonix
worktree.go
根目录 / internal / worktree / worktree.go
1 // Package worktree creates durable, Git-backed workspaces for parallel
2 // Delivery sessions. Attached worktrees live under Reasonix-managed state,
3 // never inside the source repository, and are never deleted automatically;
4 // an exact untouched allocation may be rolled back before it is attached.
5 package worktree
6
7 import (
8 "bytes"
9 "context"
10 "crypto/rand"
11 "crypto/sha256"
12 "encoding/hex"
13 "errors"
14 "fmt"
15 "os"
16 "os/exec"
17 "path/filepath"
18 "strings"
19 "time"
20
21 "reasonix/internal/gitcmd"
22 )
23
24 const (
25 gitProbeTimeout = 15 * time.Second
26 gitWorktreeMutationTimeout = 5 * time.Minute
27 )
28
29 // Availability describes whether a project can be isolated with Git worktree.
30 type Availability struct {
31 Available bool `json:"available"`
32 Reason string `json:"reason,omitempty"`
33 RepoRoot string `json:"repoRoot,omitempty"`
34 Branch string `json:"branch,omitempty"`
35 SourceDirty bool `json:"sourceDirty,omitempty"`
36 }
37
38 // RollbackCreate removes a worktree and its branch only while they still match
39 // the exact clean result returned by Create. Any user or Git mutation makes the
40 // rollback fail closed and leaves the workspace available for recovery.
41 func RollbackCreate(ctx context.Context, result Result) error {
42 sourceRoot := strings.TrimSpace(result.SourceRoot)
43 worktreeRoot := strings.TrimSpace(result.WorktreeRoot)
44 branch := strings.TrimSpace(result.Branch)
45 head := strings.TrimSpace(result.Head)
46 if sourceRoot == "" || worktreeRoot == "" || branch == "" || head == "" {
47 return errors.New("rollback needs the complete created worktree identity")
48 }
49 if !strings.HasPrefix(branch, "reasonix/delivery-") {
50 return fmt.Errorf("refuse to roll back unmanaged branch %q", branch)
51 }
52 if _, _, err := runGit(ctx, sourceRoot, "check-ref-format", "refs/heads/"+branch); err != nil {
53 return fmt.Errorf("refuse to roll back invalid branch %q", branch)
54 }
55
56 sourceInfo, err := os.Stat(sourceRoot)
57 if err != nil {
58 return fmt.Errorf("inspect rollback source: %w", err)
59 }
60 if !sourceInfo.IsDir() {
61 return errors.New("rollback source is not a directory")
62 }
63 worktreeInfo, err := os.Stat(worktreeRoot)
64 if err != nil {
65 return fmt.Errorf("inspect rollback worktree: %w", err)
66 }
67 if !worktreeInfo.IsDir() {
68 return errors.New("rollback worktree is not a directory")
69 }
70 if os.SameFile(sourceInfo, worktreeInfo) {
71 return errors.New("refuse to remove the source worktree")
72 }
73 reportedRoot, _, err := runGit(ctx, worktreeRoot, "rev-parse", "--show-toplevel")
74 if err != nil {
75 return fmt.Errorf("verify rollback worktree root: %w", err)
76 }
77 reportedInfo, err := os.Stat(strings.TrimSpace(reportedRoot))
78 if err != nil || !os.SameFile(worktreeInfo, reportedInfo) {
79 return errors.New("rollback target is not the exact created worktree root")
80 }
81 if err := verifySameCommonDir(ctx, sourceRoot, worktreeRoot); err != nil {
82 return err
83 }
84 currentBranch, _, err := runGit(ctx, worktreeRoot, "symbolic-ref", "--quiet", "--short", "HEAD")
85 if err != nil || strings.TrimSpace(currentBranch) != branch {
86 return fmt.Errorf("rollback worktree branch changed from %q", branch)
87 }
88 currentHead, _, err := runGit(ctx, worktreeRoot, "rev-parse", "--verify", "HEAD")
89 if err != nil || strings.TrimSpace(currentHead) != head {
90 return errors.New("rollback worktree HEAD changed after creation")
91 }
92 status, _, err := runGit(ctx, worktreeRoot, "status", "--porcelain=v1", "--untracked-files=all", "--ignored")
93 if err != nil {
94 return fmt.Errorf("inspect rollback worktree changes: %w", err)
95 }
96 if strings.TrimSpace(status) != "" {
97 return errors.New("rollback worktree contains changes; it was preserved")
98 }
99 metadataFile, err := verifyRollbackMetadata(sourceRoot, worktreeRoot, branch, head)
100 if err != nil {
101 return err
102 }
103 if _, stderr, err := runGit(ctx, sourceRoot, "worktree", "remove", worktreeRoot); err != nil {
104 return fmt.Errorf("remove unused worktree: %w%s", err, stderrSuffix(stderr))
105 }
106 if _, stderr, err := runGit(ctx, sourceRoot, "update-ref", "-d", "refs/heads/"+branch, head); err != nil {
107 return fmt.Errorf("remove unused worktree branch %q: %w%s", branch, err, stderrSuffix(stderr))
108 }
109 if err := os.Remove(metadataFile); err != nil && !errors.Is(err, os.ErrNotExist) {
110 return fmt.Errorf("remove unused worktree metadata: %w", err)
111 }
112 return nil
113 }
114
115 func verifySameCommonDir(ctx context.Context, sourceRoot, worktreeRoot string) error {
116 resolve := func(root string) (os.FileInfo, error) {
117 commonDir, _, err := runGit(ctx, root, "rev-parse", "--git-common-dir")
118 if err != nil {
119 return nil, err
120 }
121 commonDir = strings.TrimSpace(commonDir)
122 if !filepath.IsAbs(commonDir) {
123 commonDir = filepath.Join(root, commonDir)
124 }
125 return os.Stat(filepath.Clean(commonDir))
126 }
127 sourceInfo, err := resolve(sourceRoot)
128 if err != nil {
129 return fmt.Errorf("resolve rollback source repository: %w", err)
130 }
131 worktreeInfo, err := resolve(worktreeRoot)
132 if err != nil || !os.SameFile(sourceInfo, worktreeInfo) {
133 return errors.New("rollback source and worktree do not share a Git repository")
134 }
135 return nil
136 }
137
138 // Result identifies one newly created isolated Delivery workspace.
139 type Result struct {
140 WorkspaceRoot string `json:"workspaceRoot"`
141 WorktreeRoot string `json:"worktreeRoot"`
142 SourceRoot string `json:"sourceRoot"`
143 Branch string `json:"branch"`
144 Head string `json:"head"`
145 SourceDirty bool `json:"sourceDirty"`
146 }
147
148 type inspection struct {
149 Availability
150 head string
151 prefix string
152 commonDir string
153 }
154
155 // Inspect checks Git and repository prerequisites without changing state.
156 func Inspect(ctx context.Context, workspaceRoot string) Availability {
157 info, err := inspect(ctx, workspaceRoot)
158 if err != nil {
159 return Availability{Available: false, Reason: err.Error()}
160 }
161 return info.Availability
162 }
163
164 // Create makes a new branch and linked worktree at managedRoot, based on the
165 // source repository's committed HEAD. Uncommitted source changes are reported
166 // but never copied or modified. When workspaceRoot names a repository
167 // subdirectory, Result.WorkspaceRoot points at the corresponding subdirectory
168 // in the new worktree.
169 func Create(ctx context.Context, workspaceRoot, managedRoot string) (Result, error) {
170 info, err := inspect(ctx, workspaceRoot)
171 if err != nil {
172 return Result{}, err
173 }
174 managedRoot = strings.TrimSpace(managedRoot)
175 if managedRoot == "" {
176 return Result{}, errors.New("Reasonix worktree storage is unavailable")
177 }
178 if err := os.MkdirAll(managedRoot, 0o700); err != nil {
179 return Result{}, fmt.Errorf("create Reasonix worktree storage: %w", err)
180 }
181
182 repoSum := sha256.Sum256([]byte(info.commonDir))
183 repoKey := hex.EncodeToString(repoSum[:8])
184 repoBase := safePathComponent(filepath.Base(info.RepoRoot))
185 if repoBase == "" {
186 repoBase = "repository"
187 }
188
189 for range 5 {
190 id, randomErr := randomID()
191 if randomErr != nil {
192 return Result{}, randomErr
193 }
194 branch := fmt.Sprintf("reasonix/delivery-%s-%s", time.Now().Format("20060102-150405"), id)
195 worktreeRoot := filepath.Join(managedRoot, repoKey, id, repoBase)
196 if _, statErr := os.Stat(worktreeRoot); statErr == nil {
197 continue
198 } else if !os.IsNotExist(statErr) {
199 return Result{}, fmt.Errorf("inspect worktree destination: %w", statErr)
200 }
201 if err := os.MkdirAll(filepath.Dir(worktreeRoot), 0o700); err != nil {
202 return Result{}, fmt.Errorf("create worktree parent: %w", err)
203 }
204
205 _, stderr, addErr := runGit(ctx, info.RepoRoot, "worktree", "add", "-b", branch, worktreeRoot, info.head)
206 if addErr != nil {
207 // A random branch collision is retryable. We deliberately leave any
208 // non-empty partial directory untouched rather than risk deleting user
209 // data after Git returned an ambiguous failure.
210 if strings.Contains(strings.ToLower(stderr), "already exists") {
211 continue
212 }
213 return Result{}, fmt.Errorf("create Git worktree: %w%s", addErr, stderrSuffix(stderr))
214 }
215
216 selectedRoot := worktreeRoot
217 if prefix := filepath.FromSlash(strings.Trim(strings.TrimSpace(info.prefix), "/")); prefix != "" && prefix != "." {
218 selectedRoot = filepath.Join(worktreeRoot, prefix)
219 st, statErr := os.Stat(selectedRoot)
220 if statErr != nil || !st.IsDir() {
221 return Result{}, fmt.Errorf("created worktree is missing selected project subdirectory %q", prefix)
222 }
223 }
224 result := Result{
225 WorkspaceRoot: selectedRoot,
226 WorktreeRoot: worktreeRoot,
227 SourceRoot: info.RepoRoot,
228 Branch: branch,
229 Head: info.head,
230 SourceDirty: info.SourceDirty,
231 }
232 if err := writeMergeMetadata(result, info.Branch); err != nil {
233 rollbackErr := RollbackCreate(ctx, result)
234 if rollbackErr != nil {
235 return Result{}, fmt.Errorf("publish merge metadata and roll back allocation: %w", errors.Join(err, fmt.Errorf("exact-clean rollback failed and the worktree was preserved: %w", rollbackErr)))
236 }
237 return Result{}, err
238 }
239 return result, nil
240 }
241 return Result{}, errors.New("could not allocate a unique Delivery worktree")
242 }
243
244 // IsManagedPath reports whether path belongs to Reasonix's durable worktree
245 // storage. It is a lexical UI identity check, not an authorization boundary.
246 func IsManagedPath(path, managedRoot string) bool {
247 path = strings.TrimSpace(path)
248 managedRoot = strings.TrimSpace(managedRoot)
249 if path == "" || managedRoot == "" {
250 return false
251 }
252 absPath, err := filepath.Abs(path)
253 if err != nil {
254 return false
255 }
256 absManaged, err := filepath.Abs(managedRoot)
257 if err != nil {
258 return false
259 }
260 rel, err := filepath.Rel(filepath.Clean(absManaged), filepath.Clean(absPath))
261 if err != nil || rel == "." || rel == "" {
262 return false
263 }
264 return rel != ".." && !strings.HasPrefix(rel, ".."+string(filepath.Separator))
265 }
266
267 func inspect(ctx context.Context, workspaceRoot string) (inspection, error) {
268 workspaceRoot = strings.TrimSpace(workspaceRoot)
269 if workspaceRoot == "" {
270 return inspection{}, errors.New("project folder is required")
271 }
272 st, err := os.Stat(workspaceRoot)
273 if err != nil {
274 return inspection{}, fmt.Errorf("project folder is unavailable: %w", err)
275 }
276 if !st.IsDir() {
277 return inspection{}, errors.New("project path is not a folder")
278 }
279 if _, err := exec.LookPath("git"); err != nil {
280 return inspection{}, errors.New("Git is not installed; Delivery remains safe and will serialize writes in this folder")
281 }
282
283 repoRoot, stderr, err := runGit(ctx, workspaceRoot, "rev-parse", "--show-toplevel")
284 if err != nil {
285 return inspection{}, fmt.Errorf("project folder is not inside a Git repository%s", stderrSuffix(stderr))
286 }
287 repoRoot = filepath.Clean(strings.TrimSpace(repoRoot))
288 if repoRoot == "" {
289 return inspection{}, errors.New("Git did not report a repository root")
290 }
291 bare, _, err := runGit(ctx, workspaceRoot, "rev-parse", "--is-bare-repository")
292 if err != nil || strings.EqualFold(strings.TrimSpace(bare), "true") {
293 return inspection{}, errors.New("bare Git repositories cannot be opened as Delivery workspaces")
294 }
295 head, _, err := runGit(ctx, repoRoot, "rev-parse", "--verify", "HEAD")
296 if err != nil || strings.TrimSpace(head) == "" {
297 return inspection{}, errors.New("the Git repository needs an initial commit before a worktree can be created")
298 }
299 head = strings.TrimSpace(head)
300 prefix, _, err := runGit(ctx, workspaceRoot, "rev-parse", "--show-prefix")
301 if err != nil {
302 return inspection{}, fmt.Errorf("resolve selected project path inside repository: %w", err)
303 }
304 prefix = strings.TrimSpace(prefix)
305 if prefix != "" {
306 objectType, _, objectErr := runGit(ctx, repoRoot, "cat-file", "-t", head+":"+strings.TrimSuffix(prefix, "/"))
307 if objectErr != nil || strings.TrimSpace(objectType) != "tree" {
308 return inspection{}, errors.New("the selected project folder is not present in the committed HEAD; commit it before creating a worktree")
309 }
310 }
311 commonDir, _, err := runGit(ctx, repoRoot, "rev-parse", "--git-common-dir")
312 if err != nil {
313 return inspection{}, fmt.Errorf("resolve Git common directory: %w", err)
314 }
315 commonDir = strings.TrimSpace(commonDir)
316 if !filepath.IsAbs(commonDir) {
317 commonDir = filepath.Join(repoRoot, commonDir)
318 }
319 commonDir = filepath.Clean(commonDir)
320 branch, _, _ := runGit(ctx, repoRoot, "symbolic-ref", "--quiet", "--short", "HEAD")
321 status, _, statusErr := runGit(ctx, repoRoot, "status", "--porcelain=v1", "--untracked-files=normal")
322 if statusErr != nil {
323 return inspection{}, fmt.Errorf("inspect Git working tree: %w", statusErr)
324 }
325 return inspection{
326 Availability: Availability{
327 Available: true,
328 RepoRoot: repoRoot,
329 Branch: strings.TrimSpace(branch),
330 SourceDirty: strings.TrimSpace(status) != "",
331 },
332 head: head,
333 prefix: prefix,
334 commonDir: commonDir,
335 }, nil
336 }
337
338 func runGit(parent context.Context, dir string, args ...string) (stdout, stderr string, err error) {
339 return runGitEnvInput(parent, dir, "", nil, args...)
340 }
341
342 func runGitInput(parent context.Context, dir, input string, args ...string) (stdout, stderr string, err error) {
343 return runGitEnvInput(parent, dir, input, nil, args...)
344 }
345
346 func runGitEnv(parent context.Context, dir string, env []string, args ...string) (stdout, stderr string, err error) {
347 return runGitEnvInput(parent, dir, "", env, args...)
348 }
349
350 func runGitEnvInput(parent context.Context, dir, input string, env []string, args ...string) (stdout, stderr string, err error) {
351 if parent == nil {
352 parent = context.Background()
353 }
354 ctx, cancel := context.WithTimeout(parent, gitTimeout(args))
355 defer cancel()
356 cmd := gitcmd.Command(ctx, dir, args...)
357 if len(env) > 0 {
358 cmd.Env = append(os.Environ(), env...)
359 }
360 var outBuf, errBuf bytes.Buffer
361 if input != "" {
362 cmd.Stdin = strings.NewReader(input)
363 }
364 cmd.Stdout = &outBuf
365 cmd.Stderr = &errBuf
366 err = cmd.Run()
367 if ctx.Err() != nil {
368 err = ctx.Err()
369 }
370 return outBuf.String(), strings.TrimSpace(errBuf.String()), err
371 }
372
373 func gitTimeout(args []string) time.Duration {
374 if len(args) >= 2 && args[0] == "worktree" && (args[1] == "add" || args[1] == "move" || args[1] == "remove") {
375 return gitWorktreeMutationTimeout
376 }
377 return gitProbeTimeout
378 }
379
380 func randomID() (string, error) {
381 var b [5]byte
382 if _, err := rand.Read(b[:]); err != nil {
383 return "", fmt.Errorf("generate worktree id: %w", err)
384 }
385 return hex.EncodeToString(b[:]), nil
386 }
387
388 func safePathComponent(name string) string {
389 name = strings.TrimSpace(name)
390 name = strings.Map(func(r rune) rune {
391 switch {
392 case r < 32:
393 return '-'
394 case strings.ContainsRune(`/\\:<>"|?*`, r):
395 return '-'
396 default:
397 return r
398 }
399 }, name)
400 name = strings.Trim(name, ". ")
401 reserved := strings.ToUpper(strings.SplitN(name, ".", 2)[0])
402 if reserved == "CON" || reserved == "PRN" || reserved == "AUX" || reserved == "NUL" ||
403 (len(reserved) == 4 && (strings.HasPrefix(reserved, "COM") || strings.HasPrefix(reserved, "LPT")) && reserved[3] >= '1' && reserved[3] <= '9') {
404 name = "_" + name
405 }
406 return name
407 }
408
409 func stderrSuffix(stderr string) string {
410 stderr = strings.TrimSpace(stderr)
411 if stderr == "" {
412 return ""
413 }
414 const max = 500
415 if len(stderr) > max {
416 stderr = stderr[:max] + "…"
417 }
418 return ": " + stderr
419 }
420
420 lines GO