@lenne.tech/cli 1.35.1 → 1.36.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.
@@ -15,13 +15,16 @@ const dev_process_1 = require("../../lib/dev-process");
15
15
  const dev_project_1 = require("../../lib/dev-project");
16
16
  const dev_state_1 = require("../../lib/dev-state");
17
17
  const dev_ticket_1 = require("../../lib/dev-ticket");
18
+ const workspace_integration_1 = require("../../lib/workspace-integration");
18
19
  /**
19
20
  * `lt ticket start <name>` — spin up a fully-isolated parallel dev environment
20
21
  * for a ticket or feature, in seconds:
21
22
  *
22
- * 1. `git fetch` + create a git WORKTREE on a fresh branch from `origin/dev`
23
- * (so every ticket starts independent from the latest dev) at a sibling
24
- * folder `<parent>/<slug>-<id>`,
23
+ * 1. `git fetch` + create a git WORKTREE on a fresh branch from the repo's base
24
+ * branch (`origin/dev` `origin/develop` remote HEAD …, or `--base`;
25
+ * the user is asked when none of them exists) so every ticket starts
26
+ * independent from the latest integration state, at a sibling folder
27
+ * `<parent>/<slug>-<id>`,
25
28
  * 2. tag it with a `.lt-dev/ticket` marker (makes every `lt dev *` in it
26
29
  * ticket-aware),
27
30
  * 3. `pnpm install` (hard-links from the shared store → fast),
@@ -63,7 +66,6 @@ const StartCommand = {
63
66
  const base = (0, dev_identity_1.buildIdentity)(mainRepoRoot);
64
67
  const id = (0, dev_ticket_1.deriveTicketId)(name, parameters.options.as != null ? String(parameters.options.as) : undefined);
65
68
  const branch = typeof parameters.options.branch === 'string' ? parameters.options.branch : (0, dev_ticket_1.defaultTicketBranch)(name);
66
- const baseRef = typeof parameters.options.base === 'string' ? parameters.options.base : 'origin/dev';
67
69
  const ticketIdentity = (0, dev_identity_1.buildTicketIdentity)(base, id);
68
70
  const dbName = (0, dev_project_1.deriveTicketDbName)((0, dev_project_1.deriveDbName)(layout.apiDir, base.slug), id);
69
71
  const worktreePath = (0, dev_ticket_1.worktreePathFor)(mainRepoRoot, base.slug, id);
@@ -80,14 +82,55 @@ const StartCommand = {
80
82
  }
81
83
  info('');
82
84
  info(colors.bold(`Starting ticket "${id}" → ${ticketIdentity.slug}`));
83
- info(colors.dim(` branch: ${branch} (from ${baseRef})`));
85
+ info(colors.dim(` branch: ${branch}`));
84
86
  info(colors.dim(` worktree: ${worktreePath}`));
85
87
  info('');
86
- // 1. fetch + worktree (fresh branch from origin/dev independent).
88
+ // 1. fetch, so the base ref below is resolved against the freshest remote
89
+ // state. Best-effort: offline / auth-less repos still get their worktree
90
+ // from whatever refs exist locally.
91
+ info(colors.dim('Fetching …'));
87
92
  try {
88
- info(colors.dim(`Fetching + creating worktree from ${baseRef} …`));
89
93
  (0, dev_ticket_1.gitFetch)(mainRepoRoot);
90
- (0, dev_ticket_1.worktreeAdd)(mainRepoRoot, worktreePath, branch, baseRef);
94
+ }
95
+ catch (e) {
96
+ warning(`git fetch failed (${e.message.split('\n')[0]}) — continuing with the local refs.`);
97
+ }
98
+ // 2. resolve the ref the fresh branch starts from. Only needed for a NEW
99
+ // branch — an existing one is simply checked out into the worktree.
100
+ let baseRef = null;
101
+ if (!(0, dev_ticket_1.gitBranchExists)(mainRepoRoot, branch)) {
102
+ const explicit = typeof parameters.options.base === 'string' ? parameters.options.base : undefined;
103
+ const resolution = (0, dev_ticket_1.resolveBaseRef)(mainRepoRoot, explicit);
104
+ baseRef = resolution.ref;
105
+ if (!baseRef) {
106
+ // No base branch found (repos differ: `dev`, `develop`, `main`, …) — ask
107
+ // the user rather than failing on a guess. Non-interactive callers (CI /
108
+ // AI agents) must pass `--base` instead.
109
+ if (resolution.explicit) {
110
+ error(`Base ref "${explicit}" does not exist in this repository.`);
111
+ }
112
+ else {
113
+ error(`No base branch found — tried: ${resolution.candidates.join(', ')}`);
114
+ }
115
+ if ((0, workspace_integration_1.isNonInteractive)(Boolean(parameters.options.noConfirm))) {
116
+ info(colors.dim(` Pass one explicitly: lt ticket start ${name} --base <ref>`));
117
+ if (!parameters.options.fromGluegunMenu)
118
+ process.exit(1);
119
+ return 'ticket start: no base ref';
120
+ }
121
+ baseRef = yield askForBaseRef(toolbox, mainRepoRoot);
122
+ if (!baseRef) {
123
+ if (!parameters.options.fromGluegunMenu)
124
+ process.exit(1);
125
+ return 'ticket start: no base ref';
126
+ }
127
+ }
128
+ info(colors.dim(` base: ${baseRef}`));
129
+ }
130
+ // 3. worktree (fresh branch from the base ref → independent).
131
+ try {
132
+ info(colors.dim(`Creating worktree${baseRef ? ` from ${baseRef}` : ` on existing branch ${branch}`} …`));
133
+ (0, dev_ticket_1.worktreeAdd)(mainRepoRoot, worktreePath, branch, baseRef !== null && baseRef !== void 0 ? baseRef : '');
91
134
  }
92
135
  catch (e) {
93
136
  error(`git worktree setup failed: ${e.message}`);
@@ -95,9 +138,9 @@ const StartCommand = {
95
138
  process.exit(1);
96
139
  return 'ticket start: worktree failed';
97
140
  }
98
- // 2. tag the worktree with its ticket id (makes lt dev * ticket-aware).
141
+ // 4. tag the worktree with its ticket id (makes lt dev * ticket-aware).
99
142
  (0, dev_ticket_1.writeTicketMarker)(worktreePath, id);
100
- // 3. install deps. Auto-detects pnpm/yarn/npm from the worktree's
143
+ // 5. install deps. Auto-detects pnpm/yarn/npm from the worktree's
101
144
  // lockfile so an npm-only repo doesn't get a foreign pnpm-lock
102
145
  // injected on every ticket start.
103
146
  if (parameters.options.install !== false) {
@@ -109,7 +152,7 @@ const StartCommand = {
109
152
  warning(`install failed (${e.message}) — continuing; run it manually in the worktree.`);
110
153
  }
111
154
  }
112
- // 4. bring the isolated stack up (re-invokes THIS lt build so the marker is
155
+ // 6. bring the isolated stack up (re-invokes THIS lt build so the marker is
113
156
  // honoured even before a global recompile). `--no-up` just scaffolds.
114
157
  if (parameters.options.up !== false) {
115
158
  info('');
@@ -140,4 +183,47 @@ const StartCommand = {
140
183
  return `ticket start: ${id}`;
141
184
  }),
142
185
  };
186
+ /**
187
+ * Ask which ref the ticket branch should start from — the last resort when no
188
+ * base branch was found (repos name theirs differently: `dev`, `develop`,
189
+ * `main`, …). Offers the repo's branches (most recently committed first) plus a
190
+ * free-text escape hatch, and re-asks until the ref actually resolves, so a typo
191
+ * fails here instead of inside `git worktree add`.
192
+ */
193
+ function askForBaseRef(toolbox, repoDir) {
194
+ return __awaiter(this, void 0, void 0, function* () {
195
+ var _a;
196
+ const { print: { colors, info, warning }, prompt, } = toolbox;
197
+ const MANUAL = 'Other — type a ref …';
198
+ const choices = (0, dev_ticket_1.listBaseRefChoices)(repoDir);
199
+ for (let attempt = 0; attempt < 3; attempt++) {
200
+ let ref;
201
+ if (choices.length) {
202
+ const answer = yield prompt.ask({
203
+ choices: [...choices, MANUAL],
204
+ message: 'Which branch should this ticket start from?',
205
+ name: 'ref',
206
+ type: 'select',
207
+ });
208
+ ref = answer.ref;
209
+ }
210
+ // No branches at all (fresh repo), or the user picked the escape hatch.
211
+ if (!choices.length || ref === MANUAL) {
212
+ const answer = yield prompt.ask({
213
+ message: 'Base ref (branch, tag or commit):',
214
+ name: 'ref',
215
+ type: 'input',
216
+ });
217
+ ref = (_a = answer.ref) === null || _a === void 0 ? void 0 : _a.trim();
218
+ }
219
+ if (!ref)
220
+ return null; // aborted / empty
221
+ if ((0, dev_ticket_1.gitRefExists)(repoDir, ref))
222
+ return ref;
223
+ warning(`"${ref}" does not resolve to a commit — pick another one.`);
224
+ }
225
+ info(colors.dim(' No usable base ref — aborting.'));
226
+ return null;
227
+ });
228
+ }
143
229
  module.exports = StartCommand;
