返回 DeepSeek-Reasonix
recovery_gc.go
根目录 / internal / agent / recovery_gc.go
1 package agent
2
3 import (
4 "bytes"
5 "encoding/json"
6 "errors"
7 "fmt"
8 "os"
9 "path/filepath"
10 "strings"
11 "time"
12
13 "reasonix/internal/fileutil"
14 "reasonix/internal/store"
15 )
16
17 // Recovery-branch garbage collection. Conflict recovery forks a copy of the
18 // in-memory transcript whenever a save conflicts (#5993); the triggers are
19 // fixed, but every fork that ever happened still sits in the session list
20 // until the user trashes it by hand. Most of them preserve nothing: the
21 // original session went on to contain everything the fork saved. Those — and
22 // only those — are safe to reclaim automatically.
23
24 // RecoveryGCGracePeriod is how long a reclaimable recovery branch must sit
25 // idle before GC may collect it. A fresh fork is part of an active conflict
26 // flow — the user may be comparing it against the original right now.
27 const RecoveryGCGracePeriod = 24 * time.Hour
28
29 const (
30 recoveryTrashDir = ".trash"
31 recoveryTrashMetaFile = ".trash-meta.json"
32 recoveryTrashOperationPrefix = "recovery-trash:"
33 recoveryTrashPendingFile = ".recovery-trash-pending.json"
34 recoveryTrashStagingPrefix = ".recovery-trash-staging-"
35 )
36
37 // ErrRecoveryBranchNotCovered means the branch cannot currently be proven
38 // redundant with its parent. Destructive callers must preserve it.
39 var ErrRecoveryBranchNotCovered = errors.New("recovery branch is not covered by its parent")
40
41 // ErrRecoveryBranchNotIdle means the branch has not yet passed the safety
42 // grace period. It remains visible and may be retried by a later GC pass.
43 var ErrRecoveryBranchNotIdle = errors.New("recovery branch is still inside its safety grace period")
44
45 type recoveryTrashMeta struct {
46 Key string `json:"key"`
47 DeletedAt int64 `json:"deletedAt"`
48 }
49
50 type recoveryTrashPendingMeta struct {
51 Key string `json:"key"`
52 }
53
54 // SessionLeaseHeld reports whether ANY live runtime — this process included —
55 // holds the session's write lease. SessionLeaseHeldByOtherRuntime deliberately
56 // answers false for the current process; GC needs the stricter question, since
57 // a branch open in one of our own tabs is just as much in use.
58 func SessionLeaseHeld(path string) bool {
59 if strings.TrimSpace(path) == "" {
60 return false
61 }
62 if _, ok := sessionLeaseOwners.Load(canonicalSessionSavePath(path)); ok {
63 return true
64 }
65 return SessionLeaseHeldByOtherRuntime(path)
66 }
67
68 // RecoveryBranchCoveredByParent reports whether a conflict-recovery branch
69 // preserves no content that is absent from its parent. It deliberately reads
70 // both transcripts instead of trusting listing sidecars: stale metadata must
71 // never authorize hiding, migration skipping, bulk trash, or permanent purge.
72 // Missing/corrupt metadata, a changed branch, or a missing/diverged parent are
73 // all treated conservatively as not covered.
74 func RecoveryBranchCoveredByParent(path, parentDir string) bool {
75 meta, ok, err := LoadBranchMeta(path)
76 if err != nil || !ok || !meta.Recovered || strings.TrimSpace(meta.RecoveryDigest) == "" {
77 return false
78 }
79 return recoveryBranchCoveredByParent(path, parentDir, meta)
80 }
81
82 // TryAcquireRecoveryParentGuard verifies that a recovery branch is covered by
83 // its parent while holding the parent's save and lease locks. The caller must
84 // keep the returned guard until permanent deletion finishes, then Release it.
85 // If the parent is open or being rewritten, acquisition fails without waiting
86 // so bulk cleanup preserves the branch and can be retried later.
87 func TryAcquireRecoveryParentGuard(path, parentDir string) (*SessionRemovalGuard, error) {
88 meta, ok, err := LoadBranchMeta(path)
89 if err != nil || !ok || !meta.Recovered || strings.TrimSpace(meta.RecoveryDigest) == "" {
90 return nil, ErrRecoveryBranchNotCovered
91 }
92 parentID := strings.TrimSpace(meta.ParentID)
93 if parentID == "" {
94 return nil, ErrRecoveryBranchNotCovered
95 }
96 parentDir = strings.TrimSpace(parentDir)
97 if parentDir == "" {
98 parentDir = filepath.Dir(path)
99 }
100 parentPath := filepath.Join(parentDir, parentID+".jsonl")
101 if parentPath == path || !IsVisibleSession(parentPath) {
102 return nil, ErrRecoveryBranchNotCovered
103 }
104 guard, err := TryAcquireSessionRemovalGuard(parentPath)
105 if err != nil {
106 return nil, err
107 }
108 if !recoveryBranchCoveredByParent(path, parentDir, meta) {
109 guard.Release()
110 return nil, ErrRecoveryBranchNotCovered
111 }
112 return guard, nil
113 }
114
115 func recoveryBranchCoveredByParent(path, parentDir string, meta BranchMeta) bool {
116 parentID := strings.TrimSpace(meta.ParentID)
117 if parentID == "" {
118 return false
119 }
120 branch, err := LoadSession(path)
121 if err != nil || branch == nil {
122 return false
123 }
124 branchMsgs := branch.Snapshot()
125 branchDigest, err := digestSessionMessages(branchMsgs)
126 if err != nil || digestString(branchDigest) != strings.TrimSpace(meta.RecoveryDigest) {
127 // Continued on (or undigestable): this is someone's conversation now.
128 return false
129 }
130 parentDir = strings.TrimSpace(parentDir)
131 if parentDir == "" {
132 parentDir = filepath.Dir(path)
133 }
134 parentPath := filepath.Join(parentDir, parentID+".jsonl")
135 if parentPath == path || !IsVisibleSession(parentPath) {
136 return false
137 }
138 parent, err := LoadSession(parentPath)
139 if err != nil || parent == nil {
140 return false
141 }
142 parentMsgs := parent.Snapshot()
143 parentDigest, err := digestSessionMessages(parentMsgs)
144 if err != nil {
145 return false
146 }
147 return bytes.Equal(parentDigest[:], branchDigest[:]) ||
148 messagesHavePrefix(parentMsgs, branchMsgs) ||
149 messagesHavePrefixWithCompatibleSystem(parentMsgs, branchMsgs)
150 }
151
152 // ReclaimableRecoveryBranches scans dir for conflict-recovery branches that
153 // are safe to dispose of. Every condition must hold — when in doubt the branch
154 // stays, because a recovery branch exists precisely to prevent data loss:
155 //
156 // 1. The branch meta says Recovered and records the fork digest.
157 // 2. The transcript still matches that fork digest: the branch was never
158 // continued on. A single follow-up turn disqualifies it permanently.
159 // 3. The parent transcript (meta.ParentID, same directory) exists and covers
160 // the branch content — equal digest, or the branch is a strict prefix
161 // (allowing a compatible leading-system swap). These are the same checks
162 // SaveRecoveryBranch uses to declare a recovery not needed in the first
163 // place, so "covered" here means the fork preserves nothing unique.
164 // 4. No live runtime holds the branch's session lease.
165 // 5. The branch has been idle for at least grace.
166 //
167 // It returns candidate paths only; disposal (trash, delete) is caller policy.
168 func ReclaimableRecoveryBranches(dir string, now time.Time, grace time.Duration) ([]string, error) {
169 dir = strings.TrimSpace(dir)
170 if dir == "" {
171 return nil, nil
172 }
173 entries, err := os.ReadDir(dir)
174 if err != nil {
175 if os.IsNotExist(err) {
176 return nil, nil
177 }
178 return nil, err
179 }
180 var out []string
181 for _, e := range entries {
182 if e.IsDir() || !strings.HasSuffix(e.Name(), ".jsonl") || strings.HasSuffix(e.Name(), ".events.jsonl") {
183 continue
184 }
185 path := filepath.Join(dir, e.Name())
186 if !IsVisibleSession(path) {
187 continue
188 }
189 meta, ok, err := LoadBranchMeta(path)
190 if err != nil || !ok || !meta.Recovered || strings.TrimSpace(meta.RecoveryDigest) == "" {
191 continue
192 }
193 if strings.TrimSpace(meta.ParentID) == "" {
194 continue
195 }
196 if !recoveryBranchIdle(path, meta, now, grace) {
197 continue
198 }
199 if SessionLeaseHeld(path) {
200 continue
201 }
202 if !recoveryBranchCoveredByParent(path, dir, meta) {
203 continue
204 }
205 out = append(out, path)
206 }
207 return out, nil
208 }
209
210 // TrashReclaimableRecoveryBranch moves one redundant recovery branch into the
211 // same recoverable .trash layout used by Desktop. It rechecks parent coverage
212 // while holding both the parent and branch removal guards, so a concurrent save
213 // cannot turn a redundant copy into unique history between verification and
214 // relocation. The operation is durable: an interrupted move stays in an
215 // invisible staging directory and is completed by ReconcileCleanupPending on
216 // startup.
217 func TrashReclaimableRecoveryBranch(path, parentDir string) error {
218 path = filepath.Clean(strings.TrimSpace(path))
219 parentDir = filepath.Clean(strings.TrimSpace(parentDir))
220 if path == "." || parentDir == "." || filepath.Dir(path) != parentDir {
221 return fmt.Errorf("recovery branch must be a direct child of its session directory")
222 }
223 key := filepath.Base(path)
224 if !strings.HasSuffix(key, ".jsonl") || strings.HasSuffix(key, ".events.jsonl") {
225 return fmt.Errorf("invalid recovery session path")
226 }
227
228 parentGuard, err := TryAcquireRecoveryParentGuard(path, parentDir)
229 if err != nil {
230 return err
231 }
232 defer parentGuard.Release()
233
234 branchGuard, err := TryAcquireSessionRemovalGuard(path)
235 if err != nil {
236 return err
237 }
238 defer branchGuard.Release()
239 meta, ok, err := LoadBranchMeta(path)
240 if err != nil || !ok || !recoveryBranchIdle(path, meta, time.Now(), RecoveryGCGracePeriod) {
241 return ErrRecoveryBranchNotIdle
242 }
243 if !RecoveryBranchCoveredByParent(path, parentDir) {
244 return ErrRecoveryBranchNotCovered
245 }
246
247 stageDir, err := reserveRecoveryTrashStage(parentDir)
248 if err != nil {
249 return err
250 }
251 // Keep the move invisible until every artifact is staged. Older Reasonix
252 // versions ignore the non-session staging directory, while new versions can
253 // finish it from the durable in-directory marker after a crash. Publishing is
254 // one same-filesystem rename, so Desktop can never restore or purge a split
255 // transcript/sidecar set.
256 if err := prepareRecoveryTrashStage(path, key, stageDir); err != nil {
257 return err
258 }
259 return finishRecoveryTrashStage(parentDir, path, key, stageDir, branchGuard)
260 }
261
262 func recoveryBranchIdle(path string, meta BranchMeta, now time.Time, grace time.Duration) bool {
263 idleSince := meta.UpdatedAt
264 if idleSince.IsZero() {
265 info, err := os.Stat(path)
266 if err != nil {
267 return false
268 }
269 idleSince = info.ModTime()
270 }
271 return now.Sub(idleSince) >= grace
272 }
273
274 // reconcileRecoveryTrashPending completes an interrupted move left by the
275 // pre-staging recovery-trash protocol. Keep this compatibility path so users
276 // upgrading from an intermediate build do not strand its typed marker.
277 func reconcileRecoveryTrashPending(item CleanupPendingInfo) (bool, error) {
278 operation := strings.TrimSpace(item.Meta.Operation)
279 if !strings.HasPrefix(operation, recoveryTrashOperationPrefix) {
280 return false, nil
281 }
282 itemName := strings.TrimPrefix(operation, recoveryTrashOperationPrefix)
283 if itemName == "" || filepath.Base(itemName) != itemName || itemName == "." || itemName == ".." {
284 return true, fmt.Errorf("invalid recovery trash target")
285 }
286 path := filepath.Clean(item.SessionPath)
287 dir := filepath.Dir(path)
288 key := filepath.Base(path)
289 guard, err := TryAcquireSessionRemovalGuard(path)
290 if err != nil {
291 return true, err
292 }
293 defer guard.Release()
294 return true, finishRecoveryTrashMove(dir, path, key, filepath.Join(dir, recoveryTrashDir, itemName), guard)
295 }
296
297 // reconcileRecoveryTrashStages completes the atomic staging protocol used by
298 // new runtimes. A staging directory is intentionally not a valid Desktop trash
299 // item: it has neither a session-shaped directory name nor .trash-meta.json.
300 // Once complete, the whole directory is renamed into place atomically.
301 func reconcileRecoveryTrashStages(dir string) error {
302 dir = strings.TrimSpace(dir)
303 if dir == "" {
304 return nil
305 }
306 root := filepath.Join(dir, recoveryTrashDir)
307 entries, err := os.ReadDir(root)
308 if err != nil {
309 if os.IsNotExist(err) {
310 return nil
311 }
312 return err
313 }
314 var errs []error
315 for _, entry := range entries {
316 if !entry.IsDir() {
317 continue
318 }
319 itemDir := filepath.Join(root, entry.Name())
320 if _, err := os.Stat(filepath.Join(itemDir, recoveryTrashPendingFile)); err != nil {
321 if os.IsNotExist(err) {
322 continue
323 }
324 errs = append(errs, fmt.Errorf("inspect recovery trash stage %s: %w", itemDir, err))
325 continue
326 }
327 if err := reconcileRecoveryTrashStage(dir, itemDir); err != nil {
328 errs = append(errs, fmt.Errorf("reconcile recovery trash stage %s: %w", itemDir, err))
329 }
330 }
331 return errors.Join(errs...)
332 }
333
334 func reconcileRecoveryTrashStage(dir, itemDir string) error {
335 pending, err := readRecoveryTrashPending(itemDir)
336 if err != nil {
337 return err
338 }
339 key := strings.TrimSpace(pending.Key)
340 if !validRecoveryTrashKey(key) {
341 return fmt.Errorf("invalid recovery trash key")
342 }
343 stagedPath := filepath.Join(itemDir, key)
344 staged, err := regularRecoveryTrashPath(stagedPath)
345 if err != nil {
346 return err
347 }
348
349 // A non-staging name means the atomic directory rename already succeeded;
350 // only visibility metadata/final marker cleanup may remain.
351 if !strings.HasPrefix(filepath.Base(itemDir), recoveryTrashStagingPrefix) {
352 if !staged {
353 return fmt.Errorf("published recovery trash item is missing transcript")
354 }
355 if _, err := os.Stat(filepath.Join(itemDir, recoveryTrashMetaFile)); err == nil {
356 return clearRecoveryTrashPending(itemDir)
357 } else if !os.IsNotExist(err) {
358 return err
359 }
360 if err := writeRecoveryTrashMetaExisting(itemDir, key); err != nil {
361 if os.IsNotExist(err) {
362 return nil // restored or purged after complete publication
363 }
364 return err
365 }
366 return clearRecoveryTrashPending(itemDir)
367 }
368
369 livePath := filepath.Join(dir, key)
370 live, err := regularRecoveryTrashPath(livePath)
371 if err != nil {
372 return err
373 }
374 switch {
375 case live && !staged:
376 // The durable marker landed but the first rename did not. No session
377 // artifact has moved, so discard the empty stage and let a later GC pass
378 // revalidate coverage before trying again.
379 return removeEmptyRecoveryTrashStage(itemDir)
380 case live && staged:
381 return fmt.Errorf("live and staged recovery transcripts both exist")
382 case !live && !staged:
383 return fmt.Errorf("recovery trash stage is missing transcript")
384 }
385
386 guard, err := TryAcquireSessionRemovalGuard(livePath)
387 if err != nil {
388 return err
389 }
390 defer guard.Release()
391 return finishRecoveryTrashStage(dir, livePath, key, itemDir, guard)
392 }
393
394 func regularRecoveryTrashPath(path string) (bool, error) {
395 info, err := os.Lstat(path)
396 if os.IsNotExist(err) {
397 return false, nil
398 }
399 if err != nil {
400 return false, err
401 }
402 if !info.Mode().IsRegular() {
403 return false, fmt.Errorf("recovery trash path is not a regular file: %s", path)
404 }
405 return true, nil
406 }
407
408 func removeEmptyRecoveryTrashStage(itemDir string) error {
409 entries, err := os.ReadDir(itemDir)
410 if err != nil {
411 return err
412 }
413 if len(entries) != 1 || entries[0].Name() != recoveryTrashPendingFile || entries[0].IsDir() {
414 return fmt.Errorf("recovery trash stage contains artifacts without a transcript")
415 }
416 return os.RemoveAll(itemDir)
417 }
418
419 func reserveRecoveryTrashStage(dir string) (string, error) {
420 root := filepath.Join(dir, recoveryTrashDir)
421 if err := os.MkdirAll(root, 0o755); err != nil {
422 return "", err
423 }
424 return os.MkdirTemp(root, recoveryTrashStagingPrefix)
425 }
426
427 func prepareRecoveryTrashStage(path, key, stageDir string) error {
428 if err := writeRecoveryTrashPending(stageDir, key); err != nil {
429 return err
430 }
431 return moveRecoveryTrashPath(path, filepath.Join(stageDir, key))
432 }
433
434 func finishRecoveryTrashStage(dir, path, key, stageDir string, guard *SessionRemovalGuard) error {
435 if err := moveRecoveryTrashArtifacts(dir, path, stageDir); err != nil {
436 return err
437 }
438 itemDir, err := publishRecoveryTrashStage(dir, key, stageDir)
439 if err != nil {
440 return err
441 }
442 if err := writeRecoveryTrashMetaExisting(itemDir, key); err != nil {
443 if !os.IsNotExist(err) {
444 return err
445 }
446 // A complete entry may be restored or purged as soon as it is published.
447 // In that case there is nothing left for this producer to finalize.
448 return guard.RemoveSidecarsAndRelease()
449 }
450 if err := clearRecoveryTrashPending(itemDir); err != nil {
451 return err
452 }
453 return guard.RemoveSidecarsAndRelease()
454 }
455
456 func publishRecoveryTrashStage(dir, key, stageDir string) (string, error) {
457 root := filepath.Join(dir, recoveryTrashDir)
458 stem := strings.TrimSuffix(key, filepath.Ext(key))
459 stamp := time.Now().UTC().UnixMilli()
460 for i := 0; i < 1000; i++ {
461 name := key
462 if i > 0 {
463 name = fmt.Sprintf("%s-recovery-%d-%d", stem, stamp, i)
464 }
465 itemDir := filepath.Join(root, name)
466 if _, err := os.Lstat(itemDir); err == nil {
467 continue
468 } else if !os.IsNotExist(err) {
469 return "", err
470 }
471 if err := os.Rename(stageDir, itemDir); err == nil {
472 return itemDir, nil
473 } else if _, statErr := os.Lstat(itemDir); statErr == nil {
474 continue // another producer won this candidate
475 } else if !os.IsNotExist(statErr) {
476 return "", statErr
477 } else {
478 return "", err
479 }
480 }
481 return "", fmt.Errorf("could not publish recovery trash target")
482 }
483
484 func writeRecoveryTrashPending(itemDir, key string) error {
485 if !validRecoveryTrashKey(key) {
486 return fmt.Errorf("invalid recovery trash key")
487 }
488 b, err := json.MarshalIndent(recoveryTrashPendingMeta{Key: key}, "", " ")
489 if err != nil {
490 return err
491 }
492 return fileutil.AtomicWriteFileStrict(filepath.Join(itemDir, recoveryTrashPendingFile), b, 0o644)
493 }
494
495 func readRecoveryTrashPending(itemDir string) (recoveryTrashPendingMeta, error) {
496 b, err := os.ReadFile(filepath.Join(itemDir, recoveryTrashPendingFile))
497 if err != nil {
498 return recoveryTrashPendingMeta{}, err
499 }
500 var meta recoveryTrashPendingMeta
501 if err := json.Unmarshal(b, &meta); err != nil {
502 return recoveryTrashPendingMeta{}, err
503 }
504 return meta, nil
505 }
506
507 func clearRecoveryTrashPending(itemDir string) error {
508 err := os.Remove(filepath.Join(itemDir, recoveryTrashPendingFile))
509 if os.IsNotExist(err) {
510 return nil
511 }
512 return err
513 }
514
515 func validRecoveryTrashKey(key string) bool {
516 return key != "" && filepath.Base(key) == key && key != "." && key != ".." &&
517 strings.HasSuffix(key, ".jsonl") && !strings.HasSuffix(key, ".events.jsonl")
518 }
519
520 func reserveRecoveryTrashItemDir(dir, key string) (string, string, error) {
521 root := filepath.Join(dir, recoveryTrashDir)
522 if err := os.MkdirAll(root, 0o755); err != nil {
523 return "", "", err
524 }
525 stem := strings.TrimSuffix(key, filepath.Ext(key))
526 for i := 0; i < 1000; i++ {
527 name := key
528 if i > 0 {
529 name = fmt.Sprintf("%s-recovery-%d-%d", stem, time.Now().UTC().UnixMilli(), i)
530 }
531 itemDir := filepath.Join(root, name)
532 if err := os.Mkdir(itemDir, 0o755); err == nil {
533 return name, itemDir, nil
534 } else if !os.IsExist(err) {
535 return "", "", err
536 }
537 }
538 return "", "", fmt.Errorf("could not reserve recovery trash target")
539 }
540
541 func finishRecoveryTrashMove(dir, path, key, itemDir string, guard *SessionRemovalGuard) error {
542 if err := os.MkdirAll(itemDir, 0o755); err != nil {
543 return err
544 }
545 if err := moveRecoveryTrashArtifacts(dir, path, itemDir); err != nil {
546 return err
547 }
548 if err := writeRecoveryTrashMeta(itemDir, key); err != nil {
549 return err
550 }
551 // Keep the branch guard until the trash entry is complete and the hidden
552 // marker is cleared. No runtime can bind the now-vacant live path in the
553 // middle and inherit an incomplete cleanup state.
554 if err := ClearCleanupPending(path); err != nil {
555 return err
556 }
557 return guard.RemoveSidecarsAndRelease()
558 }
559
560 func moveRecoveryTrashArtifacts(dir, path, itemDir string) error {
561 for _, src := range recoveryTrashSidecars(path) {
562 if err := moveRecoveryTrashPath(src, filepath.Join(itemDir, filepath.Base(src))); err != nil {
563 return err
564 }
565 }
566 return moveRecoverySubagentArtifacts(dir, path, itemDir)
567 }
568
569 func prepareRecoveryTrashEntry(path, key, itemDir string) error {
570 if err := writeRecoveryTrashMeta(itemDir, key); err != nil {
571 return err
572 }
573 return moveRecoveryTrashPath(path, filepath.Join(itemDir, key))
574 }
575
576 func writeRecoveryTrashMeta(itemDir, key string) error {
577 meta := recoveryTrashMeta{Key: key, DeletedAt: time.Now().UnixMilli()}
578 b, err := json.MarshalIndent(meta, "", " ")
579 if err != nil {
580 return err
581 }
582 if err := os.MkdirAll(itemDir, 0o755); err != nil {
583 return err
584 }
585 return os.WriteFile(filepath.Join(itemDir, recoveryTrashMetaFile), b, 0o644)
586 }
587
588 func writeRecoveryTrashMetaExisting(itemDir, key string) error {
589 meta := recoveryTrashMeta{Key: key, DeletedAt: time.Now().UnixMilli()}
590 b, err := json.MarshalIndent(meta, "", " ")
591 if err != nil {
592 return err
593 }
594 return os.WriteFile(filepath.Join(itemDir, recoveryTrashMetaFile), b, 0o644)
595 }
596
597 func recoveryTrashSidecars(path string) []string {
598 artifacts := append([]string(nil), store.SessionSidecarFiles(path)...)
599 artifacts = append(artifacts,
600 path+".telemetry.json",
601 store.SessionCheckpointDir(path),
602 store.SessionJobsDir(path),
603 )
604 return artifacts
605 }
606
607 func moveRecoverySubagentArtifacts(dir, path, itemDir string) error {
608 artifacts, err := ListSubagentsByParent(dir, BranchID(path))
609 if err != nil {
610 return err
611 }
612 targetDir := filepath.Join(itemDir, "subagents")
613 for _, artifact := range artifacts {
614 paths := []string{artifact.SessionPath, artifact.MetaPath}
615 paths = append(paths, store.SessionSidecarFiles(artifact.SessionPath)...)
616 for _, src := range paths {
617 if err := moveRecoveryTrashPath(src, filepath.Join(targetDir, filepath.Base(src))); err != nil {
618 return err
619 }
620 }
621 }
622 return nil
623 }
624
625 func moveRecoveryTrashPath(src, dst string) error {
626 if strings.TrimSpace(src) == "" {
627 return nil
628 }
629 if _, err := os.Lstat(src); os.IsNotExist(err) {
630 return nil
631 } else if err != nil {
632 return err
633 }
634 if err := os.MkdirAll(filepath.Dir(dst), 0o755); err != nil {
635 return err
636 }
637 return os.Rename(src, dst)
638 }
639
639 lines GO