返回 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 source, created, err := result.repos(ctx)
53 if err != nil {
54 return fmt.Errorf("resolve rollback repositories: %w", err)
55 }
56 if _, _, err := runGit(ctx, source, "check-ref-format", "refs/heads/"+branch); err != nil {
57 return fmt.Errorf("refuse to roll back invalid branch %q", branch)
58 }
59
60 sourceInfo, err := os.Stat(sourceRoot)
61 if err != nil {
62 return fmt.Errorf("inspect rollback source: %w", err)
63 }
64 if !sourceInfo.IsDir() {
65 return errors.New("rollback source is not a directory")
66 }
67 worktreeInfo, err := os.Stat(worktreeRoot)
68 if err != nil {
69 return fmt.Errorf("inspect rollback worktree: %w", err)
70 }
71 if !worktreeInfo.IsDir() {
72 return errors.New("rollback worktree is not a directory")
73 }
74 if os.SameFile(sourceInfo, worktreeInfo) {
75 return errors.New("refuse to remove the source worktree")
76 }
77 reportedInfo, err := os.Stat(created.WorkTree)
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(source, created); err != nil {
82 return err
83 }
84 currentBranch, _, err := runGit(ctx, created, "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, created, "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, created, "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 // --force after the clean-tree check above: a bare remove runs status
104 // inside the linked worktree, whose config.worktree the source-root
105 // listing never saw. The check already confirmed the tree is clean.
106 if _, stderr, err := runGit(ctx, source, "worktree", "remove", "--force", worktreeRoot); err != nil {
107 return fmt.Errorf("remove unused worktree: %w%s", err, stderrSuffix(stderr))
108 }
109 if _, stderr, err := runGit(ctx, source, "update-ref", "-d", "refs/heads/"+branch, head); err != nil {
110 return fmt.Errorf("remove unused worktree branch %q: %w%s", branch, err, stderrSuffix(stderr))
111 }
112 if err := removeMergeMetadata(metadataFile); err != nil {
113 return fmt.Errorf("remove unused worktree metadata: %w", err)
114 }
115 return nil
116 }
117
118 // verifySameCommonDir compares the common dirs the two identities were pinned
119 // with; neither is rediscovered from its checkout.
120 func verifySameCommonDir(source, worktree gitcmd.Repo) error {
121 sourceInfo, err := os.Stat(source.CommonDir)
122 if err != nil {
123 return fmt.Errorf("resolve rollback source repository: %w", err)
124 }
125 worktreeInfo, err := os.Stat(worktree.CommonDir)
126 if err != nil || !os.SameFile(sourceInfo, worktreeInfo) {
127 return errors.New("rollback source and worktree do not share a Git repository")
128 }
129 return nil
130 }
131
132 // Result identifies one newly created isolated Delivery workspace.
133 type Result struct {
134 WorkspaceRoot string `json:"workspaceRoot"`
135 WorktreeRoot string `json:"worktreeRoot"`
136 SourceRoot string `json:"sourceRoot"`
137 Branch string `json:"branch"`
138 Head string `json:"head"`
139 SourceDirty bool `json:"sourceDirty"`
140 SourceRepo gitcmd.Repo `json:"-"` // the source's identity, at its root
141 WorktreeRepo gitcmd.Repo `json:"-"` // resolved as the worktree was added
142 }
143
144 // repos is the pinned source and worktree identities; a Result built without
145 // them resolves each from its root.
146 func (r Result) repos(ctx context.Context) (source, worktree gitcmd.Repo, err error) {
147 source, worktree = r.SourceRepo, r.WorktreeRepo
148 if !source.Valid() {
149 if source, err = gitcmd.Open(ctx, r.SourceRoot); err != nil {
150 return source, worktree, err
151 }
152 }
153 if !worktree.Valid() {
154 worktree, err = gitcmd.Open(ctx, r.WorktreeRoot)
155 }
156 return source.Top(), worktree.Top(), err
157 }
158
159 type inspection struct {
160 Availability
161 repo gitcmd.Repo // pinned at the work tree root
162 head string
163 prefix string
164 }
165
166 // Inspect checks Git and repository prerequisites without changing state.
167 // repo is the workspace's identity, resolved when it was opened.
168 func Inspect(ctx context.Context, repo gitcmd.Repo) Availability {
169 info, err := inspect(ctx, repo)
170 if err != nil {
171 return Availability{Available: false, Reason: err.Error()}
172 }
173 return info.Availability
174 }
175
176 // Create makes a new branch and linked worktree at managedRoot, based on the
177 // source repository's committed HEAD. Uncommitted source changes are reported
178 // but never copied or modified. When workspaceRoot names a repository
179 // subdirectory, Result.WorkspaceRoot points at the corresponding subdirectory
180 // in the new worktree. repo is the workspace's identity, resolved when it opened.
181 func Create(ctx context.Context, repo gitcmd.Repo, managedRoot string) (Result, error) {
182 info, err := inspect(ctx, repo)
183 if err != nil {
184 return Result{}, err
185 }
186 managedRoot = strings.TrimSpace(managedRoot)
187 if managedRoot == "" {
188 return Result{}, errors.New("Reasonix worktree storage is unavailable")
189 }
190 if err := os.MkdirAll(managedRoot, 0o700); err != nil {
191 return Result{}, fmt.Errorf("create Reasonix worktree storage: %w", err)
192 }
193
194 repoSum := sha256.Sum256([]byte(info.repo.CommonDir))
195 repoKey := hex.EncodeToString(repoSum[:8])
196 repoBase := safePathComponent(filepath.Base(info.RepoRoot))
197 if repoBase == "" {
198 repoBase = "repository"
199 }
200
201 for range 5 {
202 id, randomErr := randomID()
203 if randomErr != nil {
204 return Result{}, randomErr
205 }
206 branch := fmt.Sprintf("reasonix/delivery-%s-%s", time.Now().Format("20060102-150405"), id)
207 worktreeRoot := filepath.Join(managedRoot, repoKey, id, repoBase)
208 if _, statErr := os.Stat(worktreeRoot); statErr == nil {
209 continue
210 } else if !os.IsNotExist(statErr) {
211 return Result{}, fmt.Errorf("inspect worktree destination: %w", statErr)
212 }
213 if err := os.MkdirAll(filepath.Dir(worktreeRoot), 0o700); err != nil {
214 return Result{}, fmt.Errorf("create worktree parent: %w", err)
215 }
216
217 // --no-checkout, then populate through gitcmd inside the new worktree,
218 // where the reset's driver listing sees an includeIf keyed on that
219 // worktree's gitdir or new branch (a checkout during add would not).
220 _, stderr, addErr := runGit(ctx, info.repo, "worktree", "add", "--no-checkout", "-b", branch, worktreeRoot, info.head)
221 var created gitcmd.Repo
222 if addErr == nil {
223 var resetStderr string
224 var resetErr error
225 if created, resetStderr, resetErr = populate(ctx, worktreeRoot, info.head); resetErr != nil {
226 _, _, _ = runGit(ctx, info.repo, "worktree", "remove", "--force", worktreeRoot)
227 _, _, _ = runGit(ctx, info.repo, "update-ref", "-d", "refs/heads/"+branch, info.head)
228 return Result{}, fmt.Errorf("populate Git worktree: %w%s", resetErr, stderrSuffix(resetStderr))
229 }
230 }
231 if addErr != nil {
232 // A random branch collision is retryable. We deliberately leave any
233 // non-empty partial directory untouched rather than risk deleting user
234 // data after Git returned an ambiguous failure.
235 if strings.Contains(strings.ToLower(stderr), "already exists") {
236 continue
237 }
238 return Result{}, fmt.Errorf("create Git worktree: %w%s", addErr, stderrSuffix(stderr))
239 }
240
241 selectedRoot := worktreeRoot
242 if prefix := filepath.FromSlash(strings.Trim(strings.TrimSpace(info.prefix), "/")); prefix != "" && prefix != "." {
243 selectedRoot = filepath.Join(worktreeRoot, prefix)
244 st, statErr := os.Stat(selectedRoot)
245 if statErr != nil || !st.IsDir() {
246 return Result{}, fmt.Errorf("created worktree is missing selected project subdirectory %q", prefix)
247 }
248 }
249 result := Result{
250 WorkspaceRoot: selectedRoot,
251 WorktreeRoot: worktreeRoot,
252 SourceRoot: info.RepoRoot,
253 Branch: branch,
254 Head: info.head,
255 SourceDirty: info.SourceDirty,
256 SourceRepo: info.repo,
257 WorktreeRepo: created,
258 }
259 if err := writeMergeMetadata(result, info.Branch); err != nil {
260 rollbackErr := RollbackCreate(ctx, result)
261 if rollbackErr != nil {
262 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)))
263 }
264 return Result{}, err
265 }
266 return result, nil
267 }
268 return Result{}, errors.New("could not allocate a unique Delivery worktree")
269 }
270
271 // IsManagedPath reports whether path belongs to Reasonix's durable worktree
272 // storage. It is a lexical UI identity check, not an authorization boundary.
273 func IsManagedPath(path, managedRoot string) bool {
274 path = strings.TrimSpace(path)
275 managedRoot = strings.TrimSpace(managedRoot)
276 if path == "" || managedRoot == "" {
277 return false
278 }
279 absPath, err := filepath.Abs(path)
280 if err != nil {
281 return false
282 }
283 absManaged, err := filepath.Abs(managedRoot)
284 if err != nil {
285 return false
286 }
287 rel, err := filepath.Rel(filepath.Clean(absManaged), filepath.Clean(absPath))
288 if err != nil || rel == "." || rel == "" {
289 return false
290 }
291 return rel != ".." && !strings.HasPrefix(rel, ".."+string(filepath.Separator))
292 }
293
294 func inspect(ctx context.Context, repo gitcmd.Repo) (inspection, error) {
295 workspaceRoot := strings.TrimSpace(repo.Dir)
296 if workspaceRoot == "" {
297 return inspection{}, errors.New("project folder is required")
298 }
299 st, err := os.Stat(workspaceRoot)
300 if err != nil {
301 return inspection{}, fmt.Errorf("project folder is unavailable: %w", err)
302 }
303 if !st.IsDir() {
304 return inspection{}, errors.New("project path is not a folder")
305 }
306 if _, err := exec.LookPath("git"); err != nil {
307 return inspection{}, errors.New("Git is not installed; Delivery remains safe and will serialize writes in this folder")
308 }
309 if !repo.Valid() {
310 return inspection{}, fmt.Errorf("project folder is not inside a Git repository: %w", gitcmd.ErrNotRepository)
311 }
312 top := repo.Top()
313 head, _, err := runGit(ctx, top, "rev-parse", "--verify", "HEAD")
314 if err != nil || strings.TrimSpace(head) == "" {
315 return inspection{}, errors.New("the Git repository needs an initial commit before a worktree can be created")
316 }
317 head = strings.TrimSpace(head)
318 prefix, _, err := runGit(ctx, repo, "rev-parse", "--show-prefix")
319 if err != nil {
320 return inspection{}, fmt.Errorf("resolve selected project path inside repository: %w", err)
321 }
322 prefix = strings.TrimSpace(prefix)
323 if prefix != "" {
324 objectType, _, objectErr := runGit(ctx, top, "cat-file", "-t", head+":"+strings.TrimSuffix(prefix, "/"))
325 if objectErr != nil || strings.TrimSpace(objectType) != "tree" {
326 return inspection{}, errors.New("the selected project folder is not present in the committed HEAD; commit it before creating a worktree")
327 }
328 }
329 branch, _, _ := runGit(ctx, top, "symbolic-ref", "--quiet", "--short", "HEAD")
330 status, _, statusErr := runGit(ctx, top, "status", "--porcelain=v1", "--untracked-files=normal")
331 if statusErr != nil {
332 return inspection{}, fmt.Errorf("inspect Git working tree: %w", statusErr)
333 }
334 return inspection{
335 Availability: Availability{
336 Available: true,
337 RepoRoot: top.WorkTree,
338 Branch: strings.TrimSpace(branch),
339 SourceDirty: strings.TrimSpace(status) != "",
340 },
341 repo: top,
342 head: head,
343 prefix: prefix,
344 }, nil
345 }
346
347 // populate checks head out into a worktree the host just added, under the
348 // identity that worktree resolves to now, before anything else has run in it.
349 func populate(ctx context.Context, root, head string) (gitcmd.Repo, string, error) {
350 repo, err := gitcmd.Open(ctx, root)
351 if err != nil {
352 return repo, "", err
353 }
354 _, stderr, err := runGit(ctx, repo, "reset", "--hard", head)
355 return repo, stderr, err
356 }
357
358 func runGit(parent context.Context, repo gitcmd.Repo, args ...string) (stdout, stderr string, err error) {
359 return runGitEnvInput(parent, repo, "", nil, args...)
360 }
361
362 func runGitInput(parent context.Context, repo gitcmd.Repo, input string, args ...string) (stdout, stderr string, err error) {
363 return runGitEnvInput(parent, repo, input, nil, args...)
364 }
365
366 func runGitEnv(parent context.Context, repo gitcmd.Repo, env []string, args ...string) (stdout, stderr string, err error) {
367 return runGitEnvInput(parent, repo, "", env, args...)
368 }
369
370 // runGitEnvInput runs git against repo's identity, pinned when the repository
371 // was opened, never one rediscovered from the directory.
372 func runGitEnvInput(parent context.Context, repo gitcmd.Repo, input string, env []string, args ...string) (stdout, stderr string, err error) {
373 if parent == nil {
374 parent = context.Background()
375 }
376 ctx, cancel := context.WithTimeout(parent, gitTimeout(args))
377 defer cancel()
378 cmd := repo.Command(ctx, args...)
379 if len(env) > 0 {
380 cmd.Env = append(cmd.Env, env...)
381 }
382 var outBuf, errBuf bytes.Buffer
383 if input != "" {
384 cmd.Stdin = strings.NewReader(input)
385 }
386 cmd.Stdout = &outBuf
387 cmd.Stderr = &errBuf
388 err = cmd.Run()
389 if ctx.Err() != nil {
390 err = ctx.Err()
391 }
392 return outBuf.String(), strings.TrimSpace(errBuf.String()), err
393 }
394
395 func gitTimeout(args []string) time.Duration {
396 if len(args) >= 2 && args[0] == "worktree" && (args[1] == "add" || args[1] == "move" || args[1] == "remove") {
397 return gitWorktreeMutationTimeout
398 }
399 // The reset after worktree add is where the checkout happens.
400 if len(args) >= 1 && args[0] == "reset" {
401 return gitWorktreeMutationTimeout
402 }
403 return gitProbeTimeout
404 }
405
406 func randomID() (string, error) {
407 var b [5]byte
408 if _, err := rand.Read(b[:]); err != nil {
409 return "", fmt.Errorf("generate worktree id: %w", err)
410 }
411 return hex.EncodeToString(b[:]), nil
412 }
413
414 func safePathComponent(name string) string {
415 name = strings.TrimSpace(name)
416 name = strings.Map(func(r rune) rune {
417 switch {
418 case r < 32:
419 return '-'
420 case strings.ContainsRune(`/\\:<>"|?*`, r):
421 return '-'
422 default:
423 return r
424 }
425 }, name)
426 name = strings.Trim(name, ". ")
427 reserved := strings.ToUpper(strings.SplitN(name, ".", 2)[0])
428 if reserved == "CON" || reserved == "PRN" || reserved == "AUX" || reserved == "NUL" ||
429 (len(reserved) == 4 && (strings.HasPrefix(reserved, "COM") || strings.HasPrefix(reserved, "LPT")) && reserved[3] >= '1' && reserved[3] <= '9') {
430 name = "_" + name
431 }
432 return name
433 }
434
435 func stderrSuffix(stderr string) string {
436 stderr = strings.TrimSpace(stderr)
437 if stderr == "" {
438 return ""
439 }
440 const max = 500
441 if len(stderr) > max {
442 stderr = stderr[:max] + "…"
443 }
444 return ": " + stderr
445 }
446
446 lines GO