@@ -8,9 +8,12 @@ exports.dropDatabase = dropDatabase;
8
8
  exports.gitBranchExists = gitBranchExists;
9
9
  exports.gitFetch = gitFetch;
10
10
  exports.gitMainRepoRoot = gitMainRepoRoot;
11
+ exports.gitRefExists = gitRefExists;
11
12
  exports.installWorktreeDeps = installWorktreeDeps;
13
+ exports.listBaseRefChoices = listBaseRefChoices;
12
14
  exports.listWorktrees = listWorktrees;
13
15
  exports.readTicketMarker = readTicketMarker;
16
+ exports.resolveBaseRef = resolveBaseRef;
14
17
  exports.resolveDevIdentity = resolveDevIdentity;
15
18
  exports.worktreeAdd = worktreeAdd;
16
19
  exports.worktreeDirtyOnlyAutoDiscardable = worktreeDirtyOnlyAutoDiscardable;
@@ -24,8 +27,9 @@ exports.writeTicketMarker = writeTicketMarker;
24
27
  * command group).
25
28
  *
26
29
  * 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:
30
+ * its own branch (created fresh from the repo's base branch — see
31
+ * {@link resolveBaseRef} so tickets are independent), each running its own
32
+ * `lt dev` stack on a SUFFIXED identity:
29
33
  *
