@try-works/dsh-recursive-mode 0.1.17 → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/ts-lint.ts CHANGED
@@ -10,6 +10,7 @@ import { existsSync, readFileSync, readdirSync, statSync } from 'node:fs'
10
10
  import { join, basename, dirname } from 'node:path'
11
11
  import { createHash } from 'node:crypto'
12
12
  import { execFileSync } from 'node:child_process'
13
+ import { verifyBranchBase, verifyWorktreeBranch } from './git-context.ts'
13
14
  import {
14
15
  CURRENT_WORKFLOW_PROFILE,
15
16
  STRICT_WORKFLOW_PROFILE,
@@ -441,6 +442,26 @@ export function getRunDiffBasis(runDir: string): Record<string, string | null> {
441
442
  }
442
443
  }
443
444
 
445
+ /**
446
+ * verify_recorded_branches: worktree + branch awareness at lint time. Compares
447
+ * the branches recorded in 00-worktree.md against live git state. Returns a
448
+ * list of FAIL messages (empty when consistent). Both the base branch and the
449
+ * worktree branch must resolve; the worktree branch must match the live HEAD
450
+ * branch, and it must actually be based on the recorded base branch.
451
+ */
452
+ export function verifyRecordedBranches(repoRoot: string, diffBasis: Record<string, string | null>, runDir: string): string[] {
453
+ const fails: string[] = []
454
+ const baseBranch = trimMdValue(diffBasis.base_branch ?? '')
455
+ const worktreeBranch = trimMdValue(diffBasis.worktree_branch ?? '')
456
+ // Nothing recorded -> nothing to verify (defer, consistent with missing fields).
457
+ if (!baseBranch && !worktreeBranch) return fails
458
+ const wtCheck = verifyWorktreeBranch(repoRoot, worktreeBranch || null)
459
+ if (!wtCheck.ok && wtCheck.reason) fails.push(wtCheck.reason)
460
+ const baseCheck = verifyBranchBase(repoRoot, baseBranch || null, worktreeBranch || null)
461
+ if (!baseCheck.ok && baseCheck.reason) fails.push(baseCheck.reason)
462
+ return fails
463
+ }
464
+
444
465
  /** normalize_diff_basis: validate + compute the executable diff basis. */
