返回 DeepSeek-Reasonix
metadata.go
根目录 / internal / worktree / metadata.go
1 package worktree
2
3 import (
4 "context"
5 "encoding/json"
6 "errors"
7 "fmt"
8 "os"
9 "path/filepath"
10 "runtime"
11 "strings"
12
13 "reasonix/internal/fileutil"
14 "reasonix/internal/gitcmd"
15 )
16
17 const (
18 mergeMetadataVersion = 1
19 mergeMetadataName = "metadata.json"
20 // mergeIdentityName holds the pinned identities beside metadata.json, whose
21 // shape older builds decode strictly and so must not change.
22 mergeIdentityName = "metadata.repo.json"
23 )
24
25 type mergeMetadata struct {
26 Version int `json:"version"`
27 SourceRoot string `json:"sourceRoot"`
28 TargetBranch string `json:"targetBranch"`
29 CreatedHead string `json:"createdHead"`
30 WorktreeRoot string `json:"worktreeRoot"`
31 WorktreeBranch string `json:"worktreeBranch"`
32 // The identities pinned at creation, kept in mergeIdentityName; without
33 // that file they are resolved from the roots (see source and worktreeAt).
34 SourceRepo gitcmd.Repo `json:"-"`
35 WorktreeRepo gitcmd.Repo `json:"-"`
36 }
37
38 // mergeIdentity is the content of mergeIdentityName.
39 type mergeIdentity struct {
40 SourceRepo gitcmd.Repo `json:"sourceRepo"`
41 WorktreeRepo gitcmd.Repo `json:"worktreeRepo"`
42 }
43
44 func writeMergeMetadata(result Result, targetBranch string) error {
45 metadata := mergeMetadata{
46 Version: mergeMetadataVersion,
47 SourceRoot: filepath.Clean(result.SourceRoot),
48 TargetBranch: strings.TrimSpace(targetBranch),
49 CreatedHead: strings.TrimSpace(result.Head),
50 WorktreeRoot: filepath.Clean(result.WorktreeRoot),
51 WorktreeBranch: strings.TrimSpace(result.Branch),
52 SourceRepo: result.SourceRepo,
53 WorktreeRepo: result.WorktreeRepo,
54 }
55 body, err := json.MarshalIndent(metadata, "", " ")
56 if err != nil {
57 return fmt.Errorf("encode worktree metadata: %w", err)
58 }
59 body = append(body, '\n')
60 if err := fileutil.AtomicCreateFile(metadataPath(result.WorktreeRoot), body, 0o600); err != nil {
61 return fmt.Errorf("publish worktree metadata: %w", err)
62 }
63 identity, err := json.MarshalIndent(mergeIdentity{SourceRepo: result.SourceRepo, WorktreeRepo: result.WorktreeRepo}, "", " ")
64 if err != nil {
65 return fmt.Errorf("encode worktree identity: %w", err)
66 }
67 if err := fileutil.AtomicCreateFile(identityPath(metadataPath(result.WorktreeRoot)), append(identity, '\n'), 0o600); err != nil {
68 return fmt.Errorf("publish worktree identity: %w", err)
69 }
70 return nil
71 }
72
73 func identityPath(metadataFile string) string {
74 return filepath.Join(filepath.Dir(metadataFile), mergeIdentityName)
75 }
76
77 // removeMergeMetadata removes metadataFile and the identity beside it.
78 func removeMergeMetadata(metadataFile string) error {
79 if err := os.Remove(identityPath(metadataFile)); err != nil && !errors.Is(err, os.ErrNotExist) {
80 return err
81 }
82 if err := os.Remove(metadataFile); err != nil && !errors.Is(err, os.ErrNotExist) {
83 return err
84 }
85 return nil
86 }
87
88 // readMergeIdentity fills the pinned identities from the file beside
89 // metadataFile; a missing file leaves them unresolved.
90 func readMergeIdentity(metadataFile string, metadata *mergeMetadata) error {
91 path := identityPath(metadataFile)
92 info, err := os.Lstat(path)
93 if errors.Is(err, os.ErrNotExist) {
94 return nil
95 }
96 if err != nil {
97 return fmt.Errorf("inspect worktree identity: %w", err)
98 }
99 if !info.Mode().IsRegular() || (runtime.GOOS != "windows" && info.Mode().Perm()&0o077 != 0) {
100 return errors.New("worktree identity is not a private regular file")
101 }
102 body, err := os.ReadFile(path)
103 if err != nil {
104 return fmt.Errorf("read worktree identity: %w", err)
105 }
106 var identity mergeIdentity
107 decoder := json.NewDecoder(strings.NewReader(string(body)))
108 decoder.DisallowUnknownFields()
109 if err := decoder.Decode(&identity); err != nil {
110 return fmt.Errorf("decode worktree identity: %w", err)
111 }
112 metadata.SourceRepo, metadata.WorktreeRepo = identity.SourceRepo, identity.WorktreeRepo
113 return nil
114 }
115
116 // source is the source checkout's pinned identity, checked against the
117 // recorded source root.
118 func (m mergeMetadata) source(ctx context.Context) (gitcmd.Repo, error) {
119 repo := m.SourceRepo
120 if !repo.Valid() {
121 var err error
122 if repo, err = gitcmd.Open(ctx, m.SourceRoot); err != nil {
123 return repo, err
124 }
125 }
126 repo = repo.Top()
127 if err := repo.Verify(); err != nil {
128 return repo, fmt.Errorf("source checkout identity changed: %w", err)
129 }
130 if err := sameDirectory(repo.WorkTree, m.SourceRoot); err != nil {
131 return repo, fmt.Errorf("source checkout identity changed: %w", err)
132 }
133 return repo, nil
134 }
135
136 // worktreeAt is the worktree's pinned identity with its checkout at root,
137 // where a cleanup move may have put it; git keeps its administrative dir.
138 func (m mergeMetadata) worktreeAt(ctx context.Context, root string) (gitcmd.Repo, error) {
139 if !m.WorktreeRepo.Valid() {
140 repo, err := gitcmd.Open(ctx, root)
141 return repo.Top(), err
142 }
143 repo := m.WorktreeRepo
144 repo.Dir, repo.WorkTree = root, root
145 return repo, repo.Verify()
146 }
147
148 func metadataPath(worktreeRoot string) string {
149 return filepath.Join(filepath.Dir(filepath.Clean(worktreeRoot)), mergeMetadataName)
150 }
151
152 func readMergeMetadata(worktreeRoot, managedRoot string) (mergeMetadata, string, error) {
153 root, err := validateManagedWorktreePath(worktreeRoot, managedRoot)
154 if err != nil {
155 return mergeMetadata{}, "", err
156 }
157 path := metadataPath(root)
158 info, err := os.Lstat(path)
159 if errors.Is(err, os.ErrNotExist) {
160 return mergeMetadata{}, path, errors.New("this worktree predates safe Merge-Back metadata; merge it manually from its source checkout")
161 }
162 if err != nil {
163 return mergeMetadata{}, path, fmt.Errorf("inspect worktree metadata: %w", err)
164 }
165 if !info.Mode().IsRegular() {
166 return mergeMetadata{}, path, errors.New("worktree metadata is not a regular file")
167 }
168 if runtime.GOOS != "windows" && info.Mode().Perm()&0o077 != 0 {
169 return mergeMetadata{}, path, errors.New("worktree metadata permissions are too broad")
170 }
171 metadata, err := decodeMergeMetadata(path)
172 if err != nil {
173 return mergeMetadata{}, path, err
174 }
175 if err := sameDirectory(root, metadata.WorktreeRoot); err != nil {
176 return mergeMetadata{}, path, fmt.Errorf("worktree metadata root mismatch: %w", err)
177 }
178 metadata.WorktreeRoot = root
179 metadata.SourceRoot = filepath.Clean(metadata.SourceRoot)
180 metadata.TargetBranch = strings.TrimSpace(metadata.TargetBranch)
181 metadata.CreatedHead = strings.TrimSpace(metadata.CreatedHead)
182 metadata.WorktreeBranch = strings.TrimSpace(metadata.WorktreeBranch)
183 return metadata, path, nil
184 }
185
186 func readMergeMetadataForCleanup(worktreeRoot, managedRoot string) (mergeMetadata, string, bool, error) {
187 rootExists := false
188 if _, err := os.Lstat(worktreeRoot); err == nil {
189 rootExists = true
190 } else if !errors.Is(err, os.ErrNotExist) {
191 return mergeMetadata{}, "", false, fmt.Errorf("inspect cleanup worktree: %w", err)
192 }
193 absManaged, err := filepath.Abs(strings.TrimSpace(managedRoot))
194 if err != nil {
195 return mergeMetadata{}, "", false, fmt.Errorf("resolve managed worktree storage: %w", err)
196 }
197 realManaged, err := filepath.EvalSymlinks(absManaged)
198 if err != nil {
199 return mergeMetadata{}, "", false, fmt.Errorf("resolve managed worktree storage links: %w", err)
200 }
201 realRoot, err := resolveMissingCleanupPath(worktreeRoot)
202 if err != nil {
203 return mergeMetadata{}, "", false, err
204 }
205 rel, err := filepath.Rel(filepath.Clean(realManaged), filepath.Clean(realRoot))
206 parts := strings.Split(filepath.Clean(rel), string(filepath.Separator))
207 if err != nil || len(parts) != 3 || rel == ".." || strings.HasPrefix(rel, ".."+string(filepath.Separator)) {
208 return mergeMetadata{}, "", false, errors.New("cleanup worktree does not match the managed allocation layout")
209 }
210 allocationDir := filepath.Dir(realRoot)
211 realAllocation, err := filepath.EvalSymlinks(allocationDir)
212 if err != nil {
213 return mergeMetadata{}, "", false, fmt.Errorf("resolve cleanup allocation links: %w", err)
214 }
215 realRel, err := filepath.Rel(filepath.Clean(realManaged), filepath.Clean(realAllocation))
216 if err != nil || realRel == "." || realRel == ".." || strings.HasPrefix(realRel, ".."+string(filepath.Separator)) {
217 return mergeMetadata{}, "", false, errors.New("cleanup allocation resolves outside managed storage")
218 }
219 path := filepath.Join(allocationDir, mergeMetadataName)
220 metadata, err := decodeMergeMetadata(path)
221 if err != nil {
222 return mergeMetadata{}, path, false, err
223 }
224 realMetadataRoot, err := resolveMissingCleanupPath(metadata.WorktreeRoot)
225 if err != nil {
226 return mergeMetadata{}, path, false, fmt.Errorf("resolve cleanup metadata root: %w", err)
227 }
228 if filepath.Clean(realMetadataRoot) != filepath.Clean(realRoot) {
229 return mergeMetadata{}, path, false, errors.New("cleanup metadata root mismatch")
230 }
231 metadata.WorktreeRoot = filepath.Clean(realRoot)
232 metadata.SourceRoot = filepath.Clean(metadata.SourceRoot)
233 metadata.TargetBranch = strings.TrimSpace(metadata.TargetBranch)
234 metadata.CreatedHead = strings.TrimSpace(metadata.CreatedHead)
235 metadata.WorktreeBranch = strings.TrimSpace(metadata.WorktreeBranch)
236 return metadata, path, rootExists, nil
237 }
238
239 func resolveMissingCleanupPath(path string) (string, error) {
240 absPath, err := filepath.Abs(strings.TrimSpace(path))
241 if err != nil {
242 return "", fmt.Errorf("resolve cleanup worktree: %w", err)
243 }
244 realParent, err := filepath.EvalSymlinks(filepath.Dir(absPath))
245 if err != nil {
246 return "", fmt.Errorf("resolve cleanup allocation links: %w", err)
247 }
248 return filepath.Join(realParent, filepath.Base(absPath)), nil
249 }
250
251 func decodeMergeMetadata(path string) (mergeMetadata, error) {
252 info, err := os.Lstat(path)
253 if err != nil {
254 return mergeMetadata{}, fmt.Errorf("inspect worktree metadata: %w", err)
255 }
256 if !info.Mode().IsRegular() {
257 return mergeMetadata{}, errors.New("worktree metadata is not a regular file")
258 }
259 if runtime.GOOS != "windows" && info.Mode().Perm()&0o077 != 0 {
260 return mergeMetadata{}, errors.New("worktree metadata permissions are too broad")
261 }
262 body, err := os.ReadFile(path)
263 if err != nil {
264 return mergeMetadata{}, fmt.Errorf("read worktree metadata: %w", err)
265 }
266 var metadata mergeMetadata
267 decoder := json.NewDecoder(strings.NewReader(string(body)))
268 decoder.DisallowUnknownFields()
269 if err := decoder.Decode(&metadata); err != nil {
270 return mergeMetadata{}, fmt.Errorf("decode worktree metadata: %w", err)
271 }
272 if metadata.Version != mergeMetadataVersion {
273 return mergeMetadata{}, fmt.Errorf("unsupported worktree metadata version %d", metadata.Version)
274 }
275 if strings.TrimSpace(metadata.SourceRoot) == "" || strings.TrimSpace(metadata.CreatedHead) == "" ||
276 strings.TrimSpace(metadata.WorktreeRoot) == "" || strings.TrimSpace(metadata.WorktreeBranch) == "" {
277 return mergeMetadata{}, errors.New("worktree metadata is incomplete")
278 }
279 if !strings.HasPrefix(metadata.WorktreeBranch, "reasonix/delivery-") {
280 return mergeMetadata{}, errors.New("worktree metadata names an unmanaged branch")
281 }
282 if err := readMergeIdentity(path, &metadata); err != nil {
283 return mergeMetadata{}, err
284 }
285 return metadata, nil
286 }
287
288 func validateManagedWorktreePath(worktreeRoot, managedRoot string) (string, error) {
289 worktreeRoot = strings.TrimSpace(worktreeRoot)
290 managedRoot = strings.TrimSpace(managedRoot)
291 if worktreeRoot == "" || managedRoot == "" {
292 return "", errors.New("managed worktree identity is incomplete")
293 }
294 absManaged, err := filepath.Abs(managedRoot)
295 if err != nil {
296 return "", fmt.Errorf("resolve managed worktree storage: %w", err)
297 }
298 absRoot, err := filepath.Abs(worktreeRoot)
299 if err != nil {
300 return "", fmt.Errorf("resolve worktree root: %w", err)
301 }
302 realManaged, err := filepath.EvalSymlinks(absManaged)
303 if err != nil {
304 return "", fmt.Errorf("resolve managed worktree storage links: %w", err)
305 }
306 realRoot, err := filepath.EvalSymlinks(absRoot)
307 if err != nil {
308 return "", fmt.Errorf("resolve worktree links: %w", err)
309 }
310 realRel, err := filepath.Rel(filepath.Clean(realManaged), filepath.Clean(realRoot))
311 if err != nil || realRel == "." || realRel == "" || realRel == ".." || strings.HasPrefix(realRel, ".."+string(filepath.Separator)) {
312 return "", errors.New("worktree resolves outside Reasonix-managed storage")
313 }
314 parts := strings.Split(filepath.Clean(realRel), string(filepath.Separator))
315 if len(parts) != 3 || parts[0] == "" || parts[1] == "" || parts[2] == "" {
316 return "", errors.New("worktree does not match the managed allocation layout")
317 }
318 return filepath.Clean(realRoot), nil
319 }
320
321 func sameDirectory(left, right string) error {
322 leftInfo, err := os.Stat(left)
323 if err != nil {
324 return err
325 }
326 rightInfo, err := os.Stat(right)
327 if err != nil {
328 return err
329 }
330 if !leftInfo.IsDir() || !rightInfo.IsDir() || !os.SameFile(leftInfo, rightInfo) {
331 return errors.New("directories are not identical")
332 }
333 return nil
334 }
335
336 func verifyRollbackMetadata(sourceRoot, worktreeRoot, branch, head string) (string, error) {
337 path := metadataPath(worktreeRoot)
338 if _, err := os.Stat(path); errors.Is(err, os.ErrNotExist) {
339 return path, nil
340 } else if err != nil {
341 return path, fmt.Errorf("inspect rollback metadata: %w", err)
342 }
343 metadata, err := decodeMergeMetadata(path)
344 if err != nil || metadata.WorktreeBranch != branch || metadata.CreatedHead != head {
345 return path, errors.New("rollback worktree metadata changed; the worktree was preserved")
346 }
347 if err := sameDirectory(metadata.SourceRoot, sourceRoot); err != nil {
348 return path, errors.New("rollback source metadata changed; the worktree was preserved")
349 }
350 return path, nil
351 }
352
352 lines GO