30
34
  * ticket "DEV-2200" → id "2200" → svl-2200.localhost / api.svl-2200.localhost
31
35
  * worktree <parent>/svl-2200/ branch feat/DEV-2200
@@ -184,6 +188,16 @@ function gitMainRepoRoot(cwd) {
184
188
  const commonDir = git(cwd, ['rev-parse', '--path-format=absolute', '--git-common-dir']);
185
189
  return (0, path_1.dirname)(commonDir);
186
190
  }
191
+ /** True if `ref` resolves to a commit in the repo (any ref kind: local, remote, tag, sha). */
192
+ function gitRefExists(repoDir, ref) {
193
+ try {
194
+ (0, child_process_1.execFileSync)('git', ['-C', repoDir, 'rev-parse', '--verify', '--quiet', `${ref}^{commit}`], { stdio: 'ignore' });
195
+ return true;
196
+ }
197
+ catch (_a) {
198
+ return false;
199
+ }
200
+ }
187
201
  /**
188
202
  * Install dependencies in a freshly-created worktree. Auto-detects the
189
203
  * project's package manager from its lockfile (pnpm hard-links from the
@@ -194,6 +208,31 @@ function installWorktreeDeps(dir) {
194
208
  const pm = (0, dev_package_manager_1.pickPackageManager)(dir);
195
209
  (0, child_process_1.execFileSync)(pm.bin, pm.installArgs, { cwd: dir, stdio: 'inherit' });
196
210
  }
211
+ /**
212
+ * Branches offered when the user has to pick a base ref interactively (no
213
+ * candidate matched). Remote + local branches, most recently committed first,
214
+ * `origin/HEAD` filtered out (it is an alias, not a branch).
215
+ */
216
+ function listBaseRefChoices(repoDir, limit = 25) {
217
+ let out = '';
218
+ try {
219
+ out = git(repoDir, [
220
+ 'for-each-ref',
221
+ '--sort=-committerdate',
222
+ '--format=%(refname:short)',
223
+ 'refs/remotes',
224
+ 'refs/heads',
225
+ ]);
226
+ }
227
+ catch (_a) {
228
+ return [];
229
+ }
230
+ const refs = out
231
+ .split(/\r?\n/)
232
+ .map((l) => l.trim())
233
+ .filter((l) => l && !l.endsWith('/HEAD'));
234
+ return [...new Set(refs)].slice(0, limit);
235
+ }
197
236
  /** List all worktrees of the repo (parsed from `git worktree list --porcelain`). */
