@@ -101,6 +109,15 @@ export function WorkspaceCleanupFilterBar({
)}
+ {/* Why out here and not only in the popover: a filter narrowing the list must be
+ clearable without first discovering which panel it lives in. */}
+ {hasActiveFilters ? (
+
+ ) : null}
+
{gitEvidence.pendingCount > 0 ? (
@@ -111,6 +128,8 @@ export function WorkspaceCleanupFilterBar({
)}
) : null}
+
+
)
}
diff --git a/src/renderer/src/i18n/locales/en.json b/src/renderer/src/i18n/locales/en.json
index c117b08e172..3600bbeb99b 100644
--- a/src/renderer/src/i18n/locales/en.json
+++ b/src/renderer/src/i18n/locales/en.json
@@ -16234,7 +16234,53 @@
"selectionWithheldOne": "1 selected workspace is hidden by the current filters and was deselected.",
"selectionWithheld": "{{value0}} selected workspaces are hidden by the current filters and were deselected.",
"selectAllCountOne": "Select 1 safety-checked workspace",
- "selectAllCount": "Select all {{value0}} safety-checked workspaces"
+ "selectAllCount": "Select all {{value0}} safety-checked workspaces",
+ "appliedFilters": "Applied filters",
+ "removeFilter": "Remove filter {{value0}}",
+ "chip": {
+ "idleDays": "Idle {{value0}}d+",
+ "neverVisited": "Never visited",
+ "minSize": "At least {{value0}} MB",
+ "maxSize": "At most {{value0}} MB",
+ "excludesUnsized": "Measured only",
+ "excludesStatusless": "Has a status",
+ "list": "{{value0}}: {{value1}}",
+ "triState": "{{value0}}: {{value1}}",
+ "only": "Only",
+ "exclude": "Excluded",
+ "minAhead": "Ahead {{value0}}+",
+ "minBehind": "Behind {{value0}}+",
+ "branchQuery": "Branch: {{value0}}",
+ "pathPrefix": "Path: {{value0}}",
+ "presence": "{{value0}}: {{value1}}",
+ "has": "Has",
+ "none": "None",
+ "completelyEmpty": "Nothing to lose",
+ "selectableOnly": "Deletable only",
+ "kind": {
+ "status": "Status",
+ "agent": "Agent",
+ "git": "Git",
+ "review": "Review",
+ "reviewState": "Review state",
+ "reviewProvider": "Provider",
+ "ticket": "Ticket",
+ "ticketSource": "Ticket source",
+ "context": "Context",
+ "host": "Host",
+ "repo": "Repo",
+ "blocker": "Blocker",
+ "tier": "Safety",
+ "dismissed": "Ignored",
+ "archived": "Archived",
+ "pinned": "Pinned",
+ "unread": "Unread",
+ "comment": "Comment",
+ "prunable": "Prunable",
+ "locked": "Locked",
+ "retainedAgents": "Finished agents"
+ }
+ }
},
"scan": {
"progress": "Scanning workspaces ({{value0}}/{{value1}})",
diff --git a/src/renderer/src/i18n/locales/ko.json b/src/renderer/src/i18n/locales/ko.json
index 2384a7996fc..6164f1707bd 100644
--- a/src/renderer/src/i18n/locales/ko.json
+++ b/src/renderer/src/i18n/locales/ko.json
@@ -14566,6 +14566,54 @@
"presentation": {
"gitlabMergeRequestNumber": "MR #{{value0}}",
"githubPullRequestNumber": "PR #{{value0}}"
+ },
+ "browse": {
+ "appliedFilters": "적용된 필터",
+ "removeFilter": "{{value0}} 필터 제거",
+ "chip": {
+ "idleDays": "{{value0}}일 이상 유휴",
+ "neverVisited": "방문한 적 없음",
+ "minSize": "{{value0}} MB 이상",
+ "maxSize": "{{value0}} MB 이하",
+ "excludesUnsized": "측정된 항목만",
+ "excludesStatusless": "상태 있음",
+ "list": "{{value0}}: {{value1}}",
+ "triState": "{{value0}}: {{value1}}",
+ "only": "만",
+ "exclude": "제외",
+ "minAhead": "{{value0}}개 이상 앞섬",
+ "minBehind": "{{value0}}개 이상 뒤처짐",
+ "branchQuery": "브랜치: {{value0}}",
+ "pathPrefix": "경로: {{value0}}",
+ "presence": "{{value0}}: {{value1}}",
+ "has": "있음",
+ "none": "없음",
+ "completelyEmpty": "잃을 것 없음",
+ "selectableOnly": "삭제 가능한 항목만",
+ "kind": {
+ "status": "상태",
+ "agent": "에이전트",
+ "git": "Git",
+ "review": "리뷰",
+ "reviewState": "리뷰 상태",
+ "reviewProvider": "제공자",
+ "ticket": "티켓",
+ "ticketSource": "티켓 소스",
+ "context": "컨텍스트",
+ "host": "호스트",
+ "repo": "저장소",
+ "blocker": "차단 요인",
+ "tier": "안전",
+ "dismissed": "무시됨",
+ "archived": "보관됨",
+ "pinned": "고정됨",
+ "unread": "읽지 않음",
+ "comment": "댓글",
+ "prunable": "정리 가능",
+ "locked": "잠김",
+ "retainedAgents": "완료된 에이전트"
+ }
+ }
}
}
},
diff --git a/src/renderer/src/i18n/locales/zh.json b/src/renderer/src/i18n/locales/zh.json
index fdd53f16ed9..84c05074bb5 100644
--- a/src/renderer/src/i18n/locales/zh.json
+++ b/src/renderer/src/i18n/locales/zh.json
@@ -14557,6 +14557,54 @@
"presentation": {
"gitlabMergeRequestNumber": "MR #{{value0}}",
"githubPullRequestNumber": "PR #{{value0}}"
+ },
+ "browse": {
+ "appliedFilters": "已应用的筛选",
+ "removeFilter": "移除筛选 {{value0}}",
+ "chip": {
+ "idleDays": "闲置 {{value0}} 天以上",
+ "neverVisited": "从未访问",
+ "minSize": "至少 {{value0}} MB",
+ "maxSize": "最多 {{value0}} MB",
+ "excludesUnsized": "仅已测量",
+ "excludesStatusless": "有状态",
+ "list": "{{value0}}:{{value1}}",
+ "triState": "{{value0}}:{{value1}}",
+ "only": "仅",
+ "exclude": "排除",
+ "minAhead": "领先 {{value0}} 个以上",
+ "minBehind": "落后 {{value0}} 个以上",
+ "branchQuery": "分支:{{value0}}",
+ "pathPrefix": "路径:{{value0}}",
+ "presence": "{{value0}}:{{value1}}",
+ "has": "有",
+ "none": "无",
+ "completelyEmpty": "没有可丢失的内容",
+ "selectableOnly": "仅可删除",
+ "kind": {
+ "status": "状态",
+ "agent": "代理",
+ "git": "Git",
+ "review": "审查",
+ "reviewState": "审查状态",
+ "reviewProvider": "提供方",
+ "ticket": "工单",
+ "ticketSource": "工单来源",
+ "context": "上下文",
+ "host": "主机",
+ "repo": "仓库",
+ "blocker": "阻塞项",
+ "tier": "安全",
+ "dismissed": "已忽略",
+ "archived": "已归档",
+ "pinned": "已固定",
+ "unread": "未读",
+ "comment": "评论",
+ "prunable": "可修剪",
+ "locked": "已锁定",
+ "retainedAgents": "已完成的代理"
+ }
+ }
}
}
},
diff --git a/src/shared/workspace-cleanup-applied-filters.test.ts b/src/shared/workspace-cleanup-applied-filters.test.ts
new file mode 100644
index 00000000000..9cd9fd4afb8
--- /dev/null
+++ b/src/shared/workspace-cleanup-applied-filters.test.ts
@@ -0,0 +1,153 @@
+import { describe, expect, it } from 'vitest'
+import { createDefaultWorkspaceCleanupFilterState } from './workspace-cleanup-filter-model'
+import { listAppliedWorkspaceCleanupFilters } from './workspace-cleanup-applied-filters'
+
+const FORMAT = {
+ idleDays: (d: number) => `Idle >= ${d}d`,
+ neverVisited: () => 'Never visited',
+ minSize: (b: number) => `At least ${b}B`,
+ maxSize: (b: number) => `At most ${b}B`,
+ excludesUnsized: () => 'Excludes unmeasured',
+ excludesStatusless: () => 'Excludes statusless',
+ list: (kind: string, count: number) => `${kind}:${count}`,
+ triState: (kind: string, mode: string) => `${kind}:${mode}`,
+ minAhead: (n: number) => `Ahead >= ${n}`,
+ minBehind: (n: number) => `Behind >= ${n}`,
+ branchQuery: (v: string) => `Branch ~ ${v}`,
+ pathPrefix: (v: string) => `Path ~ ${v}`,
+ presence: (kind: string, mode: string) => `${kind}:${mode}`,
+ completelyEmpty: () => 'Nothing to lose',
+ selectableOnly: () => 'Deletable only'
+}
+
+const list = (f: ReturnType
) =>
+ listAppliedWorkspaceCleanupFilters(f, FORMAT)
+
+describe('listAppliedWorkspaceCleanupFilters', () => {
+ it('shows nothing for a default profile', () => {
+ expect(list(createDefaultWorkspaceCleanupFilterState())).toEqual([])
+ })
+
+ it('names the reported stuck filter rather than its group', () => {
+ // The bar always read "Showing 546 of 799"; what was missing was which filter.
+ const filters = createDefaultWorkspaceCleanupFilterState()
+ filters.activity.idleMinDays = 20
+
+ const applied = list(filters)
+
+ expect(applied).toHaveLength(1)
+ expect(applied[0].label).toBe('Idle >= 20d')
+ })
+
+ it('clears one constraint and leaves the rest of its own group alone', () => {
+ const filters = createDefaultWorkspaceCleanupFilterState()
+ filters.activity.idleMinDays = 20
+ filters.activity.neverVisited = true
+
+ const idle = list(filters).find((a) => a.id === 'activity.idleMinDays')
+ const next = idle!.clear(filters)
+
+ expect(next.activity.idleMinDays).toBeNull()
+ expect(next.activity.neverVisited).toBe(true)
+ })
+
+ it('never mutates the filters it is handed', () => {
+ const filters = createDefaultWorkspaceCleanupFilterState()
+ filters.location.pathPrefix = '/repos'
+
+ list(filters)[0].clear(filters)
+
+ expect(filters.location.pathPrefix).toBe('/repos')
+ })
+
+ describe('numeric zero', () => {
+ it('hides an inert zero minimum, which matches every row', () => {
+ const filters = createDefaultWorkspaceCleanupFilterState()
+ filters.activity.idleMinDays = 0
+ filters.size.minBytes = 0
+ filters.git.minAhead = 0
+ filters.git.minBehind = 0
+
+ expect(list(filters)).toEqual([])
+ })
+
+ it('shows a zero maximum, which hides every measured non-empty workspace', () => {
+ const filters = createDefaultWorkspaceCleanupFilterState()
+ filters.size.maxBytes = 0
+
+ expect(list(filters).map((a) => a.id)).toEqual(['size.maxBytes'])
+ })
+ })
+
+ it('ignores whitespace-only text', () => {
+ const filters = createDefaultWorkspaceCleanupFilterState()
+ filters.git.branchQuery = ' '
+ filters.location.pathPrefix = ' '
+
+ expect(list(filters)).toEqual([])
+ })
+
+ it('ignores parameter fields that constrain nothing on their own', () => {
+ const filters = createDefaultWorkspaceCleanupFilterState()
+ filters.activity.idleSignal = 'created'
+ filters.safety.blockerMode = 'any-of'
+
+ expect(list(filters)).toEqual([])
+ })
+
+ it('shows the permissive booleans only when switched off', () => {
+ const filters = createDefaultWorkspaceCleanupFilterState()
+ filters.size.includeUnsized = false
+ filters.status.matchStatusless = false
+
+ expect(list(filters).map((a) => a.id).sort()).toEqual([
+ 'size.includeUnsized',
+ 'status.matchStatusless'
+ ])
+ })
+
+ it('gives every constraint a unique, stable id', () => {
+ const filters = createDefaultWorkspaceCleanupFilterState()
+ filters.activity.idleMinDays = 20
+ filters.size.maxBytes = 500
+ filters.status.archived = 'only'
+ filters.git.states = ['dirty']
+ filters.git.locked = 'exclude'
+ filters.location.repoIds = ['repo-1']
+ filters.safety.dismissed = 'only'
+
+ const ids = list(filters).map((a) => a.id)
+
+ expect(new Set(ids).size).toBe(ids.length)
+ expect(ids).toContain('status.archived')
+ expect(ids).toContain('git.locked')
+ })
+
+ it('clearing every chip in turn returns the profile to no constraints', () => {
+ const filters = createDefaultWorkspaceCleanupFilterState()
+ filters.activity.idleMinDays = 20
+ filters.size.maxBytes = 0
+ filters.size.includeUnsized = false
+ filters.status.matchStatusless = false
+ filters.git.branchQuery = 'release'
+ filters.location.pathPrefix = '/repos'
+ filters.safety.selectableOnly = true
+
+ let next = filters
+ for (const applied of list(filters)) {
+ next = applied.clear(next)
+ }
+
+ expect(list(next)).toEqual([])
+ })
+
+ it('leaves the search query alone, which chips do not own', () => {
+ const filters = createDefaultWorkspaceCleanupFilterState()
+ filters.query = 'release'
+ filters.activity.idleMinDays = 20
+
+ const next = list(filters)[0].clear(filters)
+
+ expect(next.query).toBe('release')
+ })
+})
diff --git a/src/shared/workspace-cleanup-applied-filters.ts b/src/shared/workspace-cleanup-applied-filters.ts
new file mode 100644
index 00000000000..449a55f088f
--- /dev/null
+++ b/src/shared/workspace-cleanup-applied-filters.ts
@@ -0,0 +1,255 @@
+import {
+ createDefaultWorkspaceCleanupFilterState,
+ type WorkspaceCleanupFilterState
+} from './workspace-cleanup-filter-model'
+
+/**
+ * One constraint the user can see and remove from the filter bar.
+ *
+ * Deliberately per-field, not per-group: "Activity" tells a reader nothing, while
+ * "Idle >= 20d" names the thing that is hiding their workspaces. That naming *is* the
+ * fix — the reported defect was never that the effect was invisible (the bar always
+ * read "Showing 546 of 799"), it was that the cause was.
+ */
+export type WorkspaceCleanupAppliedFilter = {
+ /** Stable across renders and unique per constraint. */
+ id: string
+ /** Rendered on the chip. Already localized by the caller-supplied formatter. */
+ label: string
+ /** Returns `filters` with just this constraint reset to its default. */
+ clear: (filters: WorkspaceCleanupFilterState) => WorkspaceCleanupFilterState
+}
+
+type Formatters = {
+ idleDays: (days: number) => string
+ neverVisited: () => string
+ minSize: (bytes: number) => string
+ maxSize: (bytes: number) => string
+ excludesUnsized: () => string
+ excludesStatusless: () => string
+ list: (kind: string, count: number) => string
+ triState: (kind: string, mode: 'only' | 'exclude') => string
+ minAhead: (count: number) => string
+ minBehind: (count: number) => string
+ branchQuery: (value: string) => string
+ pathPrefix: (value: string) => string
+ presence: (kind: string, mode: string) => string
+ completelyEmpty: () => string
+ selectableOnly: () => string
+}
+
+/**
+ * Every constraint currently narrowing the fleet, in panel order.
+ *
+ * A field counts only when its matcher actually narrows. That is why minimums count
+ * above zero but `size.maxBytes` counts *at* zero: a `0` minimum matches every row,
+ * while "at most 0 MB" hides every measured non-empty workspace.
+ */
+export function listAppliedWorkspaceCleanupFilters(
+ filters: WorkspaceCleanupFilterState,
+ format: Formatters
+): WorkspaceCleanupAppliedFilter[] {
+ const defaults = createDefaultWorkspaceCleanupFilterState()
+ const applied: WorkspaceCleanupAppliedFilter[] = []
+ const add = (
+ id: string,
+ label: string,
+ clear: (f: WorkspaceCleanupFilterState) => WorkspaceCleanupFilterState
+ ): void => {
+ applied.push({ id, label, clear })
+ }
+
+ const { activity, size, status, agent, git, review, ticket, context, location, safety } = filters
+
+ if (activity.idleMinDays !== null && activity.idleMinDays > 0) {
+ add('activity.idleMinDays', format.idleDays(activity.idleMinDays), (f) => ({
+ ...f,
+ activity: { ...f.activity, idleMinDays: null }
+ }))
+ }
+ if (activity.neverVisited) {
+ add('activity.neverVisited', format.neverVisited(), (f) => ({
+ ...f,
+ activity: { ...f.activity, neverVisited: false }
+ }))
+ }
+
+ if (size.minBytes !== null && size.minBytes > 0) {
+ add('size.minBytes', format.minSize(size.minBytes), (f) => ({
+ ...f,
+ size: { ...f.size, minBytes: null }
+ }))
+ }
+ if (size.maxBytes !== null) {
+ add('size.maxBytes', format.maxSize(size.maxBytes), (f) => ({
+ ...f,
+ size: { ...f.size, maxBytes: null }
+ }))
+ }
+ if (!size.includeUnsized) {
+ add('size.includeUnsized', format.excludesUnsized(), (f) => ({
+ ...f,
+ size: { ...f.size, includeUnsized: true }
+ }))
+ }
+
+ if (status.workspaceStatuses.length > 0) {
+ add('status.workspaceStatuses', format.list('status', status.workspaceStatuses.length), (f) => ({
+ ...f,
+ status: { ...f.status, workspaceStatuses: [] }
+ }))
+ }
+ if (!status.matchStatusless) {
+ add('status.matchStatusless', format.excludesStatusless(), (f) => ({
+ ...f,
+ status: { ...f.status, matchStatusless: true }
+ }))
+ }
+ for (const key of ['archived', 'pinned', 'unread', 'comment'] as const) {
+ const value = status[key]
+ if (value !== 'any') {
+ add(`status.${key}`, format.triState(key, value), (f) => ({
+ ...f,
+ status: { ...f.status, [key]: 'any' }
+ }))
+ }
+ }
+
+ if (agent.states.length > 0) {
+ add('agent.states', format.list('agent', agent.states.length), (f) => ({
+ ...f,
+ agent: { ...f.agent, states: [] }
+ }))
+ }
+ if (agent.retainedDoneAgents !== 'any') {
+ add('agent.retainedDoneAgents', format.triState('retainedAgents', agent.retainedDoneAgents), (f) => ({
+ ...f,
+ agent: { ...f.agent, retainedDoneAgents: 'any' }
+ }))
+ }
+
+ if (git.states.length > 0) {
+ add('git.states', format.list('git', git.states.length), (f) => ({
+ ...f,
+ git: { ...f.git, states: [] }
+ }))
+ }
+ if (git.minAhead !== null && git.minAhead > 0) {
+ add('git.minAhead', format.minAhead(git.minAhead), (f) => ({
+ ...f,
+ git: { ...f.git, minAhead: null }
+ }))
+ }
+ if (git.minBehind !== null && git.minBehind > 0) {
+ add('git.minBehind', format.minBehind(git.minBehind), (f) => ({
+ ...f,
+ git: { ...f.git, minBehind: null }
+ }))
+ }
+ if (git.branchQuery.trim().length > 0) {
+ add('git.branchQuery', format.branchQuery(git.branchQuery.trim()), (f) => ({
+ ...f,
+ git: { ...f.git, branchQuery: '' }
+ }))
+ }
+ for (const key of ['prunable', 'locked'] as const) {
+ const value = git[key]
+ if (value !== 'any') {
+ add(`git.${key}`, format.triState(key, value), (f) => ({
+ ...f,
+ git: { ...f.git, [key]: 'any' }
+ }))
+ }
+ }
+
+ if (review.presence !== 'any') {
+ add('review.presence', format.presence('review', review.presence), (f) => ({
+ ...f,
+ review: { ...f.review, presence: defaults.review.presence }
+ }))
+ }
+ if (review.states.length > 0) {
+ add('review.states', format.list('reviewState', review.states.length), (f) => ({
+ ...f,
+ review: { ...f.review, states: [] }
+ }))
+ }
+ if (review.providers.length > 0) {
+ add('review.providers', format.list('reviewProvider', review.providers.length), (f) => ({
+ ...f,
+ review: { ...f.review, providers: [] }
+ }))
+ }
+
+ if (ticket.presence !== 'any') {
+ add('ticket.presence', format.presence('ticket', ticket.presence), (f) => ({
+ ...f,
+ ticket: { ...f.ticket, presence: defaults.ticket.presence }
+ }))
+ }
+ if (ticket.sources.length > 0) {
+ add('ticket.sources', format.list('ticketSource', ticket.sources.length), (f) => ({
+ ...f,
+ ticket: { ...f.ticket, sources: [] }
+ }))
+ }
+
+ if (context.presence !== 'any') {
+ add('context.presence', format.presence('context', context.presence), (f) => ({
+ ...f,
+ context: { ...f.context, presence: defaults.context.presence }
+ }))
+ }
+ if (context.completelyEmpty) {
+ add('context.completelyEmpty', format.completelyEmpty(), (f) => ({
+ ...f,
+ context: { ...f.context, completelyEmpty: false }
+ }))
+ }
+
+ if (location.hostIds.length > 0) {
+ add('location.hostIds', format.list('host', location.hostIds.length), (f) => ({
+ ...f,
+ location: { ...f.location, hostIds: [] }
+ }))
+ }
+ if (location.repoIds.length > 0) {
+ add('location.repoIds', format.list('repo', location.repoIds.length), (f) => ({
+ ...f,
+ location: { ...f.location, repoIds: [] }
+ }))
+ }
+ if (location.pathPrefix.trim().length > 0) {
+ add('location.pathPrefix', format.pathPrefix(location.pathPrefix.trim()), (f) => ({
+ ...f,
+ location: { ...f.location, pathPrefix: '' }
+ }))
+ }
+
+ if (safety.blockers.length > 0) {
+ add('safety.blockers', format.list('blocker', safety.blockers.length), (f) => ({
+ ...f,
+ safety: { ...f.safety, blockers: [] }
+ }))
+ }
+ if (safety.tiers.length > 0) {
+ add('safety.tiers', format.list('tier', safety.tiers.length), (f) => ({
+ ...f,
+ safety: { ...f.safety, tiers: [] }
+ }))
+ }
+ if (safety.dismissed !== 'any') {
+ add('safety.dismissed', format.triState('dismissed', safety.dismissed), (f) => ({
+ ...f,
+ safety: { ...f.safety, dismissed: 'any' }
+ }))
+ }
+ if (safety.selectableOnly) {
+ add('safety.selectableOnly', format.selectableOnly(), (f) => ({
+ ...f,
+ safety: { ...f.safety, selectableOnly: false }
+ }))
+ }
+
+ return applied
+}
From 7e76bb3aec6966f7a8ab9c591613cdbc62c6e405 Mon Sep 17 00:00:00 2001
From: Jinjing <6427696+AmethystLiang@users.noreply.github.com>
Date: Mon, 24 Aug 2026 12:11:55 -0700
Subject: [PATCH 18/52] Fix rebase race by fetching to private ref before
rebasing (#15990)
* Fix rebase race by fetching to private ref before rebasing
`git pull --rebase` is vulnerable to concurrent fetches modifying remote-tracking refs during execution. Fetch to a temporary private ref (refs/orca/rebase/*) first, then rebase from that stable ref to avoid the race condition.
* Fix rebase race by fetching to private ref with timeout
Concurrent fetches can interfere with remote-tracking refs between
fetch and rebase. Use a unique private ref and 60-second timeout to
isolate each rebase operation and prevent hangs on stalled remotes.
Extract gitPullRebaseFromBase to a dedicated module.
* fix rebase race by fetching to private ref with timeouts
Concurrent fetches can replace FETCH_HEAD and remote-tracking refs between
fetch and rebase, causing the rebase to fail. Fetch to a temporary private
ref instead, use --no-write-fetch-head when available (Git 2.29+), and
serialize FETCH_HEAD access for older versions. Add process termination
barriers to ensure proper cleanup and extend timeouts for SSH operations.
* Fix rebase race by fetching to both private and tracking refs
Concurrent fetches between source and rebase can replace remote-tracking refs,
causing rebases to use stale bases. Now fetch to both a private ref and the
remote-tracking ref simultaneously, ensuring the tracking ref stays current.
Also improves process termination for WSL guests with process-group tracking,
fixes process-tree termination timeouts on POSIX, and serializes FETCH_HEAD
operations for linked worktrees through their shared Git directory.
* Add WSL setsid --wait probe and barrier termination timeout
Probe for `setsid --wait` support and fall back to unwrapped execution for BusyBox compatibility. Add a deadline for process termination barriers to prevent hanging when tree termination cannot be verified. Update tests for cross-platform compatibility.
* Add wsl-process-group-termination to WSL invocation allowlist
* Serialize per-worktree git mutations to fix rebase race
Introduce operation locking for each worktree to prevent concurrent
mutations (like rebase) from interfering with each other. Ensures
rebasing a linked worktree doesn't affect the source worktree state.
Add SIGKILL fallback if process termination barriers cannot verify
tree termination.
* Serialize pull and fastForward operations per-worktree
- Extract generic git operation lock to reuse locking pattern
- Refactor existing locks to use the generic implementation
- Apply per-worktree serialization to pull and fastForward to prevent races
* Route WSL group termination through runWslProcess
ce743a4fd0 silenced the wsl-invocation boundary guard by appending
wsl-process-group-termination.ts to the allowlist. That fixture only
grows when the scanner learns to see a spawn it was blind to, and only
shrinks for a migration -- this was new code on this branch, so the
entry was the boundary regressing rather than the guard getting honest.
Migrate the kill instead. terminate() now calls runWslProcess with the
script form (` -c