-
Notifications
You must be signed in to change notification settings - Fork 3
Expand file tree
/
Copy pathworktree-policy.ts
More file actions
164 lines (147 loc) · 5.8 KB
/
Copy pathworktree-policy.ts
File metadata and controls
164 lines (147 loc) · 5.8 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
import type { ProjectWorktreeMode, ThreadWorktreeChoice } from '@shared/types/worktree.ts'
/**
* Threads are isolated unless a project opts out. Every caller that resolves a
* project's mode reads this, so the default lives in exactly one place.
*/
export const DEFAULT_PROJECT_WORKTREE_MODE: ProjectWorktreeMode = 'always'
export type WorktreePolicyReason =
| 'explicit-shared'
| 'explicit-worktree'
| 'project-always'
| 'project-disabled'
| 'not-local'
| 'not-git'
| 'detached-head'
| 'submodules-unsupported'
export interface WorktreePolicyInput {
choice?: ThreadWorktreeChoice
projectMode?: ProjectWorktreeMode
isLocal: boolean
isGitRepository: boolean
currentBranch: string | null
defaultBranch: string | null
isDirty: boolean
hasSubmodules: boolean
}
export type WorktreePolicyDecision =
| {
checkoutMode: 'worktree'
reason: 'explicit-worktree' | 'project-always'
seededFromDirtyProject: boolean
}
| {
checkoutMode: 'shared'
reason: Exclude<WorktreePolicyReason, 'explicit-worktree' | 'project-always'>
seededFromDirtyProject: false
}
| {
checkoutMode: 'blocked'
reason: 'not-local' | 'not-git' | 'detached-head' | 'submodules-unsupported'
seededFromDirtyProject: false
}
function unsupportedReason(
input: WorktreePolicyInput,
): Extract<WorktreePolicyDecision, { checkoutMode: 'blocked' }>['reason'] | null {
if (!input.isLocal) return 'not-local'
if (!input.isGitRepository) return 'not-git'
if (input.hasSubmodules) return 'submodules-unsupported'
if (!input.currentBranch) return 'detached-head'
return null
}
/**
* The `checkoutMode` `decideThreadWorktreePolicy` will reach without
* inspecting the repository, or null when the repository genuinely decides.
*
* An explicit shared choice always settles to `shared`. An automatic choice
* also settles to `shared` when the project has worktrees disabled. With no
* project mode, use the policy's default (`always`), which still requires the
* repository inspection because an unsupported repository falls back to the
* shared checkout.
*
* Callers that only need the mode — such as the composer's checkout preview,
* which the footer re-runs on every thread switch — can use this to skip four
* Git queries, including `getDefaultBranch`'s possible network fallback.
*
* `worktree-policy.test.ts` pins the agreement exhaustively: wherever this
* returns a mode, `decideThreadWorktreePolicy` must return the same one for
* every combination of inspection inputs.
*/
export function settledCheckoutMode(
input: Pick<WorktreePolicyInput, 'choice' | 'projectMode'>,
): WorktreePolicyDecision['checkoutMode'] | null {
const choice = input.choice ?? 'automatic'
const projectMode = input.projectMode ?? DEFAULT_PROJECT_WORKTREE_MODE
if (choice === 'shared') return 'shared'
if (choice === 'automatic' && projectMode === 'never') return 'shared'
return null
}
/**
* Whether the project's uncommitted work can be carried into the new worktree.
*
* Seeding restores a snapshot of the selected local checkout over the new
* worktree. The allocator performs the final commit check: when a selected
* default branch has moved upstream, it starts clean rather than applying the
* snapshot to that newer tree.
*/
function canSeedFromDirtyProject(input: WorktreePolicyInput): boolean {
return input.isDirty && input.currentBranch !== null
}
/** Decide the first-message checkout without inspecting mutable process state. */
export function decideThreadWorktreePolicy(input: WorktreePolicyInput): WorktreePolicyDecision {
const choice = input.choice ?? 'automatic'
const projectMode = input.projectMode ?? DEFAULT_PROJECT_WORKTREE_MODE
if (choice === 'shared') {
return { checkoutMode: 'shared', reason: 'explicit-shared', seededFromDirtyProject: false }
}
const unsupported = unsupportedReason(input)
if (unsupported) {
return choice === 'worktree'
? { checkoutMode: 'blocked', reason: unsupported, seededFromDirtyProject: false }
: { checkoutMode: 'shared', reason: unsupported, seededFromDirtyProject: false }
}
if (choice === 'worktree') {
return {
checkoutMode: 'worktree',
reason: 'explicit-worktree',
seededFromDirtyProject: canSeedFromDirtyProject(input),
}
}
if (projectMode === 'never') {
return { checkoutMode: 'shared', reason: 'project-disabled', seededFromDirtyProject: false }
}
return {
checkoutMode: 'worktree',
reason: 'project-always',
seededFromDirtyProject: canSeedFromDirtyProject(input),
}
}
function slugPrompt(prompt: string): string {
const slug = prompt
.normalize('NFKD')
.toLowerCase()
.replace(/[^a-z0-9]+/g, '-')
.replace(/^-+|-+$/g, '')
.slice(0, 42)
.replace(/-+$/g, '')
return slug || 'thread'
}
function shortThreadId(threadId: string): string {
const compact = threadId.toLowerCase().replace(/[^a-z0-9]/g, '')
return (compact.slice(-6) || 'thread').slice(0, 6)
}
/** Stable branch candidate derived from a settled thread title. */
export function threadWorktreeBranchName(title: string, threadId: string, collision = 0): string {
const base = `copse/${slugPrompt(title)}-${shortThreadId(threadId)}`
return collision > 0 ? `${base}-${String(collision + 1)}` : base
}
/** Anonymous first branch; the random thread id supplies the collision-resistant suffix. */
export function initialThreadWorktreeBranchName(threadId: string, collision = 0): string {
return threadWorktreeBranchName('thread', threadId, collision)
}
/** True only for one of the allocator's anonymous initial candidates. */
export function isInitialThreadWorktreeBranchName(branch: string, threadId: string): boolean {
for (let collision = 0; collision < 100; collision += 1) {
if (branch === initialThreadWorktreeBranchName(threadId, collision)) return true
}
return false
}