返回 DeepSeek-Reasonix
metadata.go
根目录 / internal / worktree / metadata.go
1 package worktree
2
3 import (
4 "encoding/json"
5 "errors"
6 "fmt"
7 "os"
8 "path/filepath"
9 "runtime"
10 "strings"
11
12 "reasonix/internal/fileutil"
13 )
14
15 const (
16 mergeMetadataVersion = 1
17 mergeMetadataName = "metadata.json"
18 )
19
20 type mergeMetadata struct {
21 Version int `json:"version"`
22 SourceRoot string `json:"sourceRoot"`
23 TargetBranch string `json:"targetBranch"`
24 CreatedHead string `json:"createdHead"`
25 WorktreeRoot string `json:"worktreeRoot"`
26 WorktreeBranch string `json:"worktreeBranch"`
27 }
28
29 func writeMergeMetadata(result Result, targetBranch string) error {
30 metadata := mergeMetadata{
31 Version: mergeMetadataVersion,
32 SourceRoot: filepath.Clean(result.SourceRoot),
33 TargetBranch: strings.TrimSpace(targetBranch),
34 CreatedHead: strings.TrimSpace(result.Head),
35 WorktreeRoot: filepath.Clean(result.WorktreeRoot),
36 WorktreeBranch: strings.TrimSpace(result.Branch),
37 }
38 body, err := json.MarshalIndent(metadata, "", " ")
39 if err != nil {
40 return fmt.Errorf("encode worktree metadata: %w", err)
41 }
42 body = append(body, '\n')
43 if err := fileutil.AtomicCreateFile(metadataPath(result.WorktreeRoot), body, 0o600); err != nil {
44 return fmt.Errorf("publish worktree metadata: %w", err)
45 }
46 return nil
47 }
48
49 func metadataPath(worktreeRoot string) string {
50 return filepath.Join(filepath.Dir(filepath.Clean(worktreeRoot)), mergeMetadataName)
51 }
52
53 func readMergeMetadata(worktreeRoot, managedRoot string) (mergeMetadata, string, error) {
54 root, err := validateManagedWorktreePath(worktreeRoot, managedRoot)
55 if err != nil {
56 return mergeMetadata{}, "", err
57 }
58 path := metadataPath(root)
59 info, err := os.Lstat(path)
60 if errors.Is(err, os.ErrNotExist) {
61 return mergeMetadata{}, path, errors.New("this worktree predates safe Merge-Back metadata; merge it manually from its source checkout")
62 }
63 if err != nil {
64 return mergeMetadata{}, path, fmt.Errorf("inspect worktree metadata: %w", err)
65 }
66 if !info.Mode().IsRegular() {
67 return mergeMetadata{}, path, errors.New("worktree metadata is not a regular file")
68 }
69 if runtime.GOOS != "windows" && info.Mode().Perm()&0o077 != 0 {
70 return mergeMetadata{}, path, errors.New("worktree metadata permissions are too broad")
71 }
72 metadata, err := decodeMergeMetadata(path)
73 if err != nil {
74 return mergeMetadata{}, path, err
75 }
76 if err := sameDirectory(root, metadata.WorktreeRoot); err != nil {
77 return mergeMetadata{}, path, fmt.Errorf("worktree metadata root mismatch: %w", err)
78 }
79 metadata.WorktreeRoot = root
80 metadata.SourceRoot = filepath.Clean(metadata.SourceRoot)
81 metadata.TargetBranch = strings.TrimSpace(metadata.TargetBranch)
82 metadata.CreatedHead = strings.TrimSpace(metadata.CreatedHead)
83 metadata.WorktreeBranch = strings.TrimSpace(metadata.WorktreeBranch)
84 return metadata, path, nil
85 }
86
87 func readMergeMetadataForCleanup(worktreeRoot, managedRoot string) (mergeMetadata, string, bool, error) {
88 rootExists := false
89 if _, err := os.Lstat(worktreeRoot); err == nil {
90 rootExists = true
91 } else if !errors.Is(err, os.ErrNotExist) {
92 return mergeMetadata{}, "", false, fmt.Errorf("inspect cleanup worktree: %w", err)
93 }
94 absManaged, err := filepath.Abs(strings.TrimSpace(managedRoot))
95 if err != nil {
96 return mergeMetadata{}, "", false, fmt.Errorf("resolve managed worktree storage: %w", err)
97 }
98 realManaged, err := filepath.EvalSymlinks(absManaged)
99 if err != nil {
100 return mergeMetadata{}, "", false, fmt.Errorf("resolve managed worktree storage links: %w", err)
101 }
102 realRoot, err := resolveMissingCleanupPath(worktreeRoot)
103 if err != nil {
104 return mergeMetadata{}, "", false, err
105 }
106 rel, err := filepath.Rel(filepath.Clean(realManaged), filepath.Clean(realRoot))
107 parts := strings.Split(filepath.Clean(rel), string(filepath.Separator))
108 if err != nil || len(parts) != 3 || rel == ".." || strings.HasPrefix(rel, ".."+string(filepath.Separator)) {
109 return mergeMetadata{}, "", false, errors.New("cleanup worktree does not match the managed allocation layout")
110 }
111 allocationDir := filepath.Dir(realRoot)
112 realAllocation, err := filepath.EvalSymlinks(allocationDir)
113 if err != nil {
114 return mergeMetadata{}, "", false, fmt.Errorf("resolve cleanup allocation links: %w", err)
115 }
116 realRel, err := filepath.Rel(filepath.Clean(realManaged), filepath.Clean(realAllocation))
117 if err != nil || realRel == "." || realRel == ".." || strings.HasPrefix(realRel, ".."+string(filepath.Separator)) {
118 return mergeMetadata{}, "", false, errors.New("cleanup allocation resolves outside managed storage")
119 }
120 path := filepath.Join(allocationDir, mergeMetadataName)
121 metadata, err := decodeMergeMetadata(path)
122 if err != nil {
123 return mergeMetadata{}, path, false, err
124 }
125 realMetadataRoot, err := resolveMissingCleanupPath(metadata.WorktreeRoot)
126 if err != nil {
127 return mergeMetadata{}, path, false, fmt.Errorf("resolve cleanup metadata root: %w", err)
128 }
129 if filepath.Clean(realMetadataRoot) != filepath.Clean(realRoot) {
130 return mergeMetadata{}, path, false, errors.New("cleanup metadata root mismatch")
131 }
132 metadata.WorktreeRoot = filepath.Clean(realRoot)
133 metadata.SourceRoot = filepath.Clean(metadata.SourceRoot)
134 metadata.TargetBranch = strings.TrimSpace(metadata.TargetBranch)
135 metadata.CreatedHead = strings.TrimSpace(metadata.CreatedHead)
136 metadata.WorktreeBranch = strings.TrimSpace(metadata.WorktreeBranch)
137 return metadata, path, rootExists, nil
138 }
139
140 func resolveMissingCleanupPath(path string) (string, error) {
141 absPath, err := filepath.Abs(strings.TrimSpace(path))
142 if err != nil {
143 return "", fmt.Errorf("resolve cleanup worktree: %w", err)
144 }
145 realParent, err := filepath.EvalSymlinks(filepath.Dir(absPath))
146 if err != nil {
147 return "", fmt.Errorf("resolve cleanup allocation links: %w", err)
148 }
149 return filepath.Join(realParent, filepath.Base(absPath)), nil
150 }
151
152 func decodeMergeMetadata(path string) (mergeMetadata, error) {
153 info, err := os.Lstat(path)
154 if err != nil {
155 return mergeMetadata{}, fmt.Errorf("inspect worktree metadata: %w", err)
156 }
157 if !info.Mode().IsRegular() {
158 return mergeMetadata{}, errors.New("worktree metadata is not a regular file")
159 }
160 if runtime.GOOS != "windows" && info.Mode().Perm()&0o077 != 0 {
161 return mergeMetadata{}, errors.New("worktree metadata permissions are too broad")
162 }
163 body, err := os.ReadFile(path)
164 if err != nil {
165 return mergeMetadata{}, fmt.Errorf("read worktree metadata: %w", err)
166 }
167 var metadata mergeMetadata
168 decoder := json.NewDecoder(strings.NewReader(string(body)))
169 decoder.DisallowUnknownFields()
170 if err := decoder.Decode(&metadata); err != nil {
171 return mergeMetadata{}, fmt.Errorf("decode worktree metadata: %w", err)
172 }
173 if metadata.Version != mergeMetadataVersion {
174 return mergeMetadata{}, fmt.Errorf("unsupported worktree metadata version %d", metadata.Version)
175 }
176 if strings.TrimSpace(metadata.SourceRoot) == "" || strings.TrimSpace(metadata.CreatedHead) == "" ||
177 strings.TrimSpace(metadata.WorktreeRoot) == "" || strings.TrimSpace(metadata.WorktreeBranch) == "" {
178 return mergeMetadata{}, errors.New("worktree metadata is incomplete")
179 }
180 if !strings.HasPrefix(metadata.WorktreeBranch, "reasonix/delivery-") {
181 return mergeMetadata{}, errors.New("worktree metadata names an unmanaged branch")
182 }
183 return metadata, nil
184 }
185
186 func validateManagedWorktreePath(worktreeRoot, managedRoot string) (string, error) {
187 worktreeRoot = strings.TrimSpace(worktreeRoot)
188 managedRoot = strings.TrimSpace(managedRoot)
189 if worktreeRoot == "" || managedRoot == "" {
190 return "", errors.New("managed worktree identity is incomplete")
191 }
192 absManaged, err := filepath.Abs(managedRoot)
193 if err != nil {
194 return "", fmt.Errorf("resolve managed worktree storage: %w", err)
195 }
196 absRoot, err := filepath.Abs(worktreeRoot)
197 if err != nil {
198 return "", fmt.Errorf("resolve worktree root: %w", err)
199 }
200 realManaged, err := filepath.EvalSymlinks(absManaged)
201 if err != nil {
202 return "", fmt.Errorf("resolve managed worktree storage links: %w", err)
203 }
204 realRoot, err := filepath.EvalSymlinks(absRoot)
205 if err != nil {
206 return "", fmt.Errorf("resolve worktree links: %w", err)
207 }
208 realRel, err := filepath.Rel(filepath.Clean(realManaged), filepath.Clean(realRoot))
209 if err != nil || realRel == "." || realRel == "" || realRel == ".." || strings.HasPrefix(realRel, ".."+string(filepath.Separator)) {
210 return "", errors.New("worktree resolves outside Reasonix-managed storage")
211 }
212 parts := strings.Split(filepath.Clean(realRel), string(filepath.Separator))
213 if len(parts) != 3 || parts[0] == "" || parts[1] == "" || parts[2] == "" {
214 return "", errors.New("worktree does not match the managed allocation layout")
215 }
216 return filepath.Clean(realRoot), nil
217 }
218
219 func sameDirectory(left, right string) error {
220 leftInfo, err := os.Stat(left)
221 if err != nil {
222 return err
223 }
224 rightInfo, err := os.Stat(right)
225 if err != nil {
226 return err
227 }
228 if !leftInfo.IsDir() || !rightInfo.IsDir() || !os.SameFile(leftInfo, rightInfo) {
229 return errors.New("directories are not identical")
230 }
231 return nil
232 }
233
234 func verifyRollbackMetadata(sourceRoot, worktreeRoot, branch, head string) (string, error) {
235 path := metadataPath(worktreeRoot)
236 if _, err := os.Stat(path); errors.Is(err, os.ErrNotExist) {
237 return path, nil
238 } else if err != nil {
239 return path, fmt.Errorf("inspect rollback metadata: %w", err)
240 }
241 metadata, err := decodeMergeMetadata(path)
242 if err != nil || metadata.WorktreeBranch != branch || metadata.CreatedHead != head {
243 return path, errors.New("rollback worktree metadata changed; the worktree was preserved")
244 }
245 if err := sameDirectory(metadata.SourceRoot, sourceRoot); err != nil {
246 return path, errors.New("rollback source metadata changed; the worktree was preserved")
247 }
248 return path, nil
249 }
250
250 lines GO