198
237
  function listWorktrees(repoDir) {
199
238
  let out = '';
@@ -232,6 +271,29 @@ function readTicketMarker(root) {
232
271
  return null;
233
272
  }
234
273
  }
274
+ /**
275
+ * Resolve the ref a fresh ticket branch is created from.
276
+ *
277
+ * An explicit `--base` always wins (and is reported as missing when it does not
278
+ * resolve). Otherwise the repo's base branch is DISCOVERED, because it is not
279
+ * called the same everywhere — `nest-server` uses `develop`, the lt starters use
280
+ * `dev`, GitHub defaults to `main`:
281
+ *
282
+ * origin/dev → origin/develop → the remote's HEAD → origin/main → origin/master
283
+ * → the same names as LOCAL branches (repo without a remote)
284
+ *
285
+ * `ref: null` means none of them exists — the caller then asks the user
286
+ * (`lt ticket start`) instead of failing on a hard-coded `origin/dev`.
287
+ */
288
+ function resolveBaseRef(repoDir, explicit) {
289
+ var _a;
290
+ const wanted = explicit === null || explicit === void 0 ? void 0 : explicit.trim();
291
+ if (wanted) {
292
+ return { candidates: [wanted], explicit: true, ref: gitRefExists(repoDir, wanted) ? wanted : null };
293
+ }
294
+ const candidates = baseRefCandidates(repoDir);
295
+ return { candidates, explicit: false, ref: (_a = candidates.find((ref) => gitRefExists(repoDir, ref))) !== null && _a !== void 0 ? _a : null };
296
+ }
235
297
  /**
236
298
  * Resolve the dev identity + DB name for a project root, ticket-aware.
237
299
  *
@@ -336,6 +398,26 @@ function writeTicketMarker(root, id) {
336
398
  (0, fs_1.mkdirSync)(dir, { recursive: true });
337
399
  (0, fs_1.writeFileSync)((0, path_1.join)(dir, TICKET_MARKER), `${id}\n`, 'utf8');
338
400
  }
401
+ /** Branch names a project may use as its integration branch, in preference order. */
402
+ const BASE_BRANCH_NAMES = ['dev', 'develop', 'main', 'master'];
403
+ /**
404
+ * The refs {@link resolveBaseRef} probes, in order. Remote branches first (a
405
+ * ticket must start from the freshest integration state), the remote's own HEAD
406
+ * ahead of the guessed `main`/`master`, and the local branches last so a repo
407
+ * without a remote still works.
408
+ */
409
+ function baseRefCandidates(repoDir) {
410
+ const remoteHead = gitRemoteHead(repoDir);
411
+ const ordered = [
412
+ 'origin/dev',
413
+ 'origin/develop',
414
+ ...(remoteHead ? [remoteHead] : []),
415
+ 'origin/main',
416
+ 'origin/master',
417
+ ...BASE_BRANCH_NAMES,
418
+ ];
419
+ return ordered.filter((ref, i) => ordered.indexOf(ref) === i); // dedupe, order preserved
420
+ }
339
421
  /**
340
422
  * Split a worktree's uncommitted changes into "auto-discardable" (framework-
341
423
  * generated files + pristine lt-dev self-heal patches) and "real" developer work.
@@ -363,6 +445,23 @@ function finalizeWorktree(partial) {
363
445
  function git(cwd, args) {
364
446
  return (0, child_process_1.execFileSync)('git', args, { cwd, encoding: 'utf8' }).trim();
365
447
  }
448
+ /**
449
+ * The branch a remote's HEAD points at (`origin/main`, `origin/develop`, …), or
450
+ * null. stderr is swallowed: a repo whose `refs/remotes/<remote>/HEAD` was never
451
+ * set is the normal case here, not an error worth printing.
452
+ */
453
+ function gitRemoteHead(repoDir, remote = 'origin') {
454
+ try {
455
+ const out = (0, child_process_1.execFileSync)('git', ['-C', repoDir, 'symbolic-ref', '--short', `refs/remotes/${remote}/HEAD`], {
456
+ encoding: 'utf8',
457
+ stdio: ['ignore', 'pipe', 'ignore'],
458
+ });
459
+ return out.trim() || null;
460
+ }
461
+ catch (_a) {
462
+ return null;
463
+ }
464
+ }
366
465
  /**
367
466
  * `git status --porcelain` lines, each kept VERBATIM (not trimmed).
368
467
  *
@@ -316,7 +316,7 @@
316
316
  <div class="page">
317
317
  <span class="eyebrow">Baustein 2</span>
318
318
  <h2><code style="font-size:.8em">lt ticket</code> — mehrere Tickets gleichzeitig</h2>
319
- <p class="lead">Ein Repo, beliebig viele Tickets — jedes in einem eigenen <b>git&nbsp;worktree</b> mit eigenem <code>lt dev</code>-Stack. Frisch aus <code>origin/dev</code>, in Sekunden startklar, voneinander komplett unabhängig.</p>
319
+ <p class="lead">Ein Repo, beliebig viele Tickets — jedes in einem eigenen <b>git&nbsp;worktree</b> mit eigenem <code>lt dev</code>-Stack. Frisch aus dem Basis-Branch (<code>origin/dev</code>, <code>origin/develop</code>, …), in Sekunden startklar, voneinander komplett unabhängig.</p>
320
320
 
321
321
  <figure>
322
322
  <!-- DIAGRAM B: one repo -> N worktrees -> N stacks -->
@@ -375,7 +375,7 @@
375
375
  </figure>
376
376
 
377
377
  <h3>So einfach geht's</h3>
378
- <pre><span class="k">lt ticket start</span> <span class="a">DEV-2200</span> <span class="c"># fetch + worktree aus origin/dev + install + lt dev up</span>
378
+ <pre><span class="k">lt ticket start</span> <span class="a">DEV-2200</span> <span class="c"># fetch + worktree aus dem Basis-Branch + install + lt dev up</span>
379
379
  <span class="c">→ <span class="s">https://svl-2200.localhost</span> · DB svl-…-2200 (leer)</span>
380
380
  <span class="k">lt ticket start</span> <span class="a">login-fix</span> <span class="c"># kein Ticket? freier Name → svl-login-fix.localhost</span>
381
381
  <span class="k">lt ticket start</span> <span class="a">DEV-2200 --as cof</span> <span class="c"># Kurzname überschreiben; --branch / --base ebenso</span>
@@ -391,7 +391,7 @@
391
391
  <p class="small">Jedes Ticket bekommt <code>&lt;slug&gt;-&lt;id&gt;</code> in URL, DB, Ports, Caddy-Block, Ordnername. Verwechslung ausgeschlossen.</p></div>
392
392
  <div class="card"><div class="ico" style="background:linear-gradient(135deg,#06b6d4,#0e7490)">🌱</div>
393
393
  <h3>Immer frisch aus dev</h3>
394
- <p class="small">Jeder Start macht <code>git fetch</code> und zweigt von <code>origin/dev</code> ab alle Tickets unabhängig. <code>--base</code> für Ausnahmen.</p></div>
394
+ <p class="small">Jeder Start macht <code>git fetch</code> und zweigt vom Basis-Branch ab — gesucht wird <code>origin/dev</code>, dann <code>origin/develop</code>, dann der Remote-HEAD (<code>main</code>/<code>master</code>). Findet sich keiner, fragt die CLI nach dem Branch. <code>--base</code> setzt ihn direkt (Pflicht im nicht-interaktiven Betrieb).</p></div>
395
395
  <div class="card"><div class="ico" style="background:linear-gradient(135deg,#10b981,#047857)">⚡</div>
396
396
  <h3>0&nbsp;s &amp; leicht</h3>
397
397
  <p class="small">Worktree teilt das <code>.git</code> — kein Re-Clone. Ein <code>git fetch</code> aktualisiert alle. pnpm hardlinkt aus dem Store.</p></div>
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@lenne.tech/cli",
3
- "version": "1.35.1",
3
+ "version": "1.36.0",
4
4
  "description": "lenne.Tech CLI: lt",
5
5
  "keywords": [
6
6
  "lenne.Tech",