@lenne.tech/cli 1.27.0 → 1.28.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/build/commands/dev/doctor.js +27 -1
- package/build/commands/dev/down.js +22 -10
- package/build/commands/dev/status.js +4 -3
- package/build/commands/dev/test.js +12 -4
- package/build/commands/dev/up.js +90 -50
- package/build/commands/ticket/list.js +78 -0
- package/build/commands/ticket/start.js +141 -0
- package/build/commands/ticket/stop.js +166 -0
- package/build/commands/ticket/switch.js +70 -0
- package/build/commands/ticket/test.js +80 -0
- package/build/commands/ticket/ticket.js +36 -0
- package/build/lib/dev-identity.js +18 -0
- package/build/lib/dev-patches.js +1 -1
- package/build/lib/dev-project.js +14 -0
- package/build/lib/dev-state.js +96 -0
- package/build/lib/dev-test-session.js +55 -35
- package/build/lib/dev-ticket.js +343 -0
- package/docs/lt-dev-ticket-workflow.html +603 -0
- package/docs/lt-dev-ticket-workflow.pdf +0 -0
- package/package.json +32 -1
|
@@ -91,29 +91,50 @@ function autoShardCount() {
|
|
|
91
91
|
*/
|
|
92
92
|
function bringUpTestSession(layout_1, baseIdentity_1, log_1) {
|
|
93
93
|
return __awaiter(this, arguments, void 0, function* (layout, baseIdentity, log, opts = {}) {
|
|
94
|
-
const { shardIndex, skipBuild } = opts;
|
|
94
|
+
const { devDbName, shardIndex, skipBuild } = opts;
|
|
95
95
|
const names = testStackNames(shardIndex);
|
|
96
|
-
const { dbName, testIdentity } = resolveTestSession(layout, baseIdentity, shardIndex);
|
|
96
|
+
const { dbName, testIdentity } = resolveTestSession(layout, baseIdentity, shardIndex, devDbName);
|
|
97
97
|
// Always start from a clean slate — reclaim a stale/crashed test session.
|
|
98
98
|
yield tearDownTestSession(layout, baseIdentity, log, { shardIndex, silent: true });
|
|
99
|
-
// Allocate internal ports
|
|
100
|
-
//
|
|
101
|
-
//
|
|
102
|
-
//
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
99
|
+
// Allocate internal ports AND reserve them in the registry ATOMICALLY, under a
|
|
100
|
+
// cross-process lock — so two parallel `lt dev test` runs (different ticket
|
|
101
|
+
// worktrees) can never read the registry, pick the same free ports, and both
|
|
102
|
+
// try to bind them during the long build window (the port-allocation race).
|
|
103
|
+
// The lock is held ONLY for this fast section; the build below runs unlocked +
|
|
104
|
+
// fully in parallel. Allocation avoids every other registry entry (dev session
|
|
105
|
+
// + sibling shards) plus anything currently listening.
|
|
106
|
+
let apiPort;
|
|
107
|
+
let appPort;
|
|
108
|
+
yield (0, dev_state_1.withRegistryLock)(() => __awaiter(this, void 0, void 0, function* () {
|
|
109
|
+
const reg = (0, dev_state_1.loadRegistry)();
|
|
110
|
+
const taken = (0, dev_state_1.takenInternalPorts)(reg, testIdentity.slug);
|
|
111
|
+
apiPort = layout.apiDir ? (0, dev_state_1.allocateInternalPort)(TEST_PORT_BASE, taken) : undefined;
|
|
112
|
+
if (apiPort)
|
|
113
|
+
taken.add(apiPort);
|
|
114
|
+
appPort = layout.appDir ? (0, dev_state_1.allocateInternalPort)(TEST_PORT_BASE, taken) : undefined;
|
|
115
|
+
const portsToCheck = [apiPort, appPort].filter((p) => typeof p === 'number');
|
|
116
|
+
const snap = yield (0, dev_process_1.listenSnapshot)(portsToCheck);
|
|
117
|
+
for (const p of portsToCheck) {
|
|
118
|
+
const r = snap.get(p);
|
|
119
|
+
if (r)
|
|
120
|
+
throw new Error(`test internal port ${p} already in use by ${r.command} (pid ${r.pid}).`);
|
|
121
|
+
}
|
|
122
|
+
// Reserve immediately (still under the lock) so a concurrent run sees these
|
|
123
|
+
// ports as taken. PIDs are written to the session file after spawn, below.
|
|
124
|
+
const subdomainMap = {};
|
|
125
|
+
for (const [k, v] of Object.entries(testIdentity.subdomains))
|
|
126
|
+
subdomainMap[k] = v.hostname;
|
|
127
|
+
reg.projects[testIdentity.slug] = {
|
|
128
|
+
dbName,
|
|
129
|
+
internalPorts: { api: apiPort, app: appPort },
|
|
130
|
+
lastUsedAt: new Date().toISOString(),
|
|
131
|
+
path: layout.root,
|
|
132
|
+
subdomains: subdomainMap,
|
|
133
|
+
};
|
|
134
|
+
(0, dev_state_1.saveRegistry)(reg);
|
|
135
|
+
}));
|
|
136
|
+
// Caddy block for the test URLs (slug-keyed → no cross-stack conflict; safe
|
|
137
|
+
// outside the lock).
|
|
117
138
|
const routes = [];
|
|
118
139
|
if (testIdentity.subdomains.api && apiPort)
|
|
119
140
|
routes.push({ hostname: testIdentity.subdomains.api.hostname, upstreamPort: apiPort });
|
|
@@ -211,19 +232,10 @@ function bringUpTestSession(layout_1, baseIdentity_1, log_1) {
|
|
|
211
232
|
if (appSpawn)
|
|
212
233
|
pids.app = appSpawn.pid;
|
|
213
234
|
}
|
|
214
|
-
// Persist
|
|
235
|
+
// Persist the session (PIDs are known now). The registry entry (ports) was
|
|
236
|
+
// already reserved BEFORE the build, above, to avoid a concurrent-allocation
|
|
237
|
+
// race between parallel ticket test runs.
|
|
215
238
|
(0, dev_state_1.saveSession)(layout.root, { pids, startedAt: new Date().toISOString() }, names.sessionFile);
|
|
216
|
-
const subdomainMap = {};
|
|
217
|
-
for (const [k, v] of Object.entries(testIdentity.subdomains))
|
|
218
|
-
subdomainMap[k] = v.hostname;
|
|
219
|
-
reg.projects[testIdentity.slug] = {
|
|
220
|
-
dbName,
|
|
221
|
-
internalPorts: { api: apiPort, app: appPort },
|
|
222
|
-
lastUsedAt: new Date().toISOString(),
|
|
223
|
-
path: layout.root,
|
|
224
|
-
subdomains: subdomainMap,
|
|
225
|
-
};
|
|
226
|
-
(0, dev_state_1.saveRegistry)(reg);
|
|
227
239
|
// ENV bridge for external tooling (kept separate from the dev `.env`).
|
|
228
240
|
(0, dev_env_bridge_1.writeEnvBridge)(layout.root, devEnv, dbName, names.bridgeFile);
|
|
229
241
|
// Wait for the test App to answer (best-effort).
|
|
@@ -251,10 +263,14 @@ function hasTestSession(root) {
|
|
|
251
263
|
return (0, dev_state_1.loadSession)(root, dev_state_1.TEST_SESSION_FILE) !== null;
|
|
252
264
|
}
|
|
253
265
|
/** Build the dedicated test identity + test DB name for a project. */
|
|
254
|
-
function resolveTestSession(layout, baseIdentity, shardIndex) {
|
|
266
|
+
function resolveTestSession(layout, baseIdentity, shardIndex, devDbName) {
|
|
255
267
|
const names = testStackNames(shardIndex);
|
|
256
268
|
const testIdentity = (0, dev_identity_1.buildTestIdentity)(baseIdentity, names.identitySuffix);
|
|
257
|
-
|
|
269
|
+
// For a ticket worktree the caller passes the ticket dev DB (e.g.
|
|
270
|
+
// `svl-sports-system-2200`), so each ticket's test DB is its own
|
|
271
|
+
// (`…-2200-test[-<shard>]`) and tickets never share a test database.
|
|
272
|
+
const baseDb = devDbName !== null && devDbName !== void 0 ? devDbName : (0, dev_project_1.deriveDbName)(layout.apiDir, baseIdentity.slug);
|
|
273
|
+
const dbName = (0, dev_project_1.deriveTestDbName)(baseDb) + names.dbSuffix;
|
|
258
274
|
return { dbName, testIdentity };
|
|
259
275
|
}
|
|
260
276
|
/**
|
|
@@ -280,7 +296,11 @@ function runShardedTestSession(layout, baseIdentity, log, opts) {
|
|
|
280
296
|
for (let index = 1; index <= total; index++) {
|
|
281
297
|
log.info('');
|
|
282
298
|
log.info(`▶ shard ${index}/${total}: bringing up isolated stack …`);
|
|
283
|
-
const ctx = yield bringUpTestSession(layout, baseIdentity, log, {
|
|
299
|
+
const ctx = yield bringUpTestSession(layout, baseIdentity, log, {
|
|
300
|
+
devDbName: opts.devDbName,
|
|
301
|
+
shardIndex: index,
|
|
302
|
+
skipBuild: index > 1,
|
|
303
|
+
});
|
|
284
304
|
contexts.push({ ctx, index });
|
|
285
305
|
}
|
|
286
306
|
// Run the N Playwright shards CONCURRENTLY, each against its own stack/DB.
|
|
@@ -0,0 +1,343 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.checkGlobalSetupTicketSafe = checkGlobalSetupTicketSafe;
|
|
4
|
+
exports.clearTicketMarker = clearTicketMarker;
|
|
5
|
+
exports.defaultTicketBranch = defaultTicketBranch;
|
|
6
|
+
exports.deriveTicketId = deriveTicketId;
|
|
7
|
+
exports.dropDatabase = dropDatabase;
|
|
8
|
+
exports.gitBranchExists = gitBranchExists;
|
|
9
|
+
exports.gitFetch = gitFetch;
|
|
10
|
+
exports.gitMainRepoRoot = gitMainRepoRoot;
|
|
11
|
+
exports.listWorktrees = listWorktrees;
|
|
12
|
+
exports.pnpmInstall = pnpmInstall;
|
|
13
|
+
exports.readTicketMarker = readTicketMarker;
|
|
14
|
+
exports.resolveDevIdentity = resolveDevIdentity;
|
|
15
|
+
exports.worktreeAdd = worktreeAdd;
|
|
16
|
+
exports.worktreeDirtyOnlyGenerated = worktreeDirtyOnlyGenerated;
|
|
17
|
+
exports.worktreePathFor = worktreePathFor;
|
|
18
|
+
exports.worktreeRemove = worktreeRemove;
|
|
19
|
+
exports.worktreeSafetyReport = worktreeSafetyReport;
|
|
20
|
+
exports.writeTicketMarker = writeTicketMarker;
|
|
21
|
+
/**
|
|
22
|
+
* Per-ticket parallel dev environments for `lt dev` (used by the `lt ticket`
|
|
23
|
+
* command group).
|
|
24
|
+
*
|
|
25
|
+
* The model: ONE git repo, N git worktrees — one per ticket/feature — each on
|
|
26
|
+
* its own branch (created fresh from `origin/dev` so tickets are independent),
|
|
27
|
+
* each running its own `lt dev` stack on a SUFFIXED identity:
|
|
28
|
+
*
|
|
29
|
+
* ticket "DEV-2200" → id "2200" → svl-2200.localhost / api.svl-2200.localhost
|
|
30
|
+
* worktree <parent>/svl-2200/ branch feat/DEV-2200
|
|
31
|
+
* DB svl-sports-system-2200 (empty at start, isolated)
|
|
32
|
+
*
|
|
33
|
+
* A worktree is "tagged" with its ticket by a `.lt-dev/ticket` marker file the
|
|
34
|
+
* moment `lt ticket start` creates it. From then on EVERY `lt dev *` command run
|
|
35
|
+
* inside that worktree (up / down / test / status) reads the marker via
|
|
36
|
+
* {@link resolveDevIdentity} and operates on the ticket's isolated stack — no
|
|
37
|
+
* flags needed. So `lt ticket` only has to orchestrate the worktree + marker and
|
|
38
|
+
* can delegate the actual bring-up/-down to the normal `lt dev` commands.
|
|
39
|
+
*/
|
|
40
|
+
const child_process_1 = require("child_process");
|
|
41
|
+
const fs_1 = require("fs");
|
|
42
|
+
const path_1 = require("path");
|
|
43
|
+
const dev_identity_1 = require("./dev-identity");
|
|
44
|
+
const dev_project_1 = require("./dev-project");
|
|
45
|
+
const dev_state_1 = require("./dev-state");
|
|
46
|
+
/** Marker file (under `.lt-dev/`) that tags a worktree with its ticket id. */
|
|
47
|
+
const TICKET_MARKER = 'ticket';
|
|
48
|
+
/**
|
|
49
|
+
* Check whether a project's Playwright `global-setup` (if it wipes a DB) would
|
|
50
|
+
* ACCEPT the per-ticket / per-shard test databases that `lt ticket` / `--shard`
|
|
51
|
+
* create (`<base>-<id>-test[-<n>]`). Used by `lt dev doctor` to WARN (never
|
|
52
|
+
* auto-edit) when a bespoke allow-list is too narrow to reset a ticket's test DB.
|
|
53
|
+
*
|
|
54
|
+
* Precise, not heuristic: it extracts the real regex literals from the file and
|
|
55
|
+
* tests them against a SYNTHETIC ticket test-DB name — so an already-safe
|
|
56
|
+
* allow-list (e.g. svl's `…-(?:[a-z0-9-]+-)?test…`) is correctly recognised and
|
|
57
|
+
* a shard-only one (`…-test-\d+`) is correctly flagged.
|
|
58
|
+
*/
|
|
59
|
+
function checkGlobalSetupTicketSafe(layout) {
|
|
60
|
+
var _a, _b;
|
|
61
|
+
const candidates = [
|
|
62
|
+
...(layout.appDir ? [(0, path_1.join)(layout.appDir, 'tests', 'global-setup.ts')] : []),
|
|
63
|
+
(0, path_1.join)(layout.root, 'tests', 'global-setup.ts'),
|
|
64
|
+
(0, path_1.join)(layout.root, 'global-setup.ts'),
|
|
65
|
+
];
|
|
66
|
+
const file = (_a = candidates.find((f) => (0, fs_1.existsSync)(f))) !== null && _a !== void 0 ? _a : null;
|
|
67
|
+
if (!file)
|
|
68
|
+
return { file: null, hasDbReset: false, ticketSafe: true };
|
|
69
|
+
let content = '';
|
|
70
|
+
try {
|
|
71
|
+
content = (0, fs_1.readFileSync)(file, 'utf8');
|
|
72
|
+
}
|
|
73
|
+
catch (_c) {
|
|
74
|
+
return { file, hasDbReset: false, ticketSafe: true };
|
|
75
|
+
}
|
|
76
|
+
const hasDbReset = /MONGO_URI|dropDatabase|emptyDatabase|deleteMany|dbNameFromUri/.test(content);
|
|
77
|
+
if (!hasDbReset)
|
|
78
|
+
return { file, hasDbReset: false, ticketSafe: true };
|
|
79
|
+
// Synthetic per-ticket test DB names derived from the project's dev DB base.
|
|
80
|
+
const base = (0, dev_project_1.deriveDbName)(layout.apiDir, (0, dev_identity_1.buildIdentity)(layout.root).slug).replace(/-(local|dev)$/i, '');
|
|
81
|
+
const samples = [`${base}-tkprobe-test`, `${base}-tkprobe-test-2`];
|
|
82
|
+
// Test every regex LITERAL in the file against the samples (char classes kept intact).
|
|
83
|
+
const literals = (_b = content.match(/\/(?:\\.|\[(?:\\.|[^\]\\])*\]|[^/\\\n[])+\/[a-z]*/gi)) !== null && _b !== void 0 ? _b : [];
|
|
84
|
+
let ticketSafe = false;
|
|
85
|
+
for (const lit of literals) {
|
|
86
|
+
const lastSlash = lit.lastIndexOf('/');
|
|
87
|
+
try {
|
|
88
|
+
const re = new RegExp(lit.slice(1, lastSlash), lit.slice(lastSlash + 1));
|
|
89
|
+
if (samples.some((s) => re.test(s))) {
|
|
90
|
+
ticketSafe = true;
|
|
91
|
+
break;
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
catch (_d) {
|
|
95
|
+
/* not a valid regex literal (e.g. a division) — ignore */
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
return { file, hasDbReset, ticketSafe };
|
|
99
|
+
}
|
|
100
|
+
/** Clear the ticket marker (called on teardown). */
|
|
101
|
+
function clearTicketMarker(root) {
|
|
102
|
+
const file = (0, path_1.join)(root, dev_state_1.paths.sessionDir, TICKET_MARKER);
|
|
103
|
+
if ((0, fs_1.existsSync)(file)) {
|
|
104
|
+
try {
|
|
105
|
+
(0, fs_1.rmSync)(file);
|
|
106
|
+
}
|
|
107
|
+
catch (_a) {
|
|
108
|
+
/* best-effort */
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
/**
|
|
113
|
+
* Default branch name for a ticket/feature: `feat/<name>` with the human ticket
|
|
114
|
+
* id preserved (case + number), only sanitised for git-ref safety.
|
|
115
|
+
*
|
|
116
|
+
* "DEV-2200" → feat/DEV-2200
|
|
117
|
+
* "checkout refactor" → feat/checkout-refactor
|
|
118
|
+
*/
|
|
119
|
+
function defaultTicketBranch(name) {
|
|
120
|
+
const safe = name
|
|
121
|
+
.trim()
|
|
122
|
+
.replace(/\s+/g, '-')
|
|
123
|
+
.replace(/[^\w.\-/]+/g, '')
|
|
124
|
+
.replace(/^-+|-+$/g, '');
|
|
125
|
+
return `feat/${safe}`;
|
|
126
|
+
}
|
|
127
|
+
/**
|
|
128
|
+
* Derive the short env id from a ticket id or free feature name.
|
|
129
|
+
*
|
|
130
|
+
* "DEV-2200" → "2200" (ticket pattern → short numeric part)
|
|
131
|
+
* "ABC-123" → "123"
|
|
132
|
+
* "checkout-refactor" → "checkout-refactor" (free name → slug)
|
|
133
|
+
* (asOverride "cof") → "cof" (explicit `--as` wins)
|
|
134
|
+
*
|
|
135
|
+
* The id flows into the slug / URLs / DB, so it is always a clean slug.
|
|
136
|
+
*/
|
|
137
|
+
function deriveTicketId(name, asOverride) {
|
|
138
|
+
if (asOverride && asOverride.trim())
|
|
139
|
+
return (0, dev_identity_1.slugify)(asOverride);
|
|
140
|
+
const trimmed = name.trim();
|
|
141
|
+
const ticketMatch = trimmed.match(/^[A-Za-z][A-Za-z0-9]*-(\d+)$/);
|
|
142
|
+
if (ticketMatch)
|
|
143
|
+
return ticketMatch[1];
|
|
144
|
+
return (0, dev_identity_1.slugify)(trimmed);
|
|
145
|
+
}
|
|
146
|
+
/** Drop a MongoDB database (best-effort, via `mongosh`). Returns true on success. */
|
|
147
|
+
function dropDatabase(dbName, mongoBaseUri = 'mongodb://127.0.0.1:27017') {
|
|
148
|
+
try {
|
|
149
|
+
(0, child_process_1.execFileSync)('mongosh', [`${mongoBaseUri}/${encodeURIComponent(dbName)}`, '--quiet', '--eval', 'db.dropDatabase()'], {
|
|
150
|
+
stdio: 'ignore',
|
|
151
|
+
});
|
|
152
|
+
return true;
|
|
153
|
+
}
|
|
154
|
+
catch (_a) {
|
|
155
|
+
return false;
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
/** True if a local branch with this name already exists. */
|
|
159
|
+
function gitBranchExists(repoDir, branch) {
|
|
160
|
+
try {
|
|
161
|
+
git(repoDir, ['show-ref', '--verify', '--quiet', `refs/heads/${branch}`]);
|
|
162
|
+
return true;
|
|
163
|
+
}
|
|
164
|
+
catch (_a) {
|
|
165
|
+
return false;
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
/** `git fetch <remote>` (so worktrees branch from the freshest base). */
|
|
169
|
+
function gitFetch(repoDir, remote = 'origin') {
|
|
170
|
+
git(repoDir, ['fetch', remote, '--prune']);
|
|
171
|
+
}
|
|
172
|
+
/**
|
|
173
|
+
* Resolve the MAIN repository root even when invoked from inside a worktree, so
|
|
174
|
+
* sibling worktrees are always created next to the primary checkout (never
|
|
175
|
+
* nested inside another worktree).
|
|
176
|
+
*/
|
|
177
|
+
function gitMainRepoRoot(cwd) {
|
|
178
|
+
// `--git-common-dir` is the SHARED `.git` (same for every worktree); its
|
|
179
|
+
// parent is the main repo root.
|
|
180
|
+
const commonDir = git(cwd, ['rev-parse', '--path-format=absolute', '--git-common-dir']);
|
|
181
|
+
return (0, path_1.dirname)(commonDir);
|
|
182
|
+
}
|
|
183
|
+
/** List all worktrees of the repo (parsed from `git worktree list --porcelain`). */
|
|
184
|
+
function listWorktrees(repoDir) {
|
|
185
|
+
let out = '';
|
|
186
|
+
try {
|
|
187
|
+
out = git(repoDir, ['worktree', 'list', '--porcelain']);
|
|
188
|
+
}
|
|
189
|
+
catch (_a) {
|
|
190
|
+
return [];
|
|
191
|
+
}
|
|
192
|
+
const result = [];
|
|
193
|
+
let current = null;
|
|
194
|
+
for (const line of out.split(/\r?\n/)) {
|
|
195
|
+
if (line.startsWith('worktree ')) {
|
|
196
|
+
if (current === null || current === void 0 ? void 0 : current.path)
|
|
197
|
+
result.push(finalizeWorktree(current));
|
|
198
|
+
current = { branch: null, path: line.slice('worktree '.length) };
|
|
199
|
+
}
|
|
200
|
+
else if (line.startsWith('branch ') && current) {
|
|
201
|
+
current.branch = line.slice('branch '.length).replace(/^refs\/heads\//, '');
|
|
202
|
+
}
|
|
203
|
+
}
|
|
204
|
+
if (current === null || current === void 0 ? void 0 : current.path)
|
|
205
|
+
result.push(finalizeWorktree(current));
|
|
206
|
+
return result;
|
|
207
|
+
}
|
|
208
|
+
/** Install dependencies in a freshly-created worktree (pnpm hard-links from the shared store → fast). */
|
|
209
|
+
function pnpmInstall(dir) {
|
|
210
|
+
const pnpmBin = process.env.LT_PNPM_BIN || 'pnpm';
|
|
211
|
+
(0, child_process_1.execFileSync)(pnpmBin, ['install'], { cwd: dir, stdio: 'inherit' });
|
|
212
|
+
}
|
|
213
|
+
/** Read the ticket id this worktree is tagged with, or null. */
|
|
214
|
+
function readTicketMarker(root) {
|
|
215
|
+
const file = (0, path_1.join)(root, dev_state_1.paths.sessionDir, TICKET_MARKER);
|
|
216
|
+
if (!(0, fs_1.existsSync)(file))
|
|
217
|
+
return null;
|
|
218
|
+
try {
|
|
219
|
+
const id = (0, fs_1.readFileSync)(file, 'utf8').trim();
|
|
220
|
+
return id || null;
|
|
221
|
+
}
|
|
222
|
+
catch (_a) {
|
|
223
|
+
return null;
|
|
224
|
+
}
|
|
225
|
+
}
|
|
226
|
+
/**
|
|
227
|
+
* Resolve the dev identity + DB name for a project root, ticket-aware.
|
|
228
|
+
*
|
|
229
|
+
* Order of precedence for the ticket id:
|
|
230
|
+
* 1. an explicit `--ticket <name>` option (raw → {@link deriveTicketId}),
|
|
231
|
+
* 2. the `.lt-dev/ticket` marker in the worktree (already an id),
|
|
232
|
+
* 3. none → the plain project identity (base dev stack).
|
|
233
|
+
*
|
|
234
|
+
* Used by `lt dev up / down / test / status` so all of them treat a ticket
|
|
235
|
+
* worktree identically without duplicating the suffix logic.
|
|
236
|
+
*/
|
|
237
|
+
function resolveDevIdentity(layout, options) {
|
|
238
|
+
const base = (0, dev_identity_1.buildIdentity)(layout.root);
|
|
239
|
+
const baseDb = (0, dev_project_1.deriveDbName)(layout.apiDir, base.slug);
|
|
240
|
+
const flag = typeof (options === null || options === void 0 ? void 0 : options.ticket) === 'string' && options.ticket.trim() ? deriveTicketId(options.ticket) : null;
|
|
241
|
+
const ticket = flag !== null && flag !== void 0 ? flag : readTicketMarker(layout.root);
|
|
242
|
+
if (!ticket)
|
|
243
|
+
return { dbName: baseDb, identity: base, ticket: null };
|
|
244
|
+
return {
|
|
245
|
+
dbName: (0, dev_project_1.deriveTicketDbName)(baseDb, ticket),
|
|
246
|
+
identity: (0, dev_identity_1.buildTicketIdentity)(base, ticket),
|
|
247
|
+
ticket,
|
|
248
|
+
};
|
|
249
|
+
}
|
|
250
|
+
/**
|
|
251
|
+
* Add a worktree for a ticket. Creates the branch from `baseRef` when it does
|
|
252
|
+
* not exist yet, otherwise checks the existing branch out into the worktree.
|
|
253
|
+
*/
|
|
254
|
+
function worktreeAdd(repoDir, worktreePath, branch, baseRef) {
|
|
255
|
+
if (gitBranchExists(repoDir, branch)) {
|
|
256
|
+
git(repoDir, ['worktree', 'add', worktreePath, branch]);
|
|
257
|
+
}
|
|
258
|
+
else {
|
|
259
|
+
git(repoDir, ['worktree', 'add', '-b', branch, worktreePath, baseRef]);
|
|
260
|
+
}
|
|
261
|
+
}
|
|
262
|
+
/** Framework-generated / ephemeral paths a dev/build run dirties (never real work). */
|
|
263
|
+
const GENERATED_PATHS = /(^|\/)(\.nuxtrc|\.nuxt|\.nitro|\.output|dist|\.turbo|\.cache|\.eslintcache)(\/|$)|\.tsbuildinfo$/;
|
|
264
|
+
/**
|
|
265
|
+
* True ONLY when a worktree has uncommitted changes AND every one is a
|
|
266
|
+
* framework-generated / ephemeral file (`.nuxtrc`, `.nuxt`, `.output`, …).
|
|
267
|
+
* `nuxt dev` rewrites the tracked `.nuxtrc` on boot, which would otherwise block
|
|
268
|
+
* `git worktree remove`; this lets `lt ticket stop` auto-clean those safely.
|
|
269
|
+
*/
|
|
270
|
+
function worktreeDirtyOnlyGenerated(worktreePath) {
|
|
271
|
+
let out = '';
|
|
272
|
+
try {
|
|
273
|
+
out = git(worktreePath, ['status', '--porcelain', '--untracked-files=all']);
|
|
274
|
+
}
|
|
275
|
+
catch (_a) {
|
|
276
|
+
return false;
|
|
277
|
+
}
|
|
278
|
+
const lines = out.split(/\r?\n/).filter((l) => l.trim());
|
|
279
|
+
if (lines.length === 0)
|
|
280
|
+
return false; // clean → no force needed
|
|
281
|
+
return lines.every((line) => GENERATED_PATHS.test(porcelainPath(line)));
|
|
282
|
+
}
|
|
283
|
+
/**
|
|
284
|
+
* Compute the sibling worktree path for a ticket: `<parent-of-main-repo>/<slug>-<id>`,
|
|
285
|
+
* so it sits right next to the primary checkout and matches the URL (`<slug>-<id>.localhost`).
|
|
286
|
+
*/
|
|
287
|
+
function worktreePathFor(mainRepoRoot, slug, id) {
|
|
288
|
+
return (0, path_1.join)((0, path_1.dirname)(mainRepoRoot), `${slug}-${id}`);
|
|
289
|
+
}
|
|
290
|
+
/** Remove a worktree (the branch is kept). */
|
|
291
|
+
function worktreeRemove(repoDir, worktreePath, force = false) {
|
|
292
|
+
const args = ['worktree', 'remove', worktreePath];
|
|
293
|
+
if (force)
|
|
294
|
+
args.push('--force');
|
|
295
|
+
git(repoDir, args);
|
|
296
|
+
}
|
|
297
|
+
/**
|
|
298
|
+
* Unsaved-work report for a worktree, so `lt ticket stop` can WARN + refuse to
|
|
299
|
+
* delete it before the user has committed AND pushed: `dirtySource` are
|
|
300
|
+
* uncommitted NON-generated changes (lost on removal), `unpushed` are commits on
|
|
301
|
+
* the branch not on any remote (the branch is kept on stop, but local-only).
|
|
302
|
+
*/
|
|
303
|
+
function worktreeSafetyReport(worktreePath) {
|
|
304
|
+
let status = '';
|
|
305
|
+
try {
|
|
306
|
+
status = git(worktreePath, ['status', '--porcelain', '--untracked-files=all']);
|
|
307
|
+
}
|
|
308
|
+
catch (_a) {
|
|
309
|
+
return { dirtySource: [], unpushed: 0 };
|
|
310
|
+
}
|
|
311
|
+
const dirtySource = status
|
|
312
|
+
.split(/\r?\n/)
|
|
313
|
+
.filter((l) => l.trim())
|
|
314
|
+
.filter((l) => !GENERATED_PATHS.test(porcelainPath(l)));
|
|
315
|
+
let unpushed = 0;
|
|
316
|
+
try {
|
|
317
|
+
unpushed = Number(git(worktreePath, ['rev-list', '--count', 'HEAD', '--not', '--remotes'])) || 0;
|
|
318
|
+
}
|
|
319
|
+
catch (_b) {
|
|
320
|
+
/* no remotes / detached HEAD → cannot determine; treat as 0 */
|
|
321
|
+
}
|
|
322
|
+
return { dirtySource, unpushed };
|
|
323
|
+
}
|
|
324
|
+
/** Write the ticket marker that tags a worktree (created by `lt ticket start`). */
|
|
325
|
+
function writeTicketMarker(root, id) {
|
|
326
|
+
const dir = (0, path_1.join)(root, dev_state_1.paths.sessionDir);
|
|
327
|
+
(0, fs_1.mkdirSync)(dir, { recursive: true });
|
|
328
|
+
(0, fs_1.writeFileSync)((0, path_1.join)(dir, TICKET_MARKER), `${id}\n`, 'utf8');
|
|
329
|
+
}
|
|
330
|
+
function finalizeWorktree(partial) {
|
|
331
|
+
var _a, _b;
|
|
332
|
+
const path = (_a = partial.path) !== null && _a !== void 0 ? _a : '';
|
|
333
|
+
return { branch: (_b = partial.branch) !== null && _b !== void 0 ? _b : null, path, ticket: path ? readTicketMarker(path) : null };
|
|
334
|
+
}
|
|
335
|
+
/** Run a git command in `cwd`, returning trimmed stdout. Throws on non-zero exit. */
|
|
336
|
+
function git(cwd, args) {
|
|
337
|
+
return (0, child_process_1.execFileSync)('git', args, { cwd, encoding: 'utf8' }).trim();
|
|
338
|
+
}
|
|
339
|
+
/** Path from a `git status --porcelain` line ("XY <path>" / "XY <old> -> <new>"). */
|
|
340
|
+
function porcelainPath(line) {
|
|
341
|
+
var _a;
|
|
342
|
+
return (_a = line.slice(3).replace(/^"|"$/g, '').split(' -> ').pop()) !== null && _a !== void 0 ? _a : '';
|
|
343
|
+
}
|