返回 DeepSeek-Reasonix
delivery_worktree.go
根目录 / desktop / delivery_worktree.go
1 package main
2
3 import (
4 "context"
5 "errors"
6 "fmt"
7 "os"
8 "path/filepath"
9 "slices"
10 "sort"
11 "strings"
12 "sync"
13 "sync/atomic"
14 "time"
15
16 "reasonix/internal/config"
17 "reasonix/internal/workspacelease"
18 "reasonix/internal/worktree"
19 )
20
21 var (
22 inspectDeliveryWorktree = worktree.Inspect
23 createDeliveryWorktree = worktree.Create
24 rollbackDeliveryWorktree = worktree.RollbackCreate
25 )
26
27 // IsolatedWorktreeOpenResult is returned after an isolated Git workspace has
28 // been created and opened as a normal Reasonix project.
29 type IsolatedWorktreeOpenResult struct {
30 WorkspaceRoot string `json:"workspaceRoot"`
31 WorktreeRoot string `json:"worktreeRoot"`
32 SourceRoot string `json:"sourceRoot"`
33 Branch string `json:"branch"`
34 SourceDirty bool `json:"sourceDirty"`
35 SourceRevision string `json:"sourceRevision,omitempty"`
36 TaskID string `json:"taskId,omitempty"`
37 ConversationID string `json:"conversationId,omitempty"`
38 Tab TabMeta `json:"tab"`
39 }
40
41 // DeliveryWorktreeOpenResult is the deprecated alias of
42 // IsolatedWorktreeOpenResult kept bound for one compatibility version.
43 type DeliveryWorktreeOpenResult = IsolatedWorktreeOpenResult
44
45 // IsolatedWorktreeAvailability reports whether workspaceRoot can use the
46 // optional Git isolation path. A false result never disables writing itself;
47 // the cross-platform workspace writer lease remains the no-Git fallback.
48 func (a *App) IsolatedWorktreeAvailability(workspaceRoot string) worktree.Availability {
49 return inspectDeliveryWorktree(a.bootContext(), workspaceRoot)
50 }
51
52 // CreateIsolatedWorktree creates a durable branch-backed worktree and opens it
53 // as a project. It never switches or modifies the source checkout, and it does
54 // not delete the new worktree if later UI registration fails. The opened tab
55 // infers the delivery quality floor (switchable to standard at any time).
56 func (a *App) CreateIsolatedWorktree(workspaceRoot string) (IsolatedWorktreeOpenResult, error) {
57 workspaceRoot = strings.TrimSpace(workspaceRoot)
58 created, err := func() (worktree.Result, error) {
59 releaseAdmission, err := a.beginWorkspaceRuntimeAdmission(workspaceRoot)
60 if err != nil {
61 return worktree.Result{}, err
62 }
63 defer releaseAdmission()
64 return createDeliveryWorktree(a.bootContext(), workspaceRoot, config.DeliveryWorktreeDir())
65 }()
66 if err != nil {
67 return IsolatedWorktreeOpenResult{}, err
68 }
69
70 tab, err := a.ensureBlankSurface("project", created.WorkspaceRoot)
71 if err != nil {
72 return IsolatedWorktreeOpenResult{}, fmt.Errorf("isolated worktree was created at %s but Reasonix could not open it: %w", created.WorktreeRoot, err)
73 }
74 return IsolatedWorktreeOpenResult{
75 WorkspaceRoot: created.WorkspaceRoot,
76 WorktreeRoot: created.WorktreeRoot,
77 SourceRoot: created.SourceRoot,
78 Branch: created.Branch,
79 SourceDirty: created.SourceDirty,
80 SourceRevision: created.Head,
81 TaskID: tab.ID,
82 ConversationID: tab.TopicID,
83 Tab: tab,
84 }, nil
85 }
86
87 // DeliveryWorktreeAvailability is the deprecated alias of
88 // IsolatedWorktreeAvailability, kept bound for one compatibility version.
89 func (a *App) DeliveryWorktreeAvailability(workspaceRoot string) worktree.Availability {
90 return a.IsolatedWorktreeAvailability(workspaceRoot)
91 }
92
93 // CreateDeliveryWorktree is the deprecated alias of CreateIsolatedWorktree,
94 // kept bound for one compatibility version.
95 func (a *App) CreateDeliveryWorktree(workspaceRoot string) (DeliveryWorktreeOpenResult, error) {
96 return a.CreateIsolatedWorktree(workspaceRoot)
97 }
98
99 var (
100 inspectWorktreeMerge = worktree.InspectMerge
101 mergeWorktreeBack = worktree.MergeBack
102 finalizeWorktreeMerge = worktree.FinalizeMerge
103 removeWorktreeProject = removeProject
104 )
105
106 // MergeWorktreeBackRequest binds a merge to the exact inspection the user
107 // confirmed. WorkspaceRoot is always resolved from TabID by the backend.
108 type MergeWorktreeBackRequest struct {
109 TabID string `json:"tabId"`
110 ExpectedTargetBranch string `json:"expectedTargetBranch"`
111 ExpectedTargetHead string `json:"expectedTargetHead"`
112 ExpectedWorktreeHead string `json:"expectedWorktreeHead"`
113 ExpectedWorktreeStateToken string `json:"expectedWorktreeStateToken"`
114 AutoCommitDirty bool `json:"autoCommitDirty"`
115 }
116
117 // CloseMergedWorktreeTabRequest binds the lifecycle handoff to both the source
118 // and worktree identities observed by the frontend after navigation.
119 type CloseMergedWorktreeTabRequest struct {
120 TabID string `json:"tabId"`
121 WorktreeRoot string `json:"worktreeRoot"`
122 SourceTabID string `json:"sourceTabId"`
123 SourceRoot string `json:"sourceRoot"`
124 NavigationIntentToken string `json:"navigationIntentToken"`
125 }
126
127 type CloseMergedWorktreeTabResult struct {
128 Closed bool `json:"closed"`
129 Idempotent bool `json:"idempotent"`
130 }
131
132 // InspectWorktreeMerge inspects the diff and merge status for the given tab's
133 // isolated worktree against its base repository branch.
134 func (a *App) InspectWorktreeMerge(tabID string) (worktree.MergeInspection, error) {
135 a.mu.RLock()
136 tab := a.tabByIDLocked(tabID)
137 if tab == nil {
138 a.mu.RUnlock()
139 return worktree.MergeInspection{Available: false, Reason: "tab not found", ChangedFiles: []string{}, ConflictFiles: []string{}, Blockers: []worktree.MergeBlocker{}, CleanupBlockers: []worktree.MergeBlocker{}}, a.workspaceNotReadyErr(nil)
140 }
141 wsRoot := tab.WorkspaceRoot
142 a.mu.RUnlock()
143 inspection, err := inspectWorktreeMerge(a.bootContext(), wsRoot, config.DeliveryWorktreeDir())
144 if err != nil {
145 return inspection, err
146 }
147 if blockers := a.inspectWorktreeMergeRuntimeBlockers(inspection.SourceRoot, inspection.WorktreeRoot); len(blockers) > 0 {
148 inspection.CanMerge = false
149 inspection.Blockers = append(inspection.Blockers, blockers...)
150 }
151 return inspection, nil
152 }
153
154 // GetWorktreeStatus is the stable status-query name for new clients. It keeps
155 // the existing inspection implementation as the single owner of merge guards.
156 func (a *App) GetWorktreeStatus(tabID string) (worktree.MergeInspection, error) {
157 return a.InspectWorktreeMerge(tabID)
158 }
159
160 // PrepareWorktreeMerge performs the same fresh inspection used immediately
161 // before a merge request. The request is intentionally small so clients cannot
162 // smuggle stale paths or identities across the Wails boundary.
163 func (a *App) PrepareWorktreeMerge(tabID string) (worktree.MergeInspection, error) {
164 return a.InspectWorktreeMerge(tabID)
165 }
166
167 // MergeWorktreeBack merges only after active-work and dual-workspace lease
168 // gates. It intentionally leaves navigation, tab closure, and cleanup to the
169 // second phase.
170 func (a *App) MergeWorktreeBack(request MergeWorktreeBackRequest) (worktree.MergeResult, error) {
171 a.worktreeMergeMu.Lock()
172 defer a.worktreeMergeMu.Unlock()
173
174 tab, wsRoot, err := a.mergeableWorktreeTab(request.TabID)
175 if err != nil {
176 return worktree.MergeResult{Error: err.Error()}, err
177 }
178 inspection, err := inspectWorktreeMerge(a.bootContext(), wsRoot, config.DeliveryWorktreeDir())
179 if err != nil {
180 return worktree.MergeResult{Error: err.Error()}, err
181 }
182 if blockers := a.inspectWorktreeMergeRuntimeBlockers(inspection.SourceRoot, inspection.WorktreeRoot); len(blockers) > 0 {
183 err := mergeRuntimeBlockersError(blockers)
184 return worktree.MergeResult{Error: err.Error()}, err
185 }
186 release, err := holdWorktreeMergeLeases(a.bootContext(), inspection.SourceRoot, inspection.WorktreeRoot)
187 if err != nil {
188 return worktree.MergeResult{Error: err.Error()}, err
189 }
190 defer release()
191 releaseReservation, err := a.reserveWorktreeMergeRuntime(inspection.SourceRoot, inspection.WorktreeRoot)
192 if err != nil {
193 return worktree.MergeResult{Error: err.Error()}, err
194 }
195 defer releaseReservation()
196 if _, currentRoot, err := a.mergeableWorktreeTabIdentity(request.TabID, tab); err != nil || !sameProjectRoot(currentRoot, wsRoot) {
197 if err == nil {
198 err = fmt.Errorf("worktree tab identity changed while waiting for merge access")
199 }
200 return worktree.MergeResult{Error: err.Error()}, err
201 }
202 return mergeWorktreeBack(a.bootContext(), config.DeliveryWorktreeDir(), worktree.MergeRequest{
203 WorkspaceRoot: wsRoot, ExpectedTargetBranch: request.ExpectedTargetBranch,
204 ExpectedTargetHead: request.ExpectedTargetHead, ExpectedWorktreeHead: request.ExpectedWorktreeHead,
205 ExpectedWorktreeStateToken: request.ExpectedWorktreeStateToken,
206 AutoCommitDirty: request.AutoCommitDirty,
207 })
208 }
209
210 // FinalizeWorktreeMerge is the cleanup phase. The frontend calls it only after
211 // navigating to source and closing the worktree view; the backend proves no
212 // visible or detached runtime still references the allocation.
213 func (a *App) FinalizeWorktreeMerge(request worktree.CleanupRequest) (worktree.CleanupResult, error) {
214 a.worktreeMergeMu.Lock()
215 defer a.worktreeMergeMu.Unlock()
216 releaseReservation, err := a.reserveWorktreeCleanup(request.WorktreeRoot)
217 if err != nil {
218 return worktree.CleanupResult{Blockers: []worktree.MergeBlocker{{Code: "runtime_reference", Message: err.Error(), Paths: []string{}}}, Error: err.Error()}, err
219 }
220 defer releaseReservation()
221 release, err := holdWorktreeMergeLeases(a.bootContext(), request.SourceRoot, request.WorktreeRoot)
222 if err != nil {
223 return worktree.CleanupResult{Blockers: []worktree.MergeBlocker{}, Error: err.Error()}, err
224 }
225 defer release()
226 if a.worktreeRuntimeReferenced(request.WorktreeRoot) {
227 err := fmt.Errorf("a runtime still references the reserved worktree; it was preserved")
228 return worktree.CleanupResult{Blockers: []worktree.MergeBlocker{{Code: "runtime_reference", Message: err.Error(), Paths: []string{}}}, Error: err.Error()}, err
229 }
230 result, err := finalizeWorktreeMerge(a.bootContext(), config.DeliveryWorktreeDir(), request)
231 if err != nil && !result.RecoveryRetained {
232 return result, err
233 }
234 if result.Completed || result.RecoveryRetained {
235 if err := a.forgetFinalizedWorktreeProject(request); err != nil {
236 result.Error = err.Error()
237 return result, nil
238 }
239 }
240 return result, nil
241 }
242
243 func (a *App) forgetFinalizedWorktreeProject(request worktree.CleanupRequest) error {
244 if err := removeWorktreeProject(request.WorktreeRoot); err != nil {
245 return fmt.Errorf("recovery worktree was retained, but the former project registration could not be removed: %w", err)
246 }
247 forgetWorkspace(request.WorktreeRoot)
248 a.catalogRegisteredProjectRoots.Delete(projectRootKey(normalizeProjectRoot(request.WorktreeRoot)))
249 if sameProjectRoot(loadWorkspace(), request.WorktreeRoot) {
250 saveWorkspace(request.SourceRoot)
251 }
252 if a.workspaceHub != nil {
253 a.workspaceHub.reconcileRoots()
254 }
255 a.emitProjectTreeChanged()
256 return nil
257 }
258
259 // CloseMergedWorktreeTab closes only the exact idle worktree view after the
260 // exact source tab is active. It rechecks the predicate under App.mu at the
261 // removal point; an already-pruned single-surface worktree is idempotent only
262 // when no detached runtime references it.
263 func (a *App) CloseMergedWorktreeTab(request CloseMergedWorktreeTabRequest) (CloseMergedWorktreeTabResult, error) {
264 worktreeKey, err := workspacelease.CanonicalWorkspace(request.WorktreeRoot)
265 if err != nil {
266 return CloseMergedWorktreeTabResult{}, fmt.Errorf("resolve worktree identity: %w", err)
267 }
268 sourceKey, err := workspacelease.CanonicalWorkspace(request.SourceRoot)
269 if err != nil {
270 return CloseMergedWorktreeTabResult{}, fmt.Errorf("resolve source identity: %w", err)
271 }
272 if err := a.requireNavigationIntent(request.NavigationIntentToken); err != nil {
273 return CloseMergedWorktreeTabResult{}, err
274 }
275 releaseRuntime := a.lockRuntimeMutation("close-merged-worktree-tab-snapshot")
276 a.sessionRemovalMu.Lock()
277 a.mu.Lock()
278 tab, err := a.validateMergedWorktreeCloseLocked(request, worktreeKey, sourceKey)
279 if err != nil {
280 a.mu.Unlock()
281 a.sessionRemovalMu.Unlock()
282 releaseRuntime()
283 return CloseMergedWorktreeTabResult{}, err
284 }
285 a.mu.Unlock()
286 if tab != nil {
287 if err := a.snapshotMergedWorktreeCloseTab(tab); err != nil {
288 a.sessionRemovalMu.Unlock()
289 releaseRuntime()
290 return CloseMergedWorktreeTabResult{}, err
291 }
292 }
293 a.sessionRemovalMu.Unlock()
294 releaseRuntime()
295 if hook := a.navigationIntent.beforeCloseFinalHook; hook != nil {
296 hook()
297 }
298
299 // Linearization order: navigation fence -> runtime barrier -> removal gate
300 // -> App.mu. A newer intent published during the first snapshot wins here.
301 a.navigationIntent.mu.Lock()
302 defer a.navigationIntent.mu.Unlock()
303 if a.navigationIntent.token != strings.TrimSpace(request.NavigationIntentToken) {
304 return CloseMergedWorktreeTabResult{}, fmt.Errorf("navigation changed before worktree close; resources were preserved")
305 }
306 releaseRuntime = a.lockRuntimeMutation("close-merged-worktree-tab-final")
307 defer releaseRuntime()
308 a.sessionRemovalMu.Lock()
309 defer a.sessionRemovalMu.Unlock()
310 a.mu.Lock()
311 current, err := a.validateMergedWorktreeCloseLocked(request, worktreeKey, sourceKey)
312 if err != nil {
313 a.mu.Unlock()
314 return CloseMergedWorktreeTabResult{}, err
315 }
316 if current != tab {
317 a.mu.Unlock()
318 return CloseMergedWorktreeTabResult{}, fmt.Errorf("worktree tab changed before close; resources were preserved")
319 }
320 if current == nil {
321 a.mu.Unlock()
322 return CloseMergedWorktreeTabResult{Closed: true, Idempotent: true}, nil
323 }
324 a.mu.Unlock()
325 if err := a.snapshotMergedWorktreeCloseTab(current); err != nil {
326 return CloseMergedWorktreeTabResult{}, err
327 }
328 a.mu.Lock()
329 final, err := a.validateMergedWorktreeCloseLocked(request, worktreeKey, sourceKey)
330 if err != nil {
331 a.mu.Unlock()
332 return CloseMergedWorktreeTabResult{}, err
333 }
334 if final != current {
335 a.mu.Unlock()
336 return CloseMergedWorktreeTabResult{}, fmt.Errorf("worktree tab changed at close linearization; resources were preserved")
337 }
338 a.markTabRemovedLocked(current)
339 delete(a.tabs, current.ID)
340 a.removeTabOrderLocked(current.ID)
341 a.saveTabsLocked()
342 a.mu.Unlock()
343
344 if a.terminals != nil {
345 a.terminals.closeForTab(current.ID)
346 }
347 a.closeTabRuntimeAdmissionHeld(current)
348 if a.workspaceHub != nil {
349 a.workspaceHub.reconcileRoots()
350 }
351 a.emitProjectTreeRuntimeChangedWithLegacy()
352 return CloseMergedWorktreeTabResult{Closed: true}, nil
353 }
354
355 func (a *App) snapshotMergedWorktreeCloseTab(tab *WorkspaceTab) error {
356 if err := a.snapshotTab(tab); err != nil {
357 return fmt.Errorf("save worktree session before closing: %w", err)
358 }
359 if err := a.saveTabSessionMetaForCurrentSession(tab); err != nil {
360 return fmt.Errorf("save worktree session metadata before closing: %w", err)
361 }
362 return nil
363 }
364
365 func (a *App) validateMergedWorktreeCloseLocked(request CloseMergedWorktreeTabRequest, worktreeKey, sourceKey string) (*WorkspaceTab, error) {
366 if request.TabID == "" || request.SourceTabID == "" || request.TabID == request.SourceTabID {
367 return nil, fmt.Errorf("merged worktree close identity is incomplete")
368 }
369 source := a.tabs[request.SourceTabID]
370 if source == nil || a.activeTabID != source.ID || canonicalRuntimeRoot(source.WorkspaceRoot) != sourceKey {
371 return nil, fmt.Errorf("source tab is no longer the active recorded workspace; resources were preserved")
372 }
373 tab := a.tabs[request.TabID]
374 if tab == nil {
375 if a.runtimeReferencesCanonicalLocked(worktreeKey) {
376 return nil, fmt.Errorf("a detached runtime still references the worktree; resources were preserved")
377 }
378 return nil, nil
379 }
380 if canonicalRuntimeRoot(tab.WorkspaceRoot) != worktreeKey {
381 return nil, fmt.Errorf("worktree tab identity changed; resources were preserved")
382 }
383 if tab.hasActiveRuntimeWork() || mergeActivityActive(tab.ActivityStatus) {
384 return nil, fmt.Errorf("worktree tab is no longer idle; resources were preserved")
385 }
386 return tab, nil
387 }
388
389 func (a *App) mergeableWorktreeTab(tabID string) (*WorkspaceTab, string, error) {
390 return a.mergeableWorktreeTabIdentity(tabID, nil)
391 }
392
393 func (a *App) mergeableWorktreeTabIdentity(tabID string, expected *WorkspaceTab) (*WorkspaceTab, string, error) {
394 a.mu.RLock()
395 tab := a.tabByIDLocked(tabID)
396 if tab == nil || (expected != nil && tab != expected) {
397 a.mu.RUnlock()
398 return nil, "", fmt.Errorf("worktree tab was closed or replaced")
399 }
400 root, ready, startupErr, ctrl, activity := tab.WorkspaceRoot, tab.Ready, tab.StartupErr, tab.Ctrl, tab.ActivityStatus
401 a.mu.RUnlock()
402 if !ready || ctrl == nil || strings.TrimSpace(startupErr) != "" {
403 return nil, "", fmt.Errorf("worktree tab is still building or unavailable")
404 }
405 if activeWorkForController(ctrl).active() || mergeActivityActive(activity) {
406 return nil, "", fmt.Errorf("worktree tab has active, waiting, or background work")
407 }
408 return tab, root, nil
409 }
410
411 func mergeActivityActive(status string) bool {
412 switch strings.TrimSpace(status) {
413 case topicStatusThinking, topicStatusStreaming, topicStatusWaitingConfirmation, topicStatusBackgroundJob:
414 return true
415 default:
416 return false
417 }
418 }
419
420 func (a *App) inspectWorktreeMergeRuntimeBlockers(sourceRoot, worktreeRoot string) []worktree.MergeBlocker {
421 rootKeys, err := canonicalMergeRuntimeRoots(sourceRoot, worktreeRoot)
422 if err != nil {
423 return []worktree.MergeBlocker{{Code: "identity", Message: err.Error(), Paths: []string{}}}
424 }
425 return a.worktreeMergeRuntimeBlockers(rootKeys)
426 }
427
428 func canonicalMergeRuntimeRoots(roots ...string) ([]string, error) {
429 seen := map[string]struct{}{}
430 out := make([]string, 0, len(roots))
431 for _, root := range roots {
432 key, err := canonicalRuntimeRootErr(root)
433 if err != nil {
434 return nil, fmt.Errorf("resolve merge runtime identity: %w", err)
435 }
436 if _, ok := seen[key]; ok {
437 continue
438 }
439 seen[key] = struct{}{}
440 out = append(out, key)
441 }
442 if len(out) == 0 {
443 return nil, fmt.Errorf("merge runtime identity is empty")
444 }
445 sort.Strings(out)
446 return out, nil
447 }
448
449 func (a *App) worktreeMergeRuntimeBlockers(rootKeys []string) []worktree.MergeBlocker {
450 building, active := false, false
451 tabIDs := map[string]struct{}{}
452 a.mu.RLock()
453 for _, tab := range a.runtimeTabsLocked() {
454 if tab == nil || !canonicalRootOverlapsAny(canonicalRuntimeRoot(tab.WorkspaceRoot), rootKeys) {
455 continue
456 }
457 tabIDs[tab.ID] = struct{}{}
458 if !tab.Ready || tab.Ctrl == nil || strings.TrimSpace(tab.StartupErr) != "" {
459 building = true
460 }
461 if tab.hasActiveRuntimeWork() || mergeActivityActive(tab.ActivityStatus) {
462 active = true
463 }
464 }
465 a.mu.RUnlock()
466
467 blockers := []worktree.MergeBlocker{}
468 if building {
469 blockers = append(blockers, worktree.MergeBlocker{Code: "tab_building", Message: "a source or worktree runtime is still building or unavailable", Paths: []string{}})
470 }
471 if active {
472 blockers = append(blockers, worktree.MergeBlocker{Code: "active_work", Message: "a source or worktree runtime still has active or waiting work", Paths: []string{}})
473 }
474 if a.terminals != nil && a.terminals.hasRunningForTabs(tabIDs) {
475 blockers = append(blockers, worktree.MergeBlocker{Code: "active_terminal", Message: "close source and worktree terminals before merging", Paths: []string{}})
476 }
477 return blockers
478 }
479
480 func canonicalRootOverlapsAny(candidate string, roots []string) bool {
481 for _, root := range roots {
482 if pathWithinCanonicalWorktree(candidate, root) || pathWithinCanonicalWorktree(root, candidate) {
483 return true
484 }
485 }
486 return false
487 }
488
489 // worktreeMergeReservationSnapshot is immutable after publication. Controller
490 // publication reads it while holding runtimeAdmissionMu's read side, which is
491 // ordered after the write-side reservation publication without reacquiring the
492 // runtime-owner mutex.
493 type worktreeMergeReservationSnapshot struct {
494 roots []string
495 }
496
497 type worktreeRuntimeReservations struct {
498 mu sync.Mutex
499 cleanup map[string]struct{}
500 merge map[string]struct{}
501 mergeSnapshot atomic.Pointer[worktreeMergeReservationSnapshot]
502 }
503
504 func (a *App) publishWorktreeMergeReservationSnapshotLocked() {
505 roots := make([]string, 0, len(a.worktreeReservations.merge))
506 for root := range a.worktreeReservations.merge {
507 roots = append(roots, root)
508 }
509 sort.Strings(roots)
510 a.worktreeReservations.mergeSnapshot.Store(&worktreeMergeReservationSnapshot{roots: roots})
511 }
512
513 func (a *App) workspaceMergeReservedSnapshot(workspaceKey string) bool {
514 snapshot := a.worktreeReservations.mergeSnapshot.Load()
515 return snapshot != nil && canonicalRootOverlapsAny(workspaceKey, snapshot.roots)
516 }
517
518 func mergeRuntimeBlockersError(blockers []worktree.MergeBlocker) error {
519 messages := make([]string, 0, len(blockers))
520 for _, blocker := range blockers {
521 messages = append(messages, blocker.Message)
522 }
523 return fmt.Errorf("merge runtime admission blocked: %s", strings.Join(messages, "; "))
524 }
525
526 // reserveWorktreeMergeRuntime briefly quiesces turn starts and controller
527 // publication, proves both workspaces are idle, then publishes canonical
528 // per-root reservations. The global admission barrier is released before Git
529 // work begins so unrelated workspaces are not frozen for the merge duration.
530 func (a *App) reserveWorktreeMergeRuntime(sourceRoot, worktreeRoot string) (func(), error) {
531 rootKeys, err := canonicalMergeRuntimeRoots(sourceRoot, worktreeRoot)
532 if err != nil {
533 return nil, err
534 }
535 a.runtimeAdmissionMu.Lock()
536 defer a.runtimeAdmissionMu.Unlock()
537 a.worktreeReservations.mu.Lock()
538 defer a.worktreeReservations.mu.Unlock()
539 if a.worktreeReservations.merge == nil {
540 a.worktreeReservations.merge = map[string]struct{}{}
541 }
542 for _, key := range rootKeys {
543 if a.cleanupReservationOverlapsLocked(key) || a.mergeReservationOverlapsLocked(key) {
544 return nil, fmt.Errorf("workspace maintenance is already in progress")
545 }
546 }
547 if blockers := a.worktreeMergeRuntimeBlockers(rootKeys); len(blockers) > 0 {
548 return nil, mergeRuntimeBlockersError(blockers)
549 }
550 for _, key := range rootKeys {
551 a.worktreeReservations.merge[key] = struct{}{}
552 }
553 a.publishWorktreeMergeReservationSnapshotLocked()
554 return func() {
555 a.worktreeReservations.mu.Lock()
556 for _, key := range rootKeys {
557 delete(a.worktreeReservations.merge, key)
558 }
559 a.publishWorktreeMergeReservationSnapshotLocked()
560 a.worktreeReservations.mu.Unlock()
561 }, nil
562 }
563
564 func holdWorktreeMergeLeases(parent context.Context, roots ...string) (func(), error) {
565 ctx, cancel := context.WithTimeout(parent, 30*time.Second)
566 release, err := workspacelease.HoldWriteRoots(ctx, config.WorkspaceLeaseDir(), roots...)
567 if err != nil {
568 cancel()
569 return nil, fmt.Errorf("wait for merge workspace lease: %w", err)
570 }
571 return func() { release(); cancel() }, nil
572 }
573
574 func (a *App) worktreeRuntimeReferenced(worktreeRoot string) bool {
575 key, err := workspacelease.CanonicalWorkspace(worktreeRoot)
576 if err != nil {
577 return true
578 }
579 a.mu.RLock()
580 defer a.mu.RUnlock()
581 return a.runtimeReferencesCanonicalLocked(key)
582 }
583
584 func canonicalRuntimeRoot(root string) string {
585 canonical, _ := canonicalRuntimeRootErr(root)
586 return canonical
587 }
588
589 func canonicalRuntimeRootErr(root string) (string, error) {
590 root = strings.TrimSpace(root)
591 if root == "" {
592 return "", fmt.Errorf("workspace root is empty")
593 }
594 abs, err := filepath.Abs(root)
595 if err != nil {
596 return "", err
597 }
598 probe := filepath.Clean(abs)
599 suffix := []string{}
600 var probeInfo os.FileInfo
601 for {
602 if info, statErr := os.Lstat(probe); statErr == nil {
603 probeInfo = info
604 break
605 } else if !os.IsNotExist(statErr) {
606 return "", statErr
607 }
608 parent := filepath.Dir(probe)
609 if parent == probe {
610 break
611 }
612 suffix = append(suffix, filepath.Base(probe))
613 probe = parent
614 }
615 if resolved, resolveErr := filepath.EvalSymlinks(probe); resolveErr == nil {
616 probe = resolved
617 } else if errors.Is(resolveErr, os.ErrNotExist) && probeInfo != nil && probeInfo.Mode()&os.ModeSymlink != 0 {
618 target, readErr := os.Readlink(probe)
619 if readErr != nil {
620 return "", readErr
621 }
622 if !filepath.IsAbs(target) {
623 target = filepath.Join(filepath.Dir(probe), target)
624 }
625 probe, readErr = canonicalRuntimeRootErr(filepath.Clean(target))
626 if readErr != nil {
627 return "", readErr
628 }
629 } else if !os.IsNotExist(resolveErr) {
630 return "", resolveErr
631 }
632 for _, component := range slices.Backward(suffix) {
633 probe = filepath.Join(probe, component)
634 }
635 return workspacelease.CanonicalWorkspace(probe)
636 }
637
638 func (a *App) runtimeReferencesCanonicalLocked(worktreeKey string) bool {
639 if worktreeKey == "" {
640 return true
641 }
642 for _, tab := range a.runtimeTabsLocked() {
643 if tab != nil && pathWithinCanonicalWorktree(canonicalRuntimeRoot(tab.WorkspaceRoot), worktreeKey) {
644 return true
645 }
646 }
647 return false
648 }
649
650 func pathWithinCanonicalWorktree(pathKey, worktreeKey string) bool {
651 if pathKey == "" || worktreeKey == "" {
652 return false
653 }
654 if pathKey == worktreeKey {
655 return true
656 }
657 rel, err := filepath.Rel(worktreeKey, pathKey)
658 return err == nil && rel != "." && rel != ".." && !strings.HasPrefix(rel, ".."+string(filepath.Separator))
659 }
660
661 func (a *App) workspaceCleanupReservedLocked(workspaceKey string) bool {
662 for reservedRoot := range a.worktreeReservations.cleanup {
663 if pathWithinCanonicalWorktree(workspaceKey, reservedRoot) {
664 return true
665 }
666 }
667 return false
668 }
669
670 func (a *App) workspaceMergeReservedLocked(workspaceKey string) bool {
671 for reservedRoot := range a.worktreeReservations.merge {
672 if pathWithinCanonicalWorktree(workspaceKey, reservedRoot) || pathWithinCanonicalWorktree(reservedRoot, workspaceKey) {
673 return true
674 }
675 }
676 return false
677 }
678
679 func (a *App) cleanupReservationOverlapsLocked(worktreeKey string) bool {
680 for reservedRoot := range a.worktreeReservations.cleanup {
681 if pathWithinCanonicalWorktree(worktreeKey, reservedRoot) || pathWithinCanonicalWorktree(reservedRoot, worktreeKey) {
682 return true
683 }
684 }
685 return false
686 }
687
688 func (a *App) mergeReservationOverlapsLocked(workspaceKey string) bool {
689 for reservedRoot := range a.worktreeReservations.merge {
690 if pathWithinCanonicalWorktree(workspaceKey, reservedRoot) || pathWithinCanonicalWorktree(reservedRoot, workspaceKey) {
691 return true
692 }
693 }
694 return false
695 }
696
697 func (a *App) reserveWorktreeCleanup(worktreeRoot string) (func(), error) {
698 key, err := canonicalRuntimeRootErr(worktreeRoot)
699 if err != nil {
700 return nil, fmt.Errorf("resolve cleanup worktree identity: %w", err)
701 }
702 // Reserve the complete allocation while the checkout moves to quarantine,
703 // so late runtimes cannot enter either path. Adjacent allocations remain
704 // independent reservation domains.
705 allocationKey, err := canonicalRuntimeRootErr(filepath.Dir(key))
706 if err != nil {
707 return nil, fmt.Errorf("resolve cleanup allocation identity: %w", err)
708 }
709 a.worktreeReservations.mu.Lock()
710 if a.worktreeReservations.cleanup == nil {
711 a.worktreeReservations.cleanup = map[string]struct{}{}
712 }
713 if a.cleanupReservationOverlapsLocked(allocationKey) || a.mergeReservationOverlapsLocked(allocationKey) {
714 a.worktreeReservations.mu.Unlock()
715 return nil, fmt.Errorf("worktree maintenance is already in progress")
716 }
717 a.mu.RLock()
718 referenced := a.runtimeReferencesCanonicalLocked(allocationKey)
719 if !referenced {
720 a.worktreeReservations.cleanup[allocationKey] = struct{}{}
721 }
722 a.mu.RUnlock()
723 a.worktreeReservations.mu.Unlock()
724 if referenced {
725 return nil, fmt.Errorf("a visible or background runtime still references the worktree; it was preserved")
726 }
727 return func() {
728 a.worktreeReservations.mu.Lock()
729 delete(a.worktreeReservations.cleanup, allocationKey)
730 a.worktreeReservations.mu.Unlock()
731 }, nil
732 }
733
734 // beginWorkspaceRuntimeAdmission holds every worktree maintenance-reservation
735 // gate through a runtime owner's final App.mu publication. Callers must invoke
736 // it before acquiring App.mu and defer the returned release.
737 func (a *App) beginWorkspaceRuntimeAdmission(workspaceRoot string) (func(), error) {
738 key, err := canonicalRuntimeRootErr(workspaceRoot)
739 if err != nil {
740 return nil, fmt.Errorf("resolve runtime workspace identity: %w", err)
741 }
742 a.worktreeReservations.mu.Lock()
743 if err := a.workspaceRuntimeReservationErrLocked(key); err != nil {
744 a.worktreeReservations.mu.Unlock()
745 return nil, fmt.Errorf("%w; retry after maintenance completes", err)
746 }
747 return a.worktreeReservations.mu.Unlock, nil
748 }
749
749 lines GO