返回 DeepSeek-Reasonix
worktree.go
根目录 / internal / worktree / worktree.go
1 // Package worktree creates durable, Git-backed workspaces for parallel
2 // Delivery sessions. Worktrees live under Reasonix-managed state, never inside
3 // the source repository, and are never deleted automatically.
4 package worktree
5
6 import (
7 "bytes"
8 "context"
9 "crypto/rand"
10 "crypto/sha256"
11 "encoding/hex"
12 "errors"
13 "fmt"
14 "os"
15 "os/exec"
16 "path/filepath"
17 "strings"
18 "time"
19
20 "reasonix/internal/gitcmd"
21 )
22
23 const (
24 gitProbeTimeout = 15 * time.Second
25 gitWorktreeAddTimeout = 5 * time.Minute
26 )
27
28 // Availability describes whether a project can be isolated with Git worktree.
29 type Availability struct {
30 Available bool `json:"available"`
31 Reason string `json:"reason,omitempty"`
32 RepoRoot string `json:"repoRoot,omitempty"`
33 Branch string `json:"branch,omitempty"`
34 SourceDirty bool `json:"sourceDirty,omitempty"`
35 }
36
37 // Result identifies one newly created isolated Delivery workspace.
38 type Result struct {
39 WorkspaceRoot string `json:"workspaceRoot"`
40 WorktreeRoot string `json:"worktreeRoot"`
41 SourceRoot string `json:"sourceRoot"`
42 Branch string `json:"branch"`
43 Head string `json:"head"`
44 SourceDirty bool `json:"sourceDirty"`
45 }
46
47 type inspection struct {
48 Availability
49 head string
50 prefix string
51 commonDir string
52 }
53
54 // Inspect checks Git and repository prerequisites without changing state.
55 func Inspect(ctx context.Context, workspaceRoot string) Availability {
56 info, err := inspect(ctx, workspaceRoot)
57 if err != nil {
58 return Availability{Available: false, Reason: err.Error()}
59 }
60 return info.Availability
61 }
62
63 // Create makes a new branch and linked worktree at managedRoot, based on the
64 // source repository's committed HEAD. Uncommitted source changes are reported
65 // but never copied or modified. When workspaceRoot names a repository
66 // subdirectory, Result.WorkspaceRoot points at the corresponding subdirectory
67 // in the new worktree.
68 func Create(ctx context.Context, workspaceRoot, managedRoot string) (Result, error) {
69 info, err := inspect(ctx, workspaceRoot)
70 if err != nil {
71 return Result{}, err
72 }
73 managedRoot = strings.TrimSpace(managedRoot)
74 if managedRoot == "" {
75 return Result{}, errors.New("Reasonix worktree storage is unavailable")
76 }
77 if err := os.MkdirAll(managedRoot, 0o700); err != nil {
78 return Result{}, fmt.Errorf("create Reasonix worktree storage: %w", err)
79 }
80
81 repoSum := sha256.Sum256([]byte(info.commonDir))
82 repoKey := hex.EncodeToString(repoSum[:8])
83 repoBase := safePathComponent(filepath.Base(info.RepoRoot))
84 if repoBase == "" {
85 repoBase = "repository"
86 }
87
88 for attempt := 0; attempt < 5; attempt++ {
89 id, randomErr := randomID()
90 if randomErr != nil {
91 return Result{}, randomErr
92 }
93 branch := fmt.Sprintf("reasonix/delivery-%s-%s", time.Now().Format("20060102-150405"), id)
94 worktreeRoot := filepath.Join(managedRoot, repoKey, id, repoBase)
95 if _, statErr := os.Stat(worktreeRoot); statErr == nil {
96 continue
97 } else if !os.IsNotExist(statErr) {
98 return Result{}, fmt.Errorf("inspect worktree destination: %w", statErr)
99 }
100 if err := os.MkdirAll(filepath.Dir(worktreeRoot), 0o700); err != nil {
101 return Result{}, fmt.Errorf("create worktree parent: %w", err)
102 }
103
104 _, stderr, addErr := runGit(ctx, info.RepoRoot, "worktree", "add", "-b", branch, worktreeRoot, info.head)
105 if addErr != nil {
106 // A random branch collision is retryable. We deliberately leave any
107 // non-empty partial directory untouched rather than risk deleting user
108 // data after Git returned an ambiguous failure.
109 if strings.Contains(strings.ToLower(stderr), "already exists") {
110 continue
111 }
112 return Result{}, fmt.Errorf("create Git worktree: %w%s", addErr, stderrSuffix(stderr))
113 }
114
115 selectedRoot := worktreeRoot
116 if prefix := filepath.FromSlash(strings.Trim(strings.TrimSpace(info.prefix), "/")); prefix != "" && prefix != "." {
117 selectedRoot = filepath.Join(worktreeRoot, prefix)
118 st, statErr := os.Stat(selectedRoot)
119 if statErr != nil || !st.IsDir() {
120 return Result{}, fmt.Errorf("created worktree is missing selected project subdirectory %q", prefix)
121 }
122 }
123 return Result{
124 WorkspaceRoot: selectedRoot,
125 WorktreeRoot: worktreeRoot,
126 SourceRoot: info.RepoRoot,
127 Branch: branch,
128 Head: info.head,
129 SourceDirty: info.SourceDirty,
130 }, nil
131 }
132 return Result{}, errors.New("could not allocate a unique Delivery worktree")
133 }
134
135 // IsManagedPath reports whether path belongs to Reasonix's durable worktree
136 // storage. It is a lexical UI identity check, not an authorization boundary.
137 func IsManagedPath(path, managedRoot string) bool {
138 path = strings.TrimSpace(path)
139 managedRoot = strings.TrimSpace(managedRoot)
140 if path == "" || managedRoot == "" {
141 return false
142 }
143 absPath, err := filepath.Abs(path)
144 if err != nil {
145 return false
146 }
147 absManaged, err := filepath.Abs(managedRoot)
148 if err != nil {
149 return false
150 }
151 rel, err := filepath.Rel(filepath.Clean(absManaged), filepath.Clean(absPath))
152 if err != nil || rel == "." || rel == "" {
153 return false
154 }
155 return rel != ".." && !strings.HasPrefix(rel, ".."+string(filepath.Separator))
156 }
157
158 func inspect(ctx context.Context, workspaceRoot string) (inspection, error) {
159 workspaceRoot = strings.TrimSpace(workspaceRoot)
160 if workspaceRoot == "" {
161 return inspection{}, errors.New("project folder is required")
162 }
163 st, err := os.Stat(workspaceRoot)
164 if err != nil {
165 return inspection{}, fmt.Errorf("project folder is unavailable: %w", err)
166 }
167 if !st.IsDir() {
168 return inspection{}, errors.New("project path is not a folder")
169 }
170 if _, err := exec.LookPath("git"); err != nil {
171 return inspection{}, errors.New("Git is not installed; Delivery remains safe and will serialize writes in this folder")
172 }
173
174 repoRoot, stderr, err := runGit(ctx, workspaceRoot, "rev-parse", "--show-toplevel")
175 if err != nil {
176 return inspection{}, fmt.Errorf("project folder is not inside a Git repository%s", stderrSuffix(stderr))
177 }
178 repoRoot = filepath.Clean(strings.TrimSpace(repoRoot))
179 if repoRoot == "" {
180 return inspection{}, errors.New("Git did not report a repository root")
181 }
182 bare, _, err := runGit(ctx, workspaceRoot, "rev-parse", "--is-bare-repository")
183 if err != nil || strings.EqualFold(strings.TrimSpace(bare), "true") {
184 return inspection{}, errors.New("bare Git repositories cannot be opened as Delivery workspaces")
185 }
186 head, _, err := runGit(ctx, repoRoot, "rev-parse", "--verify", "HEAD")
187 if err != nil || strings.TrimSpace(head) == "" {
188 return inspection{}, errors.New("the Git repository needs an initial commit before a worktree can be created")
189 }
190 head = strings.TrimSpace(head)
191 prefix, _, err := runGit(ctx, workspaceRoot, "rev-parse", "--show-prefix")
192 if err != nil {
193 return inspection{}, fmt.Errorf("resolve selected project path inside repository: %w", err)
194 }
195 prefix = strings.TrimSpace(prefix)
196 if prefix != "" {
197 objectType, _, objectErr := runGit(ctx, repoRoot, "cat-file", "-t", head+":"+strings.TrimSuffix(prefix, "/"))
198 if objectErr != nil || strings.TrimSpace(objectType) != "tree" {
199 return inspection{}, errors.New("the selected project folder is not present in the committed HEAD; commit it before creating a worktree")
200 }
201 }
202 commonDir, _, err := runGit(ctx, repoRoot, "rev-parse", "--git-common-dir")
203 if err != nil {
204 return inspection{}, fmt.Errorf("resolve Git common directory: %w", err)
205 }
206 commonDir = strings.TrimSpace(commonDir)
207 if !filepath.IsAbs(commonDir) {
208 commonDir = filepath.Join(repoRoot, commonDir)
209 }
210 commonDir = filepath.Clean(commonDir)
211 branch, _, _ := runGit(ctx, repoRoot, "symbolic-ref", "--quiet", "--short", "HEAD")
212 status, _, statusErr := runGit(ctx, repoRoot, "status", "--porcelain=v1", "--untracked-files=normal")
213 if statusErr != nil {
214 return inspection{}, fmt.Errorf("inspect Git working tree: %w", statusErr)
215 }
216 return inspection{
217 Availability: Availability{
218 Available: true,
219 RepoRoot: repoRoot,
220 Branch: strings.TrimSpace(branch),
221 SourceDirty: strings.TrimSpace(status) != "",
222 },
223 head: head,
224 prefix: prefix,
225 commonDir: commonDir,
226 }, nil
227 }
228
229 func runGit(parent context.Context, dir string, args ...string) (stdout, stderr string, err error) {
230 if parent == nil {
231 parent = context.Background()
232 }
233 ctx, cancel := context.WithTimeout(parent, gitTimeout(args))
234 defer cancel()
235 cmd := gitcmd.Command(ctx, dir, args...)
236 var outBuf, errBuf bytes.Buffer
237 cmd.Stdout = &outBuf
238 cmd.Stderr = &errBuf
239 err = cmd.Run()
240 if ctx.Err() != nil {
241 err = ctx.Err()
242 }
243 return outBuf.String(), strings.TrimSpace(errBuf.String()), err
244 }
245
246 func gitTimeout(args []string) time.Duration {
247 if len(args) >= 2 && args[0] == "worktree" && args[1] == "add" {
248 return gitWorktreeAddTimeout
249 }
250 return gitProbeTimeout
251 }
252
253 func randomID() (string, error) {
254 var b [5]byte
255 if _, err := rand.Read(b[:]); err != nil {
256 return "", fmt.Errorf("generate worktree id: %w", err)
257 }
258 return hex.EncodeToString(b[:]), nil
259 }
260
261 func safePathComponent(name string) string {
262 name = strings.TrimSpace(name)
263 name = strings.Map(func(r rune) rune {
264 switch {
265 case r < 32:
266 return '-'
267 case strings.ContainsRune(`/\\:<>"|?*`, r):
268 return '-'
269 default:
270 return r
271 }
272 }, name)
273 name = strings.Trim(name, ". ")
274 reserved := strings.ToUpper(strings.SplitN(name, ".", 2)[0])
275 if reserved == "CON" || reserved == "PRN" || reserved == "AUX" || reserved == "NUL" ||
276 (len(reserved) == 4 && (strings.HasPrefix(reserved, "COM") || strings.HasPrefix(reserved, "LPT")) && reserved[3] >= '1' && reserved[3] <= '9') {
277 name = "_" + name
278 }
279 return name
280 }
281
282 func stderrSuffix(stderr string) string {
283 stderr = strings.TrimSpace(stderr)
284 if stderr == "" {
285 return ""
286 }
287 const max = 500
288 if len(stderr) > max {
289 stderr = stderr[:max] + "…"
290 }
291 return ": " + stderr
292 }
293
293 lines GO