@try-works/dsh-recursive-mode 0.1.18 → 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/lib/git-context.d.ts +53 -0
- package/lib/index.d.ts +1 -0
- package/lib/index.js +666 -20
- package/lib/init-templates.d.ts +4 -0
- package/lib/recursive_worktree.tool.d.ts +7 -0
- package/lib/runtime.d.ts +28 -1
- package/lib/ts-lint.d.ts +8 -0
- package/lib/worktree.d.ts +59 -0
- package/package.json +1 -1
- package/src/commands.ts +46 -0
- package/src/git-context.ts +132 -0
- package/src/index.ts +3 -0
- package/src/init-templates.ts +15 -2
- package/src/recursive_init.tool.ts +9 -3
- package/src/recursive_worktree.tool.ts +54 -0
- package/src/runtime.ts +57 -6
- package/src/ts-lint.ts +31 -0
- package/src/worktree.ts +229 -0
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) {
|
package/src/worktree.ts
ADDED
|
@@ -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
|
+
}
|