445
466
  export function normalizeDiffBasis(repoRoot: string, diffBasis: Record<string, string | null>): [Record<string, string> | null, string | null] {
446
467
  const baselineType = normalizeBaselineType(diffBasis.baseline_type)
@@ -2007,6 +2028,16 @@ export function lintRun(repoRoot: string, runId: string): LintResult {
2007
2028
  let diffBasisError: string | null = null
2008
2029
  if (STRICT_WORKFLOW_PROFILES.has(workflowProfile)) {
2009
2030
  const diffBasis = getRunDiffBasis(runDir)
2031
+ // Worktree + branch awareness: verify the recorded base/worktree branch
2032
+ // context against live git before trusting the diff basis. Emits FAIL when
2033
+ // the run's recorded checkout/branch no longer matches reality (e.g. lint
2034
+ // running from the main checkout instead of the run's worktree, or the
2035
+ // worktree branch changed after Phase 0 locked).
2036
+ const branchFails = verifyRecordedBranches(root, diffBasis, runDir)
2037
+ for (const message of branchFails) {
2038
+ totalFail += 1
2039
+ writeIssue('FAIL', runDir, message)
2040
+ }
2010
2041
  if (diffBasis.baseline_reference || diffBasis.normalized_baseline) {
2011
2042
  const [rawChanged, gitError] = getGitChangedFiles(root, diffBasis)
2012
2043
  if (gitError) {
@@ -0,0 +1,229 @@
1
+ /**
2
+ * worktree.ts — drive linked-worktree creation and branch promotion.
3
+ *
4
+ * Worktree + branch awareness, operational half (R?): after detection
5
+ * (git-context.ts) and lint validation (ts-lint.ts), this module performs the
6
+ * git operations that actually realize the dev/stage/main worktree workflow:
7
+ *
8
+ * - `createLinkedWorktree`: create a linked worktree at
9
+ * `<repoRoot>/.worktrees/<runId>/` on a worktree branch based on a base
10
+ * branch (default: the upstream/promotion source branch).
11
+ * - `promoteBranch`: fast-forward one branch into a target promotion stage
12
+ * (feature -> dev -> stage -> main). The promotion is verified to be a
13
+ * fast-forward first (the target must be an ancestor of the source) and
14
+ * then applied where it is safe:
15
+ * - if the target branch is checked out in a worktree, `git merge
16
+ * --ff-only` runs THERE (safe: moves the branch and its working tree
17
+ * forward together, never a merge commit);
18
+ * - otherwise the branch ref is updated directly.
19
+ * It never rewrites history and never creates a merge commit.
20
+ *
21
+ * Both operations are explicit, workspace-scoped, and total: they return
22
+ * { ok, ... } rather than corrupting git state. No operation ever touches a
23
+ * branch other than the explicitly requested one.
24
+ */
25
+ import { execFileSync } from 'node:child_process'
26
+ import { existsSync } from 'node:fs'
27
+ import { join } from 'node:path'
28
+
29
+ export interface CreateWorktreeOptions {
30
+ repoRoot: string
31
+ runId: string
32
+ /** Base branch the worktree branch is cut from (default: current HEAD branch). */
33
+ baseBranch?: string
34
+ /** Worktree branch name (default: `recursive/<runId>`). */
35
+ worktreeBranch?: string
36
+ }
37
+
38
+ export interface CreateWorktreeResult {
39
+ ok: boolean
40
+ worktreeDir: string
41
+ worktreeBranch: string
42
+ baseBranch: string
43
+ error?: string
44
+ }
45
+
46
+ export interface PromoteBranchOptions {
47
+ repoRoot: string
48
+ /** Branch being promoted (the source of the new commits). */
49
+ fromBranch: string
50
+ /** Promotion target branch (feature -> dev -> stage -> main). */
51
+ toBranch: string
52
+ }
53
+
54
+ export interface PromoteBranchResult {
55
+ ok: boolean
56
+ fromBranch: string
57
+ toBranch: string
58
+ action: 'fast-forward' | 'created'
59
+ error?: string
60
+ }
61
+
62
+ /** Run git; throw on failure (callers catch to turn into a result). */
63
+ function gitThrow(repoRoot: string, ...args: string[]): string {
64
+ return execFileSync('git', args, { cwd: repoRoot, encoding: 'utf8', stdio: ['ignore', 'pipe', 'pipe'] }).trim()
65
+ }
66
+
67
+ /** Resolve the current branch of a repo dir (short name or null when detached). */
68
+ function currentBranch(repoRoot: string): string | null {
69
+ try {
70
+ return gitThrow(repoRoot, 'symbolic-ref', '--quiet', '--short', 'HEAD')
71
+ } catch {
72
+ return null
73
+ }
74
+ }
75
+
76
+ /** Default worktree branch name for a run. */
77
+ export function defaultWorktreeBranch(runId: string): string {
78
+ return 'recursive/' + runId
79
+ }
80
+
81
+ /** One row of `git worktree list --porcelain` (path + branch when attached). */
82
+ export interface WorktreeInfo {
83
+ path: string
84
+ branch: string | null
85
+ detached: boolean
86
+ }
87
+
88
+ /** List linked worktrees (path + branch) for a repo root. */
89
+ export function listWorktrees(repoRoot: string): WorktreeInfo[] {
90
+ let out: string
91
+ try {
92
+ out = gitThrow(repoRoot, 'worktree', 'list', '--porcelain')
93
+ } catch {
94
+ return []
95
+ }
96
+ const result: WorktreeInfo[] = []
97
+ let current: WorktreeInfo | null = null
98
+ for (const line of out.split(/\r?\n/)) {
99
+ const trimmed = line.trim()
100
+ if (!trimmed) { if (current) { result.push(current); current = null } continue }
101
+ if (trimmed.startsWith('worktree ')) {
102
+ current = { path: trimmed.slice('worktree '.length), branch: null, detached: false }
103
+ } else if (trimmed.startsWith('branch refs/heads/') && current) {
104
+ current.branch = trimmed.slice('branch refs/heads/'.length)
105
+ } else if (trimmed === 'detached' && current) {
106
+ current.detached = true
107
+ }
108
+ }
109
+ if (current) result.push(current)
110
+ return result
111
+ }
112
+
113
+ /**
114
+ * Find the worktree path where `branch` is currently checked out, or null.
115
+ * Parses `git worktree list --porcelain` (block per worktree with a
116
+ * `branch refs/heads/<name>` line).
117
+ */
118
+ export function findWorktreeForBranch(repoRoot: string, branch: string): string | null {
119
+ let out: string
120
+ try {
121
+ out = gitThrow(repoRoot, 'worktree', 'list', '--porcelain')
122
+ } catch {
123
+ return null
124
+ }
125
+ let currentPath: string | null = null
126
+ for (const line of out.split(/\r?\n/)) {
127
+ const trimmed = line.trim()
128
+ if (!trimmed) { currentPath = null; continue }
129
+ if (trimmed.startsWith('worktree ')) {
130
+ currentPath = trimmed.slice('worktree '.length)
131
+ } else if (trimmed.startsWith('branch refs/heads/')) {
132
+ const name = trimmed.slice('branch refs/heads/'.length)
133
+ if (name === branch && currentPath) return currentPath
134
+ }
135
+ }
136
+ return null
137
+ }
138
+
139
+ /**
140
+ * Create a linked worktree at `.worktrees/<runId>/` for a run. The worktree
141
+ * branch is cut from `baseBranch` (default: the current HEAD branch). Returns
142
+ * the created worktree dir + branch. No-op-safe: if the worktree dir already
143
+ * exists, returns ok with the existing dir. Refuses to create a worktree when
144
+ * the run directory already exists (prevents clobbering an in-progress run).
145
+ */
146
+ export function createLinkedWorktree(opts: CreateWorktreeOptions): CreateWorktreeResult {
147
+ const { repoRoot, runId } = opts
148
+ const baseBranch = opts.baseBranch ?? currentBranch(repoRoot) ?? 'main'
149
+ const worktreeBranch = opts.worktreeBranch ?? defaultWorktreeBranch(runId)
150
+ const worktreeDir = join(repoRoot, '.worktrees', runId)
151
+
152
+ if (existsSync(worktreeDir)) {
153
+ return { ok: true, worktreeDir, worktreeBranch, baseBranch, error: undefined }
154
+ }
155
+ // Guard: never create a worktree over an in-progress run directory.
156
+ const runDir = join(repoRoot, '.recursive', 'run', runId)
157
+ if (existsSync(runDir)) {
158
+ return { ok: false, worktreeDir, worktreeBranch, baseBranch, error: 'run directory already exists: ' + runDir + ' (refusing to create a worktree for an existing run)' }
159
+ }
160
+ // Base branch must resolve.
161
+ try {
162
+ gitThrow(repoRoot, 'rev-parse', '--verify', baseBranch + '^{commit}')
163
+ } catch {
164
+ return { ok: false, worktreeDir, worktreeBranch, baseBranch, error: 'base branch does not resolve: ' + baseBranch }
165
+ }
166
+ try {
167
+ gitThrow(repoRoot, 'worktree', 'add', '-b', worktreeBranch, worktreeDir, baseBranch)
168
+ } catch (err) {
169
+ return { ok: false, worktreeDir, worktreeBranch, baseBranch, error: 'git worktree add failed: ' + (err as Error).message }
170
+ }
171
+ return { ok: true, worktreeDir, worktreeBranch, baseBranch }
172
+ }
173
+
174
+ /**
175
+ * Fast-forward `toBranch` to `fromBranch` (promotion up the dev/stage/main
176
+ * chain). Returns ok:false when the promotion is not a pure fast-forward
177
+ * (would require a merge commit) or when the from/to branches are missing.
178
+ */
179
+ export function promoteBranch(opts: PromoteBranchOptions): PromoteBranchResult {
180
+ const { repoRoot, fromBranch, toBranch } = opts
181
+ if (fromBranch === toBranch) {
182
+ return { ok: false, fromBranch, toBranch, action: 'fast-forward', error: 'from and to branches are identical: ' + fromBranch }
183
+ }
184
+ // Resolve the source commit; the target may or may not exist yet.
185
+ let fromSha: string | null = null
186
+ try { fromSha = gitThrow(repoRoot, 'rev-parse', '--verify', fromBranch + '^{commit}') } catch { /* missing */ }
187
+ if (!fromSha) {
188
+ return { ok: false, fromBranch, toBranch, action: 'fast-forward', error: 'from branch does not resolve: ' + fromBranch }
189
+ }
190
+ let toSha: string | null = null
191
+ try { toSha = gitThrow(repoRoot, 'rev-parse', '--verify', toBranch + '^{commit}') } catch { /* missing */ }
192
+
193
+ if (!toSha) {
194
+ // Target doesn't exist yet — create it at the source commit (first promotion).
195
+ try {
196
+ gitThrow(repoRoot, 'branch', toBranch, fromSha)
197
+ return { ok: true, fromBranch, toBranch, action: 'created' }
198
+ } catch (err) {
199
+ return { ok: false, fromBranch, toBranch, action: 'created', error: 'git branch failed: ' + (err as Error).message }
200
+ }
201
+ }
202
+
203
+ // Verify the target is an ancestor of the source (a pure fast-forward).
204
+ try {
205
+ execFileSync('git', ['-C', repoRoot, 'merge-base', '--is-ancestor', toBranch, fromBranch], { stdio: ['ignore', 'pipe', 'pipe'] })
206
+ } catch {
207
+ return { ok: false, fromBranch, toBranch, action: 'fast-forward', error: 'promotion is not a fast-forward: ' + toBranch + ' is not an ancestor of ' + fromBranch }
208
+ }
209
+
210
+ // Apply the fast-forward where it is safe:
211
+ // - if the target branch is checked out in a worktree, merge --ff-only there
212
+ // (moves the branch AND its working tree forward together, never a merge);
213
+ // - otherwise update the branch ref directly.
214
+ const checkedOutIn = findWorktreeForBranch(repoRoot, toBranch)
215
+ if (checkedOutIn) {
216
+ try {
217
+ gitThrow(checkedOutIn, 'merge', '--ff-only', fromBranch)
218
+ return { ok: true, fromBranch, toBranch, action: 'fast-forward' }
219
+ } catch (err) {
220
+ return { ok: false, fromBranch, toBranch, action: 'fast-forward', error: 'fast-forward merge in ' + checkedOutIn + ' failed: ' + (err as Error).message }
221
+ }
222
+ }
223
+ try {
224
+ gitThrow(repoRoot, 'update-ref', 'refs/heads/' + toBranch, fromSha)
225
+ return { ok: true, fromBranch, toBranch, action: 'fast-forward' }
226
+ } catch (err) {
227
+ return { ok: false, fromBranch, toBranch, action: 'fast-forward', error: 'git update-ref failed: ' + (err as Error).message }
228
+ }
229
+ }