@lenne.tech/cli 1.35.1 → 1.37.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.
@@ -5,12 +5,21 @@ exports.clearTicketMarker = clearTicketMarker;
5
5
  exports.defaultTicketBranch = defaultTicketBranch;
6
6
  exports.deriveTicketId = deriveTicketId;
7
7
  exports.dropDatabase = dropDatabase;
8
+ exports.dropDatabases = dropDatabases;
8
9
  exports.gitBranchExists = gitBranchExists;
9
10
  exports.gitFetch = gitFetch;
10
11
  exports.gitMainRepoRoot = gitMainRepoRoot;
12
+ exports.gitRefExists = gitRefExists;
11
13
  exports.installWorktreeDeps = installWorktreeDeps;
14
+ exports.isReservedTicketId = isReservedTicketId;
15
+ exports.isTicketScopedDb = isTicketScopedDb;
16
+ exports.keepDbFlag = keepDbFlag;
17
+ exports.listBaseRefChoices = listBaseRefChoices;
18
+ exports.listDatabaseNames = listDatabaseNames;
12
19
  exports.listWorktrees = listWorktrees;
20
+ exports.planTicketDbDrop = planTicketDbDrop;
13
21
  exports.readTicketMarker = readTicketMarker;
22
+ exports.resolveBaseRef = resolveBaseRef;
14
23
  exports.resolveDevIdentity = resolveDevIdentity;
15
24
  exports.worktreeAdd = worktreeAdd;
16
25
  exports.worktreeDirtyOnlyAutoDiscardable = worktreeDirtyOnlyAutoDiscardable;
@@ -24,8 +33,9 @@ exports.writeTicketMarker = writeTicketMarker;
24
33
  * command group).
25
34
  *
26
35
  * The model: ONE git repo, N git worktrees — one per ticket/feature — each on
27
- * its own branch (created fresh from `origin/dev` so tickets are independent),
28
- * each running its own `lt dev` stack on a SUFFIXED identity:
36
+ * its own branch (created fresh from the repo's base branch — see
37
+ * {@link resolveBaseRef} so tickets are independent), each running its own
38
+ * `lt dev` stack on a SUFFIXED identity:
29
39
  *
30
40
  * ticket "DEV-2200" → id "2200" → svl-2200.localhost / api.svl-2200.localhost
31
41
  * worktree <parent>/svl-2200/ branch feat/DEV-2200
@@ -49,6 +59,23 @@ const dev_project_1 = require("./dev-project");
49
59
  const dev_state_1 = require("./dev-state");
50
60
  /** Marker file (under `.lt-dev/`) that tags a worktree with its ticket id. */
51
61
  const TICKET_MARKER = 'ticket';
62
+ /** Where ticket databases live. Deliberately loopback-only — never a remote/prod host. */
63
+ const MONGO_BASE_URI = 'mongodb://127.0.0.1:27017';
64
+ /** Upper bound for a single `mongosh` call, so an unreachable Mongo cannot hang a teardown. */
65
+ const MONGOSH_TIMEOUT_MS = 10000;
66
+ /**
67
+ * Ticket ids that would make the ticket's derived database collide with a
68
+ * PROJECT-level database.
69
+ *
70
+ * {@link deriveTicketDbName} and {@link deriveTestDbName} both strip a trailing
71
+ * `-(local|dev)` before appending their own suffix, so these ids round-trip onto
72
+ * the project's own DBs — e.g. project db `imo-local` + ticket id `local` derives
73
+ * back to `imo-local`, and its test db to `imo-test`. Both are the DEVELOPER's
74
+ * databases, not the ticket's. Rejecting the ids at creation keeps the collision
75
+ * from ever existing; {@link isTicketScopedDb} is the second line of defence for
76
+ * environments created before this guard.
77
+ */
78
+ const RESERVED_TICKET_IDS = new Set(['ci', 'dev', 'e2e', 'local', 'prod', 'production', 'staging', 'test']);
52
79
  /**
53
80
  * Check whether a project's Playwright `global-setup` (if it wipes a DB) would
54
81
  * ACCEPT the per-ticket / per-shard test databases that `lt ticket` / `--shard`
@@ -77,6 +104,11 @@ function checkGlobalSetupTicketSafe(layout) {
77
104
  catch (_c) {
78
105
  return { file, hasDbReset: false, ticketSafe: true };
79
106
  }
107
+ // The API-style per-run scheme (db-lifecycle reporter) manages its own
108
+ // cleanup + naming — nothing for this Playwright-oriented check to judge.
109
+ if (content.includes('db-lifecycle.reporter')) {
110
+ return { file, hasDbReset: true, ticketSafe: true };
111
+ }
80
112
  const hasDbReset = /MONGO_URI|dropDatabase|emptyDatabase|deleteMany|dbNameFromUri/.test(content);
81
113
  if (!hasDbReset)
82
114
  return { file, hasDbReset: false, ticketSafe: true };
@@ -147,17 +179,63 @@ function deriveTicketId(name, asOverride) {
147
179
  return ticketMatch[1];
148
180
  return (0, dev_identity_1.slugify)(trimmed);
149
181
  }
150
- /** Drop a MongoDB database (best-effort, via `mongosh`). Returns true on success. */
151
- function dropDatabase(dbName, mongoBaseUri = 'mongodb://127.0.0.1:27017') {
182
+ /**
183
+ * Drop a MongoDB database via `mongosh`.
184
+ *
185
+ * SHAPE IS DELIBERATE — do NOT "optimise" it into a single multi-DB `--eval`:
186
+ * the database name travels in the URI PATH (percent-encoded, so it cannot
187
+ * escape into the host/query and hijack the connection), and `--eval` is a
188
+ * CONSTANT string, so the name is never interpolated into JavaScript. Together
189
+ * with `execFileSync`'s argv form (no shell), that is what makes an arbitrary
190
+ * db name un-injectable. Batching the drops would require building the eval
191
+ * from the names — trading that property away to save one process spawn.
192
+ *
193
+ * Returns WHY it failed, not just that it did: a missing `mongosh` binary and an
194
+ * unreachable Mongo need completely different fixes from the user, and collapsing
195
+ * both into `false` is how a "cleanup" feature ends up silently cleaning nothing.
196
+ */
197
+ function dropDatabase(dbName, mongoBaseUri = MONGO_BASE_URI, driverPaths = []) {
152
198
  try {
153
- (0, child_process_1.execFileSync)('mongosh', [`${mongoBaseUri}/${encodeURIComponent(dbName)}`, '--quiet', '--eval', 'db.dropDatabase()'], {
154
- stdio: 'ignore',
199
+ (0, child_process_1.execFileSync)('mongosh', [`${mongoBaseUri}/${encodeURIComponent(dbName)}`, '--quiet', '--eval', 'db.dropDatabase()'],
200
+ // Without a timeout this blocks for as long as mongosh feels like: a Mongo that
201
+ // accepts TCP but never answers leaves the driver's 30s server-selection default
202
+ // to expire — silently, since stdio is ignored. On a default teardown path that
203
+ // is unacceptable, so we impose our own bound.
204
+ { killSignal: 'SIGKILL', stdio: 'ignore', timeout: MONGOSH_TIMEOUT_MS });
205
+ return 'dropped';
206
+ }
207
+ catch (e) {
208
+ if (e.code !== 'ENOENT')
209
+ return 'unreachable';
210
+ // mongosh is not installed — a REAL machine state that silently disabled every DB
211
+ // drop for months (ticket DBs piled up because `lt ticket stop` only warned).
212
+ // Fall back to the PROJECT's own `mongodb` driver when the caller provides
213
+ // resolution paths (the API project always depends on it via nest-server).
214
+ const res = runWithProjectDriver(driverPaths, 'await c.db(process.env.LT_MONGO_DB).dropDatabase();', {
215
+ LT_MONGO_DB: dbName,
216
+ LT_MONGO_URI: mongoBaseUri,
155
217
  });
156
- return true;
218
+ if (res.outcome === 'no-driver')
219
+ return 'no-mongosh';
220
+ return res.outcome === 'ok' ? 'dropped' : 'unreachable';
157
221
  }
158
- catch (_a) {
159
- return false;
222
+ }
223
+ /**
224
+ * Drop several databases, stopping at the first failure.
225
+ *
226
+ * A failure is never per-database: if `mongosh` is missing it is missing for all of
227
+ * them, and if Mongo is unreachable it is unreachable for all of them. Retrying each
228
+ * name would just multiply the timeout (2 names × 10s of hanging, for one diagnosis).
229
+ */
230
+ function dropDatabases(dbNames, mongoBaseUri = MONGO_BASE_URI, driverPaths = []) {
231
+ const dropped = [];
232
+ for (const db of dbNames) {
233
+ const outcome = dropDatabase(db, mongoBaseUri, driverPaths);
234
+ if (outcome !== 'dropped')
235
+ return { dropped, reason: outcome };
236
+ dropped.push(db);
160
237
  }
238
+ return { dropped, reason: null };
161
239
  }
162
240
  /** True if a local branch with this name already exists. */
163
241
  function gitBranchExists(repoDir, branch) {
@@ -184,6 +262,18 @@ function gitMainRepoRoot(cwd) {
184
262
  const commonDir = git(cwd, ['rev-parse', '--path-format=absolute', '--git-common-dir']);
185
263
  return (0, path_1.dirname)(commonDir);
186
264
  }
265
+ /** True if `ref` resolves to a commit in the repo (any ref kind: local, remote, tag, sha). */
266
+ function gitRefExists(repoDir, ref) {
267
+ try {
268
+ (0, child_process_1.execFileSync)('git', ['-C', repoDir, 'rev-parse', '--verify', '--quiet', `${ref}^{commit}`], {
269
+ stdio: 'ignore',
270
+ });
271
+ return true;
272
+ }
273
+ catch (_a) {
274
+ return false;
275
+ }
276
+ }
187
277
  /**
188
278
  * Install dependencies in a freshly-created worktree. Auto-detects the
189
279
  * project's package manager from its lockfile (pnpm hard-links from the
@@ -194,6 +284,134 @@ function installWorktreeDeps(dir) {
194
284
  const pm = (0, dev_package_manager_1.pickPackageManager)(dir);
195
285
  (0, child_process_1.execFileSync)(pm.bin, pm.installArgs, { cwd: dir, stdio: 'inherit' });
196
286
  }
287
+ /**
288
+ * True for ticket ids whose derived database would collide with a project-level
289
+ * database (see {@link RESERVED_TICKET_IDS}). `lt ticket start` refuses them, so
290
+ * the collision cannot be created in the first place.
291
+ */
292
+ function isReservedTicketId(id) {
293
+ return RESERVED_TICKET_IDS.has(id.trim().toLowerCase());
294
+ }
295
+ /**
296
+ * True only when `dbName` provably belongs to ticket `ticketId` of the project whose
297
+ * own dev database is `projectDevDb`.
298
+ *
299
+ * This is the LAST gate before an irreversible drop, and it exists because the name
300
+ * being dropped is *derived* (or read from a slug-keyed global registry) rather than
301
+ * observed. Two things can therefore steer it at the wrong database:
302
+ *
303
+ * • a reserved ticket id (`local`/`dev`/`test`) derives back onto the project's own
304
+ * dev/test DB — the suffix check alone would happily accept that, because the name
305
+ * really does look ticket-shaped. Hence the explicit project-DB exclusion FIRST.
306
+ * • a registry entry under `<slug>-<id>` that in truth belongs to a different
307
+ * checkout (the registry is global and keyed by slug alone) carries THAT project's
308
+ * dbName — which will not match this ticket's shape and is rejected here.
309
+ *
310
+ * A derivation that drifts must fail closed: refuse, never guess.
311
+ */
312
+ function isTicketScopedDb(dbName, ticketId, projectDevDb) {
313
+ if (dbName === projectDevDb || dbName === (0, dev_project_1.deriveTestDbName)(projectDevDb))
314
+ return false;
315
+ const base = projectDevDb.replace(/-(local|dev)$/i, '');
316
+ if (dbName === `${base}-${ticketId}` || dbName === `${base}-${ticketId}-test`)
317
+ return true;
318
+ // Sharded Playwright stacks (`lt dev test --shard N`) derive one DB per shard:
319
+ // `<base>-<id>-test-<n>`. Without this arm they were invisible to the drop plan
320
+ // and orphaned on every sharded ticket test run.
321
+ return new RegExp(`^${escapeRegExpForDb(`${base}-${ticketId}-test-`)}\\d+$`).test(dbName);
322
+ }
323
+ /**
324
+ * Read the `--keep-db` opt-out. FAIL-CLOSED BY CONSTRUCTION.
325
+ *
326
+ * gluegun parses argv with yargs-parser and declares NO booleans, so the flag does not
327
+ * arrive as `true` in most of the spellings people actually type:
328
+ *
329
+ * --keep-db → true (boolean)
330
+ * --keep-db=true → 'true' (STRING)
331
+ * --keep-db true → 'true' (STRING)
332
+ * --keep-db 2200 → 2200 (NUMBER — and the ticket id is GONE from positionals)
333
+ * --no-keep-db → false (boolean)
334
+ *
335
+ * A strict `=== true` test reads three of those as "the user did not ask to keep" and
336
+ * destroys the very data they asked to keep. That shape is right for `--force`, where a
337
+ * parse quirk means "don't force" (safe); it is exactly backwards for a flag that
338
+ * PREVENTS destruction.
339
+ *
340
+ * So: the flag's PRESENCE means keep. Only an explicit negation still drops.
341
+ */
342
+ function keepDbFlag(options = {}) {
343
+ var _a;
344
+ const raw = (_a = options.keepDb) !== null && _a !== void 0 ? _a : options['keep-db'];
345
+ if (raw === undefined || raw === null)
346
+ return { keep: false, strayValue: null };
347
+ if (raw === false || raw === 'false' || raw === 0 || raw === '0')
348
+ return { keep: false, strayValue: null };
349
+ // The flag takes no value, so anything that is not an affirmation is a positional
350
+ // yargs-parser swallowed. Hand it back rather than silently losing it.
351
+ const affirmative = raw === true || ['1', 'true', 'yes'].includes(String(raw).toLowerCase());
352
+ return { keep: true, strayValue: affirmative ? null : String(raw) };
353
+ }
354
+ /**
355
+ * Branches offered when the user has to pick a base ref interactively (no
356
+ * candidate matched). Remote + local branches, most recently committed first,
357
+ * `origin/HEAD` filtered out (it is an alias, not a branch).
358
+ */
359
+ function listBaseRefChoices(repoDir, limit = 25) {
360
+ let out = '';
361
+ try {
362
+ out = git(repoDir, [
363
+ 'for-each-ref',
364
+ '--sort=-committerdate',
365
+ '--format=%(refname:short)',
366
+ 'refs/remotes',
367
+ 'refs/heads',
368
+ ]);
369
+ }
370
+ catch (_a) {
371
+ return [];
372
+ }
373
+ const refs = out
374
+ .split(/\r?\n/)
375
+ .map((l) => l.trim())
376
+ .filter((l) => l && !l.endsWith('/HEAD'));
377
+ return [...new Set(refs)].slice(0, limit);
378
+ }
379
+ /**
380
+ * List all database names on the local Mongo, or null when that is impossible
381
+ * (mongosh missing / Mongo unreachable).
382
+ *
383
+ * Used to OBSERVE what actually exists instead of deriving it: sharded test DBs
384
+ * (`<base>-<id>-test-<n>`) have an unbounded index, so no static candidate list
385
+ * can cover them. Callers must still gate every observed name through
386
+ * {@link isTicketScopedDb} before dropping — observation widens the candidate
387
+ * set, never the safety rules.
388
+ */
389
+ function listDatabaseNames(mongoBaseUri = MONGO_BASE_URI, driverPaths = []) {
390
+ try {
391
+ const out = (0, child_process_1.execFileSync)('mongosh', [
392
+ `${mongoBaseUri}/admin`,
393
+ '--quiet',
394
+ '--eval',
395
+ 'db.adminCommand({ listDatabases: 1, nameOnly: true }).databases.forEach(d => print(d.name))',
396
+ ], { killSignal: 'SIGKILL', stdio: ['ignore', 'pipe', 'ignore'], timeout: MONGOSH_TIMEOUT_MS })
397
+ .toString()
398
+ .split('\n')
399
+ .map((line) => line.trim())
400
+ .filter(Boolean);
401
+ return out;
402
+ }
403
+ catch (_a) {
404
+ // Fall back to the project's own `mongodb` driver (see dropDatabase).
405
+ const res = runWithProjectDriver(driverPaths, "const { databases } = await c.db('admin').admin().listDatabases({ nameOnly: true });" +
406
+ ' databases.forEach((d) => console.log(d.name));', { LT_MONGO_URI: mongoBaseUri });
407
+ if (res.outcome !== 'ok')
408
+ return null;
409
+ return res.stdout
410
+ .split('\n')
411
+ .map((line) => line.trim())
412
+ .filter(Boolean);
413
+ }
414
+ }
197
415
  /** List all worktrees of the repo (parsed from `git worktree list --porcelain`). */
198
416
  function listWorktrees(repoDir) {
199
417
  let out = '';
@@ -219,6 +437,35 @@ function listWorktrees(repoDir) {
219
437
  result.push(finalizeWorktree(current));
220
438
  return result;
221
439
  }
440
+ /**
441
+ * Decide which databases `lt ticket stop` may drop — pure, so the decision guarding an
442
+ * irreversible action is unit-testable instead of buried in a gluegun `run()` closure.
443
+ *
444
+ * The name to drop is never observed; it is DERIVED, or read from the global registry.
445
+ * Both sources can point at a database the ticket never owned, so every candidate is
446
+ * validated against the ticket's shape and anything that does not match is refused:
447
+ *
448
+ * • App-only project → no MongoDB exists at all → nothing to drop.
449
+ * • Foreign registry → the registry is keyed by slug alone, so `<slug>-<id>` can be
450
+ * a genuinely different project (`myapp` + ticket `admin` vs. a
451
+ * real `myapp-admin`). Its `dbName` is that project's — ignore it.
452
+ * • Reserved ticket id → `local`/`dev`/`test` derive back onto the PROJECT's own dev or
453
+ * test DB. Refused by {@link isTicketScopedDb}.
454
+ */
455
+ function planTicketDbDrop(args) {
456
+ var _a, _b;
457
+ const { hasApi, observedDbNames, projectDevDb, registryEntry, ticketId, worktreePath } = args;
458
+ if (!hasApi)
459
+ return { foreignEntryPath: null, refused: [], targets: [] };
460
+ const trusted = (registryEntry === null || registryEntry === void 0 ? void 0 : registryEntry.path) && (0, dev_state_1.sameRealPath)(registryEntry.path, worktreePath) ? registryEntry : undefined;
461
+ const foreignEntryPath = registryEntry && !trusted ? ((_a = registryEntry.path) !== null && _a !== void 0 ? _a : null) : null;
462
+ const devDb = (_b = trusted === null || trusted === void 0 ? void 0 : trusted.dbName) !== null && _b !== void 0 ? _b : (0, dev_project_1.deriveTicketDbName)(projectDevDb, ticketId);
463
+ const shardCandidates = (observedDbNames !== null && observedDbNames !== void 0 ? observedDbNames : []).filter((name) => name.startsWith(`${devDb}-test-`));
464
+ // eslint-disable-next-line perfectionist/sort-sets -- dev DB first is the meaningful (and drop) order
465
+ const candidates = [...new Set([devDb, (0, dev_project_1.deriveTestDbName)(devDb), ...shardCandidates])];
466
+ const targets = candidates.filter((db) => isTicketScopedDb(db, ticketId, projectDevDb));
467
+ return { foreignEntryPath, refused: candidates.filter((db) => !targets.includes(db)), targets };
468
+ }
222
469
  /** Read the ticket id this worktree is tagged with, or null. */
223
470
  function readTicketMarker(root) {
224
471
  const file = (0, path_1.join)(root, dev_state_1.paths.sessionDir, TICKET_MARKER);
@@ -232,6 +479,37 @@ function readTicketMarker(root) {
232
479
  return null;
233
480
  }
234
481
  }
482
+ /**
483
+ * Resolve the ref a fresh ticket branch is created from.
484
+ *
485
+ * An explicit `--base` always wins (and is reported as missing when it does not
486
+ * resolve). Otherwise the repo's base branch is DISCOVERED, because it is not
487
+ * called the same everywhere — `nest-server` uses `develop`, the lt starters use
488
+ * `dev`, GitHub defaults to `main`:
489
+ *
490
+ * origin/dev → origin/develop → the remote's HEAD → origin/main → origin/master
491
+ * → the same names as LOCAL branches (repo without a remote)
492
+ *
493
+ * `ref: null` means none of them exists — the caller then asks the user
494
+ * (`lt ticket start`) instead of failing on a hard-coded `origin/dev`.
495
+ */
496
+ function resolveBaseRef(repoDir, explicit) {
497
+ var _a;
498
+ const wanted = explicit === null || explicit === void 0 ? void 0 : explicit.trim();
499
+ if (wanted) {
500
+ return {
501
+ candidates: [wanted],
502
+ explicit: true,
503
+ ref: gitRefExists(repoDir, wanted) ? wanted : null,
504
+ };
505
+ }
506
+ const candidates = baseRefCandidates(repoDir);
507
+ return {
508
+ candidates,
509
+ explicit: false,
510
+ ref: (_a = candidates.find((ref) => gitRefExists(repoDir, ref))) !== null && _a !== void 0 ? _a : null,
511
+ };
512
+ }
235
513
  /**
236
514
  * Resolve the dev identity + DB name for a project root, ticket-aware.
237
515
  *
@@ -268,6 +546,47 @@ function worktreeAdd(repoDir, worktreePath, branch, baseRef) {
268
546
  git(repoDir, ['worktree', 'add', '-b', branch, worktreePath, baseRef]);
269
547
  }
270
548
  }
549
+ /** Escape a literal database-name fragment for use inside a RegExp. */
550
+ function escapeRegExpForDb(value) {
551
+ return value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
552
+ }
553
+ /**
554
+ * Run a CONSTANT driver snippet via `node -e` using the project's own `mongodb`
555
+ * package (nest-server APIs always depend on it). SAME injection-safety doctrine
556
+ * as the mongosh path: the eval string is constant, every variable travels via
557
+ * env — never interpolated into code. `action` receives the connected client as
558
+ * `c` and must be a constant string from THIS module, never caller input.
559
+ */
560
+ function runWithProjectDriver(driverPaths, action, env) {
561
+ let driver = null;
562
+ for (const dir of driverPaths.filter(Boolean)) {
563
+ try {
564
+ driver = require.resolve('mongodb', { paths: [dir] });
565
+ break;
566
+ }
567
+ catch (_a) {
568
+ /* try next path */
569
+ }
570
+ }
571
+ if (!driver)
572
+ return { outcome: 'no-driver', stdout: '' };
573
+ const script = 'const { MongoClient } = require(process.env.LT_MONGO_DRIVER);' +
574
+ 'MongoClient.connect(process.env.LT_MONGO_URI, { serverSelectionTimeoutMS: 8000 })' +
575
+ `.then(async (c) => { ${action} await c.close(); process.exit(0); })` +
576
+ '.catch(() => process.exit(2));';
577
+ try {
578
+ const stdout = (0, child_process_1.execFileSync)(process.execPath, ['-e', script], {
579
+ env: Object.assign(Object.assign(Object.assign({}, process.env), env), { LT_MONGO_DRIVER: driver }),
580
+ killSignal: 'SIGKILL',
581
+ stdio: ['ignore', 'pipe', 'ignore'],
582
+ timeout: MONGOSH_TIMEOUT_MS,
583
+ }).toString();
584
+ return { outcome: 'ok', stdout };
585
+ }
586
+ catch (_b) {
587
+ return { outcome: 'failed', stdout: '' };
588
+ }
589
+ }
271
590
  /** Framework-generated / ephemeral paths a dev/build run dirties (never real work). */
272
591
  const GENERATED_PATHS = /(^|\/)(\.nuxtrc|\.nuxt|\.nitro|\.output|dist|\.turbo|\.cache|\.eslintcache)(\/|$)|\.tsbuildinfo$/;
273
592
  /** The three git-tracked configs `lt dev up` self-heals to be env-aware. */
@@ -323,7 +642,13 @@ function worktreeSafetyReport(worktreePath) {
323
642
  const { realDirty } = classifyWorktreeDirt(worktreePath);
324
643
  let unpushed = 0;
325
644
  try {
326
- unpushed = Number(git(worktreePath, ['rev-list', '--count', 'HEAD', '--not', '--remotes'])) || 0;
645
+ // Only meaningful when a remote EXISTS: in a local-only repo (e.g. a fresh
646
+ // `lt fullstack init` without --git-link) "push first" is impossible, and the
647
+ // branch survives worktree removal anyway — counting every commit as
648
+ // "unpushed" would turn the gate into a permanent dead end.
649
+ if (git(worktreePath, ['remote'])) {
650
+ unpushed = Number(git(worktreePath, ['rev-list', '--count', 'HEAD', '--not', '--remotes'])) || 0;
651
+ }
327
652
  }
328
653
  catch (_a) {
329
654
  /* no remotes / detached HEAD → cannot determine; treat as 0 */
@@ -336,6 +661,26 @@ function writeTicketMarker(root, id) {
336
661
  (0, fs_1.mkdirSync)(dir, { recursive: true });
337
662
  (0, fs_1.writeFileSync)((0, path_1.join)(dir, TICKET_MARKER), `${id}\n`, 'utf8');
338
663
  }
664
+ /** Branch names a project may use as its integration branch, in preference order. */
665
+ const BASE_BRANCH_NAMES = ['dev', 'develop', 'main', 'master'];
666
+ /**
667
+ * The refs {@link resolveBaseRef} probes, in order. Remote branches first (a
668
+ * ticket must start from the freshest integration state), the remote's own HEAD
669
+ * ahead of the guessed `main`/`master`, and the local branches last so a repo
670
+ * without a remote still works.
671
+ */
672
+ function baseRefCandidates(repoDir) {
673
+ const remoteHead = gitRemoteHead(repoDir);
674
+ const ordered = [
675
+ 'origin/dev',
676
+ 'origin/develop',
677
+ ...(remoteHead ? [remoteHead] : []),
678
+ 'origin/main',
679
+ 'origin/master',
680
+ ...BASE_BRANCH_NAMES,
681
+ ];
682
+ return ordered.filter((ref, i) => ordered.indexOf(ref) === i); // dedupe, order preserved
683
+ }
339
684
  /**
340
685
  * Split a worktree's uncommitted changes into "auto-discardable" (framework-
341
686
  * generated files + pristine lt-dev self-heal patches) and "real" developer work.
@@ -347,10 +692,14 @@ function classifyWorktreeDirt(worktreePath) {
347
692
  const realDirty = [];
348
693
  for (const line of gitStatusPorcelain(worktreePath)) {
349
694
  const p = porcelainPath(line);
350
- if (GENERATED_PATHS.test(p) || isPristineLtDevPatch(worktreePath, p))
695
+ if (GENERATED_PATHS.test(p) ||
696
+ isPristineLtDevPatch(worktreePath, p) ||
697
+ isPristineLtDevGitignoreAppend(worktreePath, p)) {
351
698
  autoDiscardable.push(p);
352
- else
699
+ }
700
+ else {
353
701
  realDirty.push(p);
702
+ }
354
703
  }
355
704
  return { autoDiscardable, realDirty };
356
705
  }
@@ -363,6 +712,23 @@ function finalizeWorktree(partial) {
363
712
  function git(cwd, args) {
364
713
  return (0, child_process_1.execFileSync)('git', args, { cwd, encoding: 'utf8' }).trim();
365
714
  }
715
+ /**
716
+ * The branch a remote's HEAD points at (`origin/main`, `origin/develop`, …), or
717
+ * null. stderr is swallowed: a repo whose `refs/remotes/<remote>/HEAD` was never
718
+ * set is the normal case here, not an error worth printing.
719
+ */
720
+ function gitRemoteHead(repoDir, remote = 'origin') {
721
+ try {
722
+ const out = (0, child_process_1.execFileSync)('git', ['-C', repoDir, 'symbolic-ref', '--short', `refs/remotes/${remote}/HEAD`], {
723
+ encoding: 'utf8',
724
+ stdio: ['ignore', 'pipe', 'ignore'],
725
+ });
726
+ return out.trim() || null;
727
+ }
728
+ catch (_a) {
729
+ return null;
730
+ }
731
+ }
366
732
  /**
367
733
  * `git status --porcelain` lines, each kept VERBATIM (not trimmed).
368
734
  *
@@ -374,13 +740,53 @@ function git(cwd, args) {
374
740
  function gitStatusPorcelain(cwd) {
375
741
  let out = '';
376
742
  try {
377
- out = (0, child_process_1.execFileSync)('git', ['-C', cwd, 'status', '--porcelain', '--untracked-files=all'], { encoding: 'utf8' });
743
+ out = (0, child_process_1.execFileSync)('git', ['-C', cwd, 'status', '--porcelain', '--untracked-files=all'], {
744
+ encoding: 'utf8',
745
+ });
378
746
  }
379
747
  catch (_a) {
380
748
  return [];
381
749
  }
382
750
  return out.split(/\r?\n/).filter((l) => l.trim() !== '');
383
751
  }
752
+ /**
753
+ * True when the dirty `.gitignore` differs from HEAD by EXACTLY the `.lt-dev/`
754
+ * line that `lt dev up` appends on every start (see `addToGitignore`). Older
755
+ * templates ship without that line, so EVERY ticket worktree's first `up` dirties
756
+ * `.gitignore` — and without this check every `lt ticket stop` then refuses over
757
+ * a machine-made change. Any other edit (removed lines, additional added lines)
758
+ * keeps it real work.
759
+ */
760
+ function isPristineLtDevGitignoreAppend(worktreePath, relPath) {
761
+ if (relPath !== '.gitignore')
762
+ return false;
763
+ let head = '';
764
+ try {
765
+ head = (0, child_process_1.execFileSync)('git', ['-C', worktreePath, 'show', 'HEAD:.gitignore'], {
766
+ encoding: 'utf8',
767
+ stdio: ['ignore', 'pipe', 'ignore'],
768
+ });
769
+ }
770
+ catch (_a) {
771
+ return false; // not tracked in HEAD → a NEW .gitignore is real work
772
+ }
773
+ let work = '';
774
+ try {
775
+ work = (0, fs_1.readFileSync)((0, path_1.join)(worktreePath, '.gitignore'), 'utf8');
776
+ }
777
+ catch (_b) {
778
+ return false; // deleted → real change
779
+ }
780
+ const norm = (s) => s
781
+ .split(/\r?\n/)
782
+ .map((l) => l.trim())
783
+ .filter(Boolean);
784
+ const headLines = norm(head);
785
+ const workLines = norm(work);
786
+ const removed = headLines.filter((l) => !workLines.includes(l));
787
+ const added = workLines.filter((l) => !headLines.includes(l));
788
+ return removed.length === 0 && added.length > 0 && added.every((l) => l === '.lt-dev/');
789
+ }
384
790
  /**
385
791
  * True when the dirty tracked file at `relPath` (relative to the worktree root)
386
792
  * differs from its committed (HEAD) version by EXACTLY the lt-dev self-heal
@@ -398,7 +804,9 @@ function isPristineLtDevPatch(worktreePath, relPath) {
398
804
  try {
399
805
  // NOT the trimming `git()` helper — the trailing newline must survive so the
400
806
  // comparison against the (untrimmed) working-tree content is exact.
401
- head = (0, child_process_1.execFileSync)('git', ['-C', worktreePath, 'show', `HEAD:${relPath}`], { encoding: 'utf8' });
807
+ head = (0, child_process_1.execFileSync)('git', ['-C', worktreePath, 'show', `HEAD:${relPath}`], {
808
+ encoding: 'utf8',
809
+ });
402
810
  }
403
811
  catch (_a) {
404
812
  return false; // not tracked at HEAD (e.g. a brand-new file) → never auto-discard
@@ -1,6 +1,7 @@
1
1
  "use strict";
2
2
  Object.defineProperty(exports, "__esModule", { value: true });
3
3
  exports.healCheckWrapper = healCheckWrapper;
4
+ const child_process_1 = require("child_process");
4
5
  const fs_1 = require("fs");
5
6
  const path_1 = require("path");
6
7
  /** Marker value for the report-driven check wrapper. */
@@ -39,12 +40,22 @@ function healCheckWrapper(projectRoot, assetPath) {
39
40
  return changed;
40
41
  }
41
42
  // 1. Ensure scripts/check.mjs exists and matches the bundled canonical version.
43
+ // GUARD: never overwrite a check.mjs with UNCOMMITTED local modifications —
44
+ // that would silently destroy work that exists nowhere else. A committed
45
+ // divergence is overwritten (recoverable via `git diff`/history, and the
46
+ // canonical wrapper is the supported version); an uncommitted one is kept and
47
+ // reported via the 'scripts/check.mjs (skipped: uncommitted changes)' entry.
42
48
  const targetScript = (0, path_1.join)(projectRoot, 'scripts', 'check.mjs');
43
49
  const bundled = (0, fs_1.readFileSync)(assetPath, 'utf8');
44
50
  if (!(0, fs_1.existsSync)(targetScript) || (0, fs_1.readFileSync)(targetScript, 'utf8') !== bundled) {
45
- (0, fs_1.mkdirSync)((0, path_1.dirname)(targetScript), { recursive: true });
46
- (0, fs_1.copyFileSync)(assetPath, targetScript);
47
- changed.push('scripts/check.mjs');
51
+ if ((0, fs_1.existsSync)(targetScript) && hasUncommittedChanges(projectRoot, 'scripts/check.mjs')) {
52
+ changed.push('scripts/check.mjs (skipped: uncommitted changes — commit or discard them, then re-run)');
53
+ }
54
+ else {
55
+ (0, fs_1.mkdirSync)((0, path_1.dirname)(targetScript), { recursive: true });
56
+ (0, fs_1.copyFileSync)(assetPath, targetScript);
57
+ changed.push('scripts/check.mjs');
58
+ }
48
59
  }
49
60
  // 2. Wire package.json: `check` runs the wrapper; the original chain becomes `check:raw`.
50
61
  if (scripts.check !== WRAPPER) {
@@ -57,3 +68,22 @@ function healCheckWrapper(projectRoot, assetPath) {
57
68
  }
58
69
  return changed;
59
70
  }
71
+ /**
72
+ * True when `relPath` has UNCOMMITTED modifications in the project's git tree.
73
+ * Overwriting such a file would destroy work that exists nowhere else — a
74
+ * committed file is recoverable via git, an uncommitted edit is not. Non-git
75
+ * projects (or git errors) return false: there the overwrite is the only way
76
+ * to distribute fixes, and `git` cannot protect what it does not track.
77
+ */
78
+ function hasUncommittedChanges(projectRoot, relPath) {
79
+ try {
80
+ const out = (0, child_process_1.execFileSync)('git', ['-C', projectRoot, 'status', '--porcelain', '--', relPath], {
81
+ encoding: 'utf8',
82
+ stdio: ['ignore', 'pipe', 'ignore'],
83
+ });
84
+ return out.trim().length > 0;
85
+ }
86
+ catch (_a) {
87
+ return false;
88
+ }
89
+ }