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