@ogpoyraz/wtx 0.7.0 → 0.8.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/dist/cli.mjs CHANGED
@@ -23014,6 +23014,20 @@ async function getLatestCommit(repoPath, ref) {
23014
23014
  subject: stdout.substring(splitIdx + 1)
23015
23015
  };
23016
23016
  }
23017
+ async function resolveCommitSha(repoPath, ref, opts = { verbose: false, dryRun: false }) {
23018
+ try {
23019
+ const stdout = await gitExec(["-C", repoPath, "rev-parse", "--verify", "--end-of-options", `${ref}^{commit}`], opts);
23020
+ const sha = stdout.trim();
23021
+ if (!sha) {
23022
+ throw new Error("Git returned an empty commit id");
23023
+ }
23024
+ return sha;
23025
+ } catch (err) {
23026
+ const message = err instanceof Error ? err.message : String(err);
23027
+ throw new Error(`Ref '${ref}' does not resolve to a commit: ${message.split(`
23028
+ `)[0]}`);
23029
+ }
23030
+ }
23017
23031
  async function getDirtyFiles(worktreePath) {
23018
23032
  const stdout = await gitExec(["-C", worktreePath, "status", "--porcelain"]);
23019
23033
  return stdout.split(`
@@ -23131,19 +23145,195 @@ var init_git = __esm(() => {
23131
23145
  init_log();
23132
23146
  });
23133
23147
 
23134
- // src/lib/resolver.ts
23148
+ // src/lib/stack.ts
23135
23149
  import fs7 from "fs";
23136
23150
  import path15 from "path";
23151
+ function emptyMetadata() {
23152
+ return { version: 1, branches: {} };
23153
+ }
23154
+ function isRecord(value) {
23155
+ return typeof value === "object" && value !== null && !Array.isArray(value);
23156
+ }
23157
+ function parseMetadata(value) {
23158
+ if (!isRecord(value) || value.version !== 1 || !isRecord(value.branches)) {
23159
+ throw new Error("invalid format or version");
23160
+ }
23161
+ const branches = {};
23162
+ for (const [branch, rawEntry] of Object.entries(value.branches)) {
23163
+ if (!isRecord(rawEntry))
23164
+ continue;
23165
+ if (typeof rawEntry.baseRef !== "string" || typeof rawEntry.baseSha !== "string")
23166
+ continue;
23167
+ branches[branch] = {
23168
+ baseRef: rawEntry.baseRef,
23169
+ baseSha: rawEntry.baseSha,
23170
+ explicit: rawEntry.explicit === true,
23171
+ createdAt: typeof rawEntry.createdAt === "string" ? rawEntry.createdAt : ""
23172
+ };
23173
+ }
23174
+ return { version: 1, branches };
23175
+ }
23176
+ async function metadataPath(repoPath, opts) {
23177
+ const commonDirOutput = await gitExec(["-C", repoPath, "rev-parse", "--git-common-dir"], { ...opts, dryRun: false });
23178
+ const commonDir = commonDirOutput.trim();
23179
+ if (!commonDir)
23180
+ return null;
23181
+ const resolvedCommonDir = path15.isAbsolute(commonDir) ? commonDir : path15.resolve(repoPath, commonDir);
23182
+ return path15.join(resolvedCommonDir, "wtx", "stack.json");
23183
+ }
23184
+ async function readStackMetadata(repoPath, opts = { verbose: false, dryRun: false }) {
23185
+ const filePath = await metadataPath(repoPath, opts);
23186
+ if (!filePath || !fs7.existsSync(filePath))
23187
+ return emptyMetadata();
23188
+ try {
23189
+ const raw = JSON.parse(fs7.readFileSync(filePath, "utf8"));
23190
+ return parseMetadata(raw);
23191
+ } catch (err) {
23192
+ const message = err instanceof Error ? err.message : String(err);
23193
+ throw new Error(`Failed to read stack metadata: ${message}`);
23194
+ }
23195
+ }
23196
+ async function writeStackMetadata(repoPath, metadata, opts) {
23197
+ if (opts.dryRun)
23198
+ return;
23199
+ const filePath = await metadataPath(repoPath, opts);
23200
+ if (!filePath)
23201
+ return;
23202
+ const dir = path15.dirname(filePath);
23203
+ fs7.mkdirSync(dir, { recursive: true });
23204
+ const tempPath = `${filePath}.${process.pid}.tmp`;
23205
+ fs7.writeFileSync(tempPath, `${JSON.stringify(metadata, null, 2)}
23206
+ `, "utf8");
23207
+ fs7.renameSync(tempPath, filePath);
23208
+ }
23209
+ async function recordStackEntry(repoPath, branch, entry, opts) {
23210
+ const metadata = await readStackMetadata(repoPath, opts);
23211
+ metadata.branches[branch] = entry;
23212
+ await writeStackMetadata(repoPath, metadata, opts);
23213
+ }
23214
+ async function removeStackEntry(repoPath, branch, opts) {
23215
+ const metadata = await readStackMetadata(repoPath, opts);
23216
+ if (!metadata.branches[branch])
23217
+ return;
23218
+ delete metadata.branches[branch];
23219
+ await writeStackMetadata(repoPath, metadata, opts);
23220
+ }
23221
+ async function renameStackEntry(repoPath, oldBranch, newBranch, opts) {
23222
+ const metadata = await readStackMetadata(repoPath, opts);
23223
+ const entry = metadata.branches[oldBranch];
23224
+ let changed = false;
23225
+ if (entry) {
23226
+ metadata.branches[newBranch] = entry;
23227
+ delete metadata.branches[oldBranch];
23228
+ changed = true;
23229
+ }
23230
+ for (const child of Object.values(metadata.branches)) {
23231
+ if (child.baseRef !== oldBranch && child.baseRef !== `refs/heads/${oldBranch}`)
23232
+ continue;
23233
+ child.baseRef = newBranch;
23234
+ changed = true;
23235
+ }
23236
+ if (changed)
23237
+ await writeStackMetadata(repoPath, metadata, opts);
23238
+ }
23239
+ function getStackChildren(metadata, branch) {
23240
+ return Object.entries(metadata.branches).filter(([, entry]) => entry.baseRef === branch || entry.baseRef === `refs/heads/${branch}`).map(([child]) => child).sort((a2, b) => a2.localeCompare(b));
23241
+ }
23242
+ function getStackAncestors(metadata, branch) {
23243
+ const ancestors = [branch];
23244
+ const seen = new Set([branch]);
23245
+ let current = branch;
23246
+ while (true) {
23247
+ const entry = metadata.branches[current];
23248
+ if (!entry || seen.has(entry.baseRef))
23249
+ break;
23250
+ ancestors.unshift(entry.baseRef);
23251
+ seen.add(entry.baseRef);
23252
+ current = entry.baseRef;
23253
+ }
23254
+ return ancestors;
23255
+ }
23256
+ function resolveParentBranch(baseRef, branches) {
23257
+ if (!baseRef)
23258
+ return null;
23259
+ if (branches.has(baseRef))
23260
+ return baseRef;
23261
+ const headRef = baseRef.replace(/^refs\/heads\//, "");
23262
+ if (branches.has(headRef))
23263
+ return headRef;
23264
+ const remoteRef = baseRef.replace(/^refs\/remotes\/[^/]+\//, "");
23265
+ if (branches.has(remoteRef))
23266
+ return remoteRef;
23267
+ const slash = baseRef.indexOf("/");
23268
+ if (slash > 0) {
23269
+ const shortRef = baseRef.substring(slash + 1);
23270
+ if (branches.has(shortRef))
23271
+ return shortRef;
23272
+ }
23273
+ return null;
23274
+ }
23275
+ function buildStackHierarchy(items, getBranch, getBase, compare) {
23276
+ const branchNames = new Set(items.map(getBranch).filter((branch) => branch !== undefined));
23277
+ const byBranch = new Map;
23278
+ const children = new Map;
23279
+ const roots = [];
23280
+ for (const item of items) {
23281
+ const branch = getBranch(item);
23282
+ if (branch)
23283
+ byBranch.set(branch, item);
23284
+ }
23285
+ for (const item of items) {
23286
+ const branch = getBranch(item);
23287
+ const parent = resolveParentBranch(getBase(item), branchNames);
23288
+ if (!parent || parent === branch || !byBranch.has(parent)) {
23289
+ roots.push(item);
23290
+ continue;
23291
+ }
23292
+ const siblings = children.get(parent) ?? [];
23293
+ siblings.push(item);
23294
+ children.set(parent, siblings);
23295
+ }
23296
+ const result = [];
23297
+ const visited = new Set;
23298
+ const visit = (item, depth, ancestorPrefix, isLast) => {
23299
+ const branch = getBranch(item);
23300
+ if (branch && visited.has(branch))
23301
+ return;
23302
+ if (branch)
23303
+ visited.add(branch);
23304
+ const prefix = depth === 0 ? "" : `${ancestorPrefix}${isLast ? "└─ " : "├─ "}`;
23305
+ result.push({ item, depth, prefix });
23306
+ const childRows = [...branch ? children.get(branch) ?? [] : []].sort(compare);
23307
+ const childPrefix = depth === 0 ? "" : `${ancestorPrefix}${isLast ? " " : "│ "}`;
23308
+ childRows.forEach((child, index) => {
23309
+ visit(child, depth + 1, childPrefix, index === childRows.length - 1);
23310
+ });
23311
+ };
23312
+ roots.sort(compare).forEach((root) => visit(root, 0, "", true));
23313
+ for (const item of items) {
23314
+ const branch = getBranch(item);
23315
+ if (!branch || !visited.has(branch))
23316
+ visit(item, 0, "", true);
23317
+ }
23318
+ return result;
23319
+ }
23320
+ var init_stack = __esm(() => {
23321
+ init_git();
23322
+ });
23323
+
23324
+ // src/lib/resolver.ts
23325
+ import fs8 from "fs";
23326
+ import path16 from "path";
23137
23327
  function detectRepoFromCwd(config2) {
23138
23328
  const cwd = process.cwd();
23139
23329
  const root = expandTilde(config2.root);
23140
23330
  for (const name of Object.keys(config2.repos)) {
23141
- const mainPath = path15.join(root, name);
23142
- const wtRoot = path15.join(root, `${name}${config2.postfix}`);
23143
- if (cwd === mainPath || cwd.startsWith(mainPath + path15.sep)) {
23331
+ const mainPath = path16.join(root, name);
23332
+ const wtRoot = path16.join(root, `${name}${config2.postfix}`);
23333
+ if (cwd === mainPath || cwd.startsWith(mainPath + path16.sep)) {
23144
23334
  return name;
23145
23335
  }
23146
- if (cwd === wtRoot || cwd.startsWith(wtRoot + path15.sep)) {
23336
+ if (cwd === wtRoot || cwd.startsWith(wtRoot + path16.sep)) {
23147
23337
  return name;
23148
23338
  }
23149
23339
  }
@@ -23173,11 +23363,11 @@ function resolveRepos(config2, repoFilter) {
23173
23363
  return targetRepos.map((name) => {
23174
23364
  const mainPath = `${expandTilde(config2.root)}/${name}`;
23175
23365
  const wtRoot = `${expandTilde(config2.root)}/${name}${config2.postfix}`;
23176
- if (!fs7.existsSync(mainPath)) {
23366
+ if (!fs8.existsSync(mainPath)) {
23177
23367
  throw new Error(`Repo directory not found at ${mainPath}`);
23178
23368
  }
23179
- const gitDir = path15.join(mainPath, ".git");
23180
- if (!fs7.existsSync(gitDir)) {
23369
+ const gitDir = path16.join(mainPath, ".git");
23370
+ if (!fs8.existsSync(gitDir)) {
23181
23371
  throw new Error(`Not a git repository: ${mainPath}`);
23182
23372
  }
23183
23373
  return {
@@ -23240,14 +23430,14 @@ function expandTemplate(template, vars) {
23240
23430
  }
23241
23431
 
23242
23432
  // src/lib/deps/workspaces.ts
23243
- import fs10 from "fs";
23244
- import path17 from "path";
23433
+ import fs11 from "fs";
23434
+ import path18 from "path";
23245
23435
  function getWorkspaceDirs(rootDir) {
23246
23436
  const dirs = new Set;
23247
- const pkgPath = path17.join(rootDir, "package.json");
23248
- if (fs10.existsSync(pkgPath)) {
23437
+ const pkgPath = path18.join(rootDir, "package.json");
23438
+ if (fs11.existsSync(pkgPath)) {
23249
23439
  try {
23250
- const pkg = JSON.parse(fs10.readFileSync(pkgPath, "utf-8"));
23440
+ const pkg = JSON.parse(fs11.readFileSync(pkgPath, "utf-8"));
23251
23441
  if (pkg.workspaces) {
23252
23442
  let ws = [];
23253
23443
  if (Array.isArray(pkg.workspaces)) {
@@ -23261,10 +23451,10 @@ function getWorkspaceDirs(rootDir) {
23261
23451
  }
23262
23452
  } catch {}
23263
23453
  }
23264
- const pnpmPath = path17.join(rootDir, "pnpm-workspace.yaml");
23265
- if (fs10.existsSync(pnpmPath)) {
23454
+ const pnpmPath = path18.join(rootDir, "pnpm-workspace.yaml");
23455
+ if (fs11.existsSync(pnpmPath)) {
23266
23456
  try {
23267
- const content = fs10.readFileSync(pnpmPath, "utf-8");
23457
+ const content = fs11.readFileSync(pnpmPath, "utf-8");
23268
23458
  const lines = content.split(/\r?\n/);
23269
23459
  let inPackages = false;
23270
23460
  for (let line of lines) {
@@ -23295,8 +23485,8 @@ function resolvePattern(rootDir, pattern, out) {
23295
23485
  const normalized = pattern.replace(/\/+$/, "");
23296
23486
  const hasGlob = normalized.includes("*");
23297
23487
  if (!hasGlob) {
23298
- const fullDir = path17.join(rootDir, normalized);
23299
- if (fs10.existsSync(fullDir) && fs10.statSync(fullDir).isDirectory()) {
23488
+ const fullDir = path18.join(rootDir, normalized);
23489
+ if (fs11.existsSync(fullDir) && fs11.statSync(fullDir).isDirectory()) {
23300
23490
  out.add(normalized);
23301
23491
  }
23302
23492
  return;
@@ -23311,12 +23501,12 @@ function globToRegex(pattern) {
23311
23501
  function collectMatchingDirs(base2, rel, regex, out, depth) {
23312
23502
  if (depth > 8)
23313
23503
  return;
23314
- if (rel && regex.test(rel) && fs10.existsSync(path17.join(base2, rel, "package.json"))) {
23504
+ if (rel && regex.test(rel) && fs11.existsSync(path18.join(base2, rel, "package.json"))) {
23315
23505
  out.add(rel);
23316
23506
  }
23317
23507
  let entries;
23318
23508
  try {
23319
- entries = fs10.readdirSync(rel ? path17.join(base2, rel) : base2, { withFileTypes: true });
23509
+ entries = fs11.readdirSync(rel ? path18.join(base2, rel) : base2, { withFileTypes: true });
23320
23510
  } catch {
23321
23511
  return;
23322
23512
  }
@@ -23324,7 +23514,7 @@ function collectMatchingDirs(base2, rel, regex, out, depth) {
23324
23514
  if (!entry.isDirectory() || entry.name === "node_modules" || entry.name.startsWith(".")) {
23325
23515
  continue;
23326
23516
  }
23327
- const nextRel = rel ? path17.posix.join(rel, entry.name) : entry.name;
23517
+ const nextRel = rel ? path18.posix.join(rel, entry.name) : entry.name;
23328
23518
  if (!regex.test(nextRel) && !couldMatchDeeper(regex.source, nextRel))
23329
23519
  continue;
23330
23520
  collectMatchingDirs(base2, nextRel, regex, out, depth + 1);
@@ -23336,21 +23526,21 @@ function couldMatchDeeper(regexSource, rel) {
23336
23526
  var init_workspaces = () => {};
23337
23527
 
23338
23528
  // src/lib/deps/diff.ts
23339
- import fs11 from "fs";
23340
- import path18 from "path";
23529
+ import fs12 from "fs";
23530
+ import path19 from "path";
23341
23531
  function filesMatch(wtPath, mainPath, fileNames) {
23342
23532
  for (const name of fileNames) {
23343
- const wtFile = path18.join(wtPath, name);
23344
- const mainFile = path18.join(mainPath, name);
23345
- const wtExists = fs11.existsSync(wtFile);
23346
- const mainExists = fs11.existsSync(mainFile);
23533
+ const wtFile = path19.join(wtPath, name);
23534
+ const mainFile = path19.join(mainPath, name);
23535
+ const wtExists = fs12.existsSync(wtFile);
23536
+ const mainExists = fs12.existsSync(mainFile);
23347
23537
  if (wtExists !== mainExists)
23348
23538
  return false;
23349
23539
  if (!wtExists)
23350
23540
  continue;
23351
23541
  try {
23352
- const wtContent = fs11.readFileSync(wtFile);
23353
- const mainContent = fs11.readFileSync(mainFile);
23542
+ const wtContent = fs12.readFileSync(wtFile);
23543
+ const mainContent = fs12.readFileSync(mainFile);
23354
23544
  if (!wtContent.equals(mainContent))
23355
23545
  return false;
23356
23546
  } catch {
@@ -23368,7 +23558,7 @@ function getWorkspaceDelta(wtPath, mainPath, lockfileNames) {
23368
23558
  const changedWorkspaces = [];
23369
23559
  for (const ws of allWorkspaces) {
23370
23560
  const wsFiles = ["package.json"];
23371
- if (!filesMatch(path18.join(wtPath, ws), path18.join(mainPath, ws), wsFiles)) {
23561
+ if (!filesMatch(path19.join(wtPath, ws), path19.join(mainPath, ws), wsFiles)) {
23372
23562
  changedWorkspaces.push(ws);
23373
23563
  }
23374
23564
  }
@@ -23379,8 +23569,8 @@ var init_diff = __esm(() => {
23379
23569
  });
23380
23570
 
23381
23571
  // src/lib/deps/adapters/bun.ts
23382
- import fs12 from "fs";
23383
- import path19 from "path";
23572
+ import fs13 from "fs";
23573
+ import path20 from "path";
23384
23574
  var bunAdapter;
23385
23575
  var init_bun = __esm(() => {
23386
23576
  init_diff();
@@ -23388,7 +23578,7 @@ var init_bun = __esm(() => {
23388
23578
  bunAdapter = {
23389
23579
  id: "bun",
23390
23580
  displayName: "bun",
23391
- detect: (dir) => fs12.existsSync(path19.join(dir, "bun.lockb")) || fs12.existsSync(path19.join(dir, "bun.lock")),
23581
+ detect: (dir) => fs13.existsSync(path20.join(dir, "bun.lockb")) || fs13.existsSync(path20.join(dir, "bun.lock")),
23392
23582
  lockfileNames: ["bun.lockb", "bun.lock"],
23393
23583
  definitionsMatch: (wtPath, mainPath) => {
23394
23584
  const delta = getWorkspaceDelta(wtPath, mainPath, ["bun.lockb", "bun.lock"]);
@@ -23416,8 +23606,8 @@ var init_bun = __esm(() => {
23416
23606
  });
23417
23607
 
23418
23608
  // src/lib/deps/adapters/pnpm.ts
23419
- import fs13 from "fs";
23420
- import path20 from "path";
23609
+ import fs14 from "fs";
23610
+ import path21 from "path";
23421
23611
  var pnpmAdapter;
23422
23612
  var init_pnpm = __esm(() => {
23423
23613
  init_diff();
@@ -23425,7 +23615,7 @@ var init_pnpm = __esm(() => {
23425
23615
  pnpmAdapter = {
23426
23616
  id: "pnpm",
23427
23617
  displayName: "pnpm",
23428
- detect: (dir) => fs13.existsSync(path20.join(dir, "pnpm-lock.yaml")),
23618
+ detect: (dir) => fs14.existsSync(path21.join(dir, "pnpm-lock.yaml")),
23429
23619
  lockfileNames: ["pnpm-lock.yaml"],
23430
23620
  definitionsMatch: (wtPath, mainPath) => {
23431
23621
  const delta = getWorkspaceDelta(wtPath, mainPath, ["pnpm-lock.yaml"]);
@@ -23453,8 +23643,8 @@ var init_pnpm = __esm(() => {
23453
23643
  });
23454
23644
 
23455
23645
  // src/lib/deps/adapters/yarn.ts
23456
- import fs14 from "fs";
23457
- import path21 from "path";
23646
+ import fs15 from "fs";
23647
+ import path22 from "path";
23458
23648
  var yarnAdapter;
23459
23649
  var init_yarn = __esm(() => {
23460
23650
  init_diff();
@@ -23462,7 +23652,7 @@ var init_yarn = __esm(() => {
23462
23652
  yarnAdapter = {
23463
23653
  id: "yarn",
23464
23654
  displayName: "yarn",
23465
- detect: (dir) => fs14.existsSync(path21.join(dir, "yarn.lock")),
23655
+ detect: (dir) => fs15.existsSync(path22.join(dir, "yarn.lock")),
23466
23656
  lockfileNames: ["yarn.lock"],
23467
23657
  definitionsMatch: (wtPath, mainPath) => {
23468
23658
  const delta = getWorkspaceDelta(wtPath, mainPath, ["yarn.lock"]);
@@ -23484,8 +23674,8 @@ var init_yarn = __esm(() => {
23484
23674
  });
23485
23675
 
23486
23676
  // src/lib/deps/adapters/npm.ts
23487
- import fs15 from "fs";
23488
- import path22 from "path";
23677
+ import fs16 from "fs";
23678
+ import path23 from "path";
23489
23679
  var npmAdapter;
23490
23680
  var init_npm = __esm(() => {
23491
23681
  init_diff();
@@ -23493,7 +23683,7 @@ var init_npm = __esm(() => {
23493
23683
  npmAdapter = {
23494
23684
  id: "npm",
23495
23685
  displayName: "npm",
23496
- detect: (dir) => fs15.existsSync(path22.join(dir, "package-lock.json")),
23686
+ detect: (dir) => fs16.existsSync(path23.join(dir, "package-lock.json")),
23497
23687
  lockfileNames: ["package-lock.json"],
23498
23688
  definitionsMatch: (wtPath, mainPath) => {
23499
23689
  const delta = getWorkspaceDelta(wtPath, mainPath, ["package-lock.json"]);
@@ -23850,47 +24040,47 @@ var init_registry = __esm(() => {
23850
24040
  });
23851
24041
 
23852
24042
  // src/lib/deps/linking.ts
23853
- import fs16 from "fs";
23854
- import path23 from "path";
24043
+ import fs17 from "fs";
24044
+ import path24 from "path";
23855
24045
  function performSafeLink(wtPath, mainPath, dryRun, quiet) {
23856
- const wtNm = path23.join(wtPath, "node_modules");
23857
- const mainNm = path23.join(mainPath, "node_modules");
23858
- if (!fs16.existsSync(mainNm)) {
24046
+ const wtNm = path24.join(wtPath, "node_modules");
24047
+ const mainNm = path24.join(mainPath, "node_modules");
24048
+ if (!fs17.existsSync(mainNm)) {
23859
24049
  return;
23860
24050
  }
23861
24051
  let existingLink = false;
23862
24052
  try {
23863
- const stat = fs16.lstatSync(wtNm);
24053
+ const stat = fs17.lstatSync(wtNm);
23864
24054
  existingLink = stat.isSymbolicLink();
23865
24055
  } catch {}
23866
24056
  if (existingLink) {
23867
24057
  if (!quiet)
23868
24058
  stepProgress("Removing whole-directory symlink to prepare for safe linking...");
23869
24059
  if (!dryRun) {
23870
- fs16.unlinkSync(wtNm);
24060
+ fs17.unlinkSync(wtNm);
23871
24061
  }
23872
24062
  }
23873
- if (!dryRun && !fs16.existsSync(wtNm)) {
23874
- fs16.mkdirSync(wtNm, { recursive: true });
24063
+ if (!dryRun && !fs17.existsSync(wtNm)) {
24064
+ fs17.mkdirSync(wtNm, { recursive: true });
23875
24065
  }
23876
24066
  const entriesToLink = [];
23877
24067
  try {
23878
- const mainEntries = fs16.readdirSync(mainNm, { withFileTypes: true });
24068
+ const mainEntries = fs17.readdirSync(mainNm, { withFileTypes: true });
23879
24069
  for (const entry of mainEntries) {
23880
24070
  if (entry.name === ".bin") {
23881
24071
  entriesToLink.push({
23882
24072
  name: ".bin",
23883
- target: path23.join(mainNm, ".bin"),
24073
+ target: path24.join(mainNm, ".bin"),
23884
24074
  isBin: true
23885
24075
  });
23886
24076
  } else if (entry.name.startsWith("@") && entry.isDirectory()) {
23887
- const scopePath = path23.join(mainNm, entry.name);
24077
+ const scopePath = path24.join(mainNm, entry.name);
23888
24078
  try {
23889
- const scopedEntries = fs16.readdirSync(scopePath, { withFileTypes: true });
24079
+ const scopedEntries = fs17.readdirSync(scopePath, { withFileTypes: true });
23890
24080
  for (const scopedEntry of scopedEntries) {
23891
24081
  entriesToLink.push({
23892
- name: path23.join(entry.name, scopedEntry.name),
23893
- target: path23.join(scopePath, scopedEntry.name),
24082
+ name: path24.join(entry.name, scopedEntry.name),
24083
+ target: path24.join(scopePath, scopedEntry.name),
23894
24084
  isBin: false
23895
24085
  });
23896
24086
  }
@@ -23898,7 +24088,7 @@ function performSafeLink(wtPath, mainPath, dryRun, quiet) {
23898
24088
  } else {
23899
24089
  entriesToLink.push({
23900
24090
  name: entry.name,
23901
- target: path23.join(mainNm, entry.name),
24091
+ target: path24.join(mainNm, entry.name),
23902
24092
  isBin: false
23903
24093
  });
23904
24094
  }
@@ -23908,7 +24098,7 @@ function performSafeLink(wtPath, mainPath, dryRun, quiet) {
23908
24098
  let failedCount = 0;
23909
24099
  const failedNames = [];
23910
24100
  for (const { name, target, isBin } of entriesToLink) {
23911
- const linkPath = path23.join(wtNm, name);
24101
+ const linkPath = path24.join(wtNm, name);
23912
24102
  if (dryRun) {
23913
24103
  if (!quiet)
23914
24104
  info(` [dry-run] Would link ${name}`);
@@ -23916,41 +24106,41 @@ function performSafeLink(wtPath, mainPath, dryRun, quiet) {
23916
24106
  continue;
23917
24107
  }
23918
24108
  try {
23919
- const parentDir = path23.dirname(linkPath);
23920
- if (!fs16.existsSync(parentDir)) {
23921
- fs16.mkdirSync(parentDir, { recursive: true });
24109
+ const parentDir = path24.dirname(linkPath);
24110
+ if (!fs17.existsSync(parentDir)) {
24111
+ fs17.mkdirSync(parentDir, { recursive: true });
23922
24112
  }
23923
24113
  let shouldLink = true;
23924
24114
  let backupPath = null;
23925
24115
  try {
23926
- const stat = fs16.lstatSync(linkPath);
24116
+ const stat = fs17.lstatSync(linkPath);
23927
24117
  if (stat.isSymbolicLink()) {
23928
- const existingTarget = fs16.readlinkSync(linkPath);
23929
- const resolvedExisting = path23.resolve(path23.dirname(linkPath), existingTarget);
24118
+ const existingTarget = fs17.readlinkSync(linkPath);
24119
+ const resolvedExisting = path24.resolve(path24.dirname(linkPath), existingTarget);
23930
24120
  if (resolvedExisting === target) {
23931
24121
  shouldLink = false;
23932
24122
  } else {
23933
- fs16.unlinkSync(linkPath);
24123
+ fs17.unlinkSync(linkPath);
23934
24124
  }
23935
24125
  } else {
23936
24126
  backupPath = `${linkPath}.wtx-old`;
23937
- fs16.renameSync(linkPath, backupPath);
24127
+ fs17.renameSync(linkPath, backupPath);
23938
24128
  }
23939
24129
  } catch {}
23940
24130
  if (shouldLink) {
23941
24131
  try {
23942
- const relTarget = path23.relative(path23.dirname(linkPath), target);
23943
- fs16.symlinkSync(relTarget, linkPath, isBin ? "dir" : fs16.statSync(target).isDirectory() ? "dir" : "file");
24132
+ const relTarget = path24.relative(path24.dirname(linkPath), target);
24133
+ fs17.symlinkSync(relTarget, linkPath, isBin ? "dir" : fs17.statSync(target).isDirectory() ? "dir" : "file");
23944
24134
  createdCount++;
23945
24135
  } catch (err) {
23946
24136
  if (backupPath) {
23947
- fs16.renameSync(backupPath, linkPath);
24137
+ fs17.renameSync(backupPath, linkPath);
23948
24138
  }
23949
24139
  throw err;
23950
24140
  }
23951
24141
  }
23952
- if (backupPath && fs16.existsSync(backupPath)) {
23953
- fs16.rmSync(backupPath, { recursive: true, force: true });
24142
+ if (backupPath && fs17.existsSync(backupPath)) {
24143
+ fs17.rmSync(backupPath, { recursive: true, force: true });
23954
24144
  }
23955
24145
  } catch {
23956
24146
  failedCount++;
@@ -23969,15 +24159,15 @@ var init_linking = __esm(() => {
23969
24159
  });
23970
24160
 
23971
24161
  // src/lib/deps/engine.ts
23972
- import fs17 from "fs";
23973
- import path24 from "path";
24162
+ import fs18 from "fs";
24163
+ import path25 from "path";
23974
24164
  function resolveAdapter2(dir, managerOverride) {
23975
24165
  return resolveAdapter(dir, managerOverride);
23976
24166
  }
23977
24167
  function performLegacySymlink(wtPath, mainPath, dryRun, quiet) {
23978
- const nmPath = path24.join(wtPath, "node_modules");
23979
- const mainNm = path24.join(mainPath, "node_modules");
23980
- if (!fs17.existsSync(mainNm)) {
24168
+ const nmPath = path25.join(wtPath, "node_modules");
24169
+ const mainNm = path25.join(mainPath, "node_modules");
24170
+ if (!fs18.existsSync(mainNm)) {
23981
24171
  if (!quiet)
23982
24172
  stepWarning("Main repo has no node_modules to symlink to");
23983
24173
  return;
@@ -23987,7 +24177,7 @@ function performLegacySymlink(wtPath, mainPath, dryRun, quiet) {
23987
24177
  let existingLink = false;
23988
24178
  let shouldRemove = false;
23989
24179
  try {
23990
- const stat = fs17.lstatSync(nmPath);
24180
+ const stat = fs18.lstatSync(nmPath);
23991
24181
  existingLink = stat.isSymbolicLink();
23992
24182
  shouldRemove = true;
23993
24183
  } catch {}
@@ -24001,9 +24191,9 @@ function performLegacySymlink(wtPath, mainPath, dryRun, quiet) {
24001
24191
  }
24002
24192
  if (!dryRun) {
24003
24193
  if (existingLink) {
24004
- fs17.unlinkSync(nmPath);
24194
+ fs18.unlinkSync(nmPath);
24005
24195
  } else {
24006
- fs17.rmSync(nmPath, { recursive: true, force: true });
24196
+ fs18.rmSync(nmPath, { recursive: true, force: true });
24007
24197
  }
24008
24198
  }
24009
24199
  }
@@ -24011,22 +24201,22 @@ function performLegacySymlink(wtPath, mainPath, dryRun, quiet) {
24011
24201
  if (!quiet)
24012
24202
  info(` [dry-run] Would symlink ${mainNm} to ${nmPath}`);
24013
24203
  } else {
24014
- fs17.symlinkSync(mainNm, nmPath);
24204
+ fs18.symlinkSync(mainNm, nmPath);
24015
24205
  }
24016
24206
  if (!quiet)
24017
24207
  stepSuccess("Symlinked node_modules", mainNm);
24018
24208
  }
24019
24209
  function detectCommonLinkageState(wtPath, mainPath) {
24020
- const nodeModulesPath = path24.join(wtPath, "node_modules");
24210
+ const nodeModulesPath = path25.join(wtPath, "node_modules");
24021
24211
  try {
24022
- const stat = fs17.lstatSync(nodeModulesPath);
24212
+ const stat = fs18.lstatSync(nodeModulesPath);
24023
24213
  if (stat.isSymbolicLink()) {
24024
- const target = fs17.readlinkSync(nodeModulesPath);
24025
- const resolvedTarget = path24.resolve(wtPath, target);
24214
+ const target = fs18.readlinkSync(nodeModulesPath);
24215
+ const resolvedTarget = path25.resolve(wtPath, target);
24026
24216
  const resolvedMain = safeResolve(mainPath);
24027
24217
  let targetExists = false;
24028
24218
  try {
24029
- fs17.statSync(resolvedTarget);
24219
+ fs18.statSync(resolvedTarget);
24030
24220
  targetExists = true;
24031
24221
  } catch {
24032
24222
  targetExists = false;
@@ -24034,14 +24224,14 @@ function detectCommonLinkageState(wtPath, mainPath) {
24034
24224
  if (!targetExists) {
24035
24225
  return { state: "broken", target };
24036
24226
  }
24037
- if (!isWithin(resolvedMain, resolvedTarget) && resolvedTarget !== path24.join(resolvedMain, "node_modules")) {
24227
+ if (!isWithin(resolvedMain, resolvedTarget) && resolvedTarget !== path25.join(resolvedMain, "node_modules")) {
24038
24228
  return { state: "external", target };
24039
24229
  }
24040
24230
  return { state: "linked-whole", target };
24041
24231
  } else if (stat.isDirectory()) {
24042
24232
  let isLinkedPackages = false;
24043
24233
  try {
24044
- const entries = fs17.readdirSync(nodeModulesPath, { withFileTypes: true });
24234
+ const entries = fs18.readdirSync(nodeModulesPath, { withFileTypes: true });
24045
24235
  for (const entry of entries) {
24046
24236
  if (entry.isSymbolicLink()) {
24047
24237
  isLinkedPackages = true;
@@ -24055,7 +24245,7 @@ function detectCommonLinkageState(wtPath, mainPath) {
24055
24245
  return { state: "installed" };
24056
24246
  }
24057
24247
  } catch {
24058
- if (!fs17.existsSync(nodeModulesPath)) {
24248
+ if (!fs18.existsSync(nodeModulesPath)) {
24059
24249
  return { state: "missing" };
24060
24250
  }
24061
24251
  }
@@ -24169,7 +24359,7 @@ function findRepoDepsContext(wtPath) {
24169
24359
  try {
24170
24360
  const config2 = loadConfig();
24171
24361
  const repos = resolveRepos(config2, []);
24172
- const repo = repos.find((r) => wtPath === r.wtRoot || wtPath.startsWith(r.wtRoot + "/"));
24362
+ const repo = repos.find((r) => wtPath === r.mainPath || wtPath === r.wtRoot || wtPath.startsWith(r.wtRoot + "/"));
24173
24363
  if (repo) {
24174
24364
  return {
24175
24365
  name: repo.name,
@@ -24250,18 +24440,18 @@ var init_deps = __esm(() => {
24250
24440
  });
24251
24441
 
24252
24442
  // src/lib/forge/map.ts
24253
- function isRecord(value) {
24443
+ function isRecord2(value) {
24254
24444
  return typeof value === "object" && value !== null && !Array.isArray(value);
24255
24445
  }
24256
24446
  function asCheckItem(value) {
24257
- if (!isRecord(value))
24447
+ if (!isRecord2(value))
24258
24448
  return null;
24259
24449
  return {
24260
24450
  __typename: typeof value.__typename === "string" ? value.__typename : undefined,
24261
24451
  status: typeof value.status === "string" ? value.status : null,
24262
24452
  conclusion: typeof value.conclusion === "string" ? value.conclusion : null,
24263
24453
  state: typeof value.state === "string" ? value.state : null,
24264
- commit: isRecord(value.commit) ? value.commit : null
24454
+ commit: isRecord2(value.commit) ? value.commit : null
24265
24455
  };
24266
24456
  }
24267
24457
  function collectCheckItems(raw) {
@@ -24335,7 +24525,7 @@ function mapReviewDecision(raw) {
24335
24525
  return null;
24336
24526
  }
24337
24527
  function mapGithubPr(raw) {
24338
- if (!isRecord(raw))
24528
+ if (!isRecord2(raw))
24339
24529
  return null;
24340
24530
  const number4 = typeof raw.number === "number" ? raw.number : null;
24341
24531
  const headRefName = typeof raw.headRefName === "string" ? raw.headRefName : null;
@@ -24346,9 +24536,10 @@ function mapGithubPr(raw) {
24346
24536
  const url2 = typeof raw.url === "string" ? raw.url : "";
24347
24537
  const isDraft = raw.isDraft === true;
24348
24538
  const updatedAt = typeof raw.updatedAt === "string" ? raw.updatedAt : "";
24539
+ const baseRefName = typeof raw.baseRefName === "string" ? raw.baseRefName : undefined;
24349
24540
  const checks3 = bucketChecks(collectCheckItems(raw.statusCheckRollup));
24350
24541
  const author = raw.author;
24351
- const authorLogin = isRecord(author) && typeof author.login === "string" ? author.login : null;
24542
+ const authorLogin = isRecord2(author) && typeof author.login === "string" ? author.login : null;
24352
24543
  return {
24353
24544
  number: number4,
24354
24545
  authorLogin,
@@ -24359,12 +24550,13 @@ function mapGithubPr(raw) {
24359
24550
  mergeable: mapMergeable(raw.mergeable),
24360
24551
  checks: checks3,
24361
24552
  reviewDecision: mapReviewDecision(raw.reviewDecision),
24553
+ ...baseRefName ? { baseRefName } : {},
24362
24554
  unresolvedThreads: 0,
24363
24555
  updatedAt
24364
24556
  };
24365
24557
  }
24366
24558
  function mapPrHead(raw) {
24367
- if (!isRecord(raw))
24559
+ if (!isRecord2(raw))
24368
24560
  return null;
24369
24561
  const number4 = typeof raw.number === "number" ? raw.number : null;
24370
24562
  const headRefName = typeof raw.headRefName === "string" ? raw.headRefName : null;
@@ -24375,12 +24567,13 @@ function mapPrHead(raw) {
24375
24567
  const url2 = typeof raw.url === "string" ? raw.url : "";
24376
24568
  const isDraft = raw.isDraft === true;
24377
24569
  const isCrossRepository = raw.isCrossRepository === true;
24570
+ const baseRefName = typeof raw.baseRefName === "string" ? raw.baseRefName : undefined;
24378
24571
  let headOwnerLogin = null;
24379
- if (isRecord(raw.headRepositoryOwner) && typeof raw.headRepositoryOwner.login === "string") {
24572
+ if (isRecord2(raw.headRepositoryOwner) && typeof raw.headRepositoryOwner.login === "string") {
24380
24573
  headOwnerLogin = raw.headRepositoryOwner.login;
24381
24574
  }
24382
24575
  let headRepoName = null;
24383
- if (isRecord(raw.headRepository) && typeof raw.headRepository.name === "string") {
24576
+ if (isRecord2(raw.headRepository) && typeof raw.headRepository.name === "string") {
24384
24577
  headRepoName = raw.headRepository.name;
24385
24578
  }
24386
24579
  return {
@@ -24390,6 +24583,7 @@ function mapPrHead(raw) {
24390
24583
  state,
24391
24584
  isDraft,
24392
24585
  headRefName,
24586
+ ...baseRefName ? { baseRefName } : {},
24393
24587
  isCrossRepository,
24394
24588
  headOwnerLogin,
24395
24589
  headRepoName
@@ -24485,7 +24679,7 @@ async function ghExec(args, opts = {}) {
24485
24679
  }
24486
24680
  function mapWithBranch(raw) {
24487
24681
  const pr = mapGithubPr(raw);
24488
- if (!pr || !isRecord(raw) || typeof raw.headRefName !== "string")
24682
+ if (!pr || !isRecord2(raw) || typeof raw.headRefName !== "string")
24489
24683
  return null;
24490
24684
  return { branch: raw.headRefName, pr };
24491
24685
  }
@@ -24530,12 +24724,12 @@ async function fetchBranchPrs(ctx) {
24530
24724
  return results.filter((entry) => entry !== null);
24531
24725
  }
24532
24726
  function countUnresolvedThreads(result) {
24533
- if (!isRecord(result))
24727
+ if (!isRecord2(result))
24534
24728
  return 0;
24535
24729
  const threads = result.reviewThreads;
24536
- if (!isRecord(threads) || !Array.isArray(threads.nodes))
24730
+ if (!isRecord2(threads) || !Array.isArray(threads.nodes))
24537
24731
  return 0;
24538
- return threads.nodes.filter((node) => isRecord(node) && node.isResolved === false).length;
24732
+ return threads.nodes.filter((node) => isRecord2(node) && node.isResolved === false).length;
24539
24733
  }
24540
24734
  function buildThreadQuery(numbers) {
24541
24735
  const aliases = numbers.map((n2, i2) => `pr${i2}: pullRequest(number: ${n2}) { reviewThreads(first: ${THREADS_PER_PR}) { nodes { isResolved } } }`).join(" ");
@@ -24556,10 +24750,10 @@ async function enrichUnresolvedThreads(slug, prs, verboseFlag) {
24556
24750
  `name=${slug.name}`
24557
24751
  ], { verbose: verboseFlag });
24558
24752
  const payload = parseJson(stdout);
24559
- if (!isRecord(payload) || !isRecord(payload.data))
24753
+ if (!isRecord2(payload) || !isRecord2(payload.data))
24560
24754
  return;
24561
24755
  const repoData = payload.data.repository;
24562
- if (!isRecord(repoData))
24756
+ if (!isRecord2(repoData))
24563
24757
  return;
24564
24758
  prs.forEach((pr, i2) => {
24565
24759
  pr.unresolvedThreads = countUnresolvedThreads(repoData[`pr${i2}`]);
@@ -24589,7 +24783,7 @@ function createGithubAdapter(slug) {
24589
24783
  "-R",
24590
24784
  `${slug.owner}/${slug.name}`,
24591
24785
  "--json",
24592
- "number,title,url,state,isDraft,headRefName,isCrossRepository,headRepositoryOwner,headRepository"
24786
+ "number,title,url,state,isDraft,headRefName,baseRefName,isCrossRepository,headRepositoryOwner,headRepository"
24593
24787
  ];
24594
24788
  const stdout = await ghExec(args, opts);
24595
24789
  const parsed = parseJson(stdout);
@@ -24629,6 +24823,7 @@ var init_github = __esm(() => {
24629
24823
  "statusCheckRollup",
24630
24824
  "reviewDecision",
24631
24825
  "headRefName",
24826
+ "baseRefName",
24632
24827
  "updatedAt"
24633
24828
  ].join(",");
24634
24829
  REMOTE_URL_PATTERNS = [
@@ -24639,8 +24834,8 @@ var init_github = __esm(() => {
24639
24834
  });
24640
24835
 
24641
24836
  // src/lib/forge/index.ts
24642
- import fs18 from "fs";
24643
- import path25 from "path";
24837
+ import fs19 from "fs";
24838
+ import path26 from "path";
24644
24839
  function parsePrLink(link) {
24645
24840
  for (const forge of FORGES) {
24646
24841
  const ref = forge.parsePrLink(link);
@@ -24653,10 +24848,10 @@ function descriptorFor(id) {
24653
24848
  return FORGES.find((f) => f.id === id) ?? null;
24654
24849
  }
24655
24850
  function readOriginUrl(mainPath) {
24656
- const gitConfigPath = path25.join(mainPath, ".git", "config");
24657
- if (!fs18.existsSync(gitConfigPath))
24851
+ const gitConfigPath = path26.join(mainPath, ".git", "config");
24852
+ if (!fs19.existsSync(gitConfigPath))
24658
24853
  return null;
24659
- const content = fs18.readFileSync(gitConfigPath, "utf-8");
24854
+ const content = fs19.readFileSync(gitConfigPath, "utf-8");
24660
24855
  const originSection = content.match(/\[remote "origin"\]([^[]*)/);
24661
24856
  if (!originSection)
24662
24857
  return null;
@@ -24706,7 +24901,7 @@ var init_forge = __esm(() => {
24706
24901
  });
24707
24902
 
24708
24903
  // src/lib/owner.ts
24709
- import fs19 from "fs";
24904
+ import fs20 from "fs";
24710
24905
  function deriveOwnership(input) {
24711
24906
  if (input.hasLocalChanges) {
24712
24907
  return { mine: true, author: null };
@@ -24733,7 +24928,7 @@ async function resolveOwnership(input) {
24733
24928
  }
24734
24929
  const { mainPath, branch, verbose: verbose2 } = input;
24735
24930
  let hasLocalChanges = false;
24736
- if (input.wtPath && fs19.existsSync(input.wtPath)) {
24931
+ if (input.wtPath && fs20.existsSync(input.wtPath)) {
24737
24932
  try {
24738
24933
  const dirtyFiles = await getDirtyFiles(input.wtPath);
24739
24934
  hasLocalChanges = dirtyFiles.length > 0;
@@ -24995,6 +25190,13 @@ async function fetchWorktreeData(opts, scope) {
24995
25190
  try {
24996
25191
  const mainBranch = await resolveMainBranch(repo, config2);
24997
25192
  const wts = await getWorktreeList(repo.mainPath);
25193
+ let stackMetadata = { version: 1, branches: {} };
25194
+ try {
25195
+ stackMetadata = await readStackMetadata(repo.mainPath, opts);
25196
+ } catch (err) {
25197
+ const message = err instanceof Error ? err.message : String(err);
25198
+ warnings.push({ repoName: repo.name, message });
25199
+ }
24998
25200
  const forge = resolveForge(repo);
24999
25201
  const branches = wts.map((w) => w.branch).filter(Boolean);
25000
25202
  let prMap = new Map;
@@ -25022,6 +25224,10 @@ async function fetchWorktreeData(opts, scope) {
25022
25224
  if (isMainCheckout && !branch) {
25023
25225
  branch = mainBranch;
25024
25226
  }
25227
+ const stackEntry = branch ? stackMetadata.branches[branch] : undefined;
25228
+ const pr = branch ? prMap.get(branch) : undefined;
25229
+ const base2 = stackEntry?.baseRef ?? pr?.baseRefName;
25230
+ const comparisonBase = stackEntry?.explicit ? stackEntry.baseRef : pr?.baseRefName && pr.baseRefName !== mainBranch ? pr.baseRefName : `origin/${mainBranch}`;
25025
25231
  const row = {
25026
25232
  repoName: repo.name,
25027
25233
  branch: branch || "(detached)",
@@ -25040,7 +25246,9 @@ async function fetchWorktreeData(opts, scope) {
25040
25246
  prUrl: null,
25041
25247
  owner: null,
25042
25248
  rebaseStatus: null,
25043
- depsStrategy: "none"
25249
+ depsStrategy: "none",
25250
+ base: base2,
25251
+ baseChanged: false
25044
25252
  };
25045
25253
  if (isMainCheckout) {
25046
25254
  allRows.push(row);
@@ -25052,7 +25260,7 @@ async function fetchWorktreeData(opts, scope) {
25052
25260
  })(),
25053
25261
  (async () => {
25054
25262
  try {
25055
- const stdout = await gitExec(["-C", wt.path, "rev-list", "--left-right", "--count", `origin/${mainBranch}...HEAD`], { dryRun: opts.dryRun });
25263
+ const stdout = await gitExec(["-C", wt.path, "rev-list", "--left-right", "--count", `${comparisonBase}...HEAD`], { dryRun: opts.dryRun });
25056
25264
  if (stdout) {
25057
25265
  const parts = stdout.trim().split(/\s+/);
25058
25266
  if (parts.length === 2) {
@@ -25062,6 +25270,16 @@ async function fetchWorktreeData(opts, scope) {
25062
25270
  }
25063
25271
  } catch {}
25064
25272
  })(),
25273
+ (async () => {
25274
+ if (!stackEntry?.explicit || opts.dryRun)
25275
+ return;
25276
+ try {
25277
+ const currentBaseSha = await resolveCommitSha(repo.mainPath, stackEntry.baseRef, opts);
25278
+ row.baseChanged = currentBaseSha !== stackEntry.baseSha;
25279
+ } catch {
25280
+ row.baseChanged = true;
25281
+ }
25282
+ })(),
25065
25283
  (async () => {
25066
25284
  row.rebaseStatus = detectInProgressRebase(wt.path);
25067
25285
  })(),
@@ -25073,7 +25291,6 @@ async function fetchWorktreeData(opts, scope) {
25073
25291
  })(),
25074
25292
  (async () => {
25075
25293
  if (branch && config2.user) {
25076
- const pr = prMap.get(branch);
25077
25294
  try {
25078
25295
  const owner = await resolveOwnership({
25079
25296
  configUser: config2.user,
@@ -25090,7 +25307,6 @@ async function fetchWorktreeData(opts, scope) {
25090
25307
  })()
25091
25308
  ]);
25092
25309
  if (branch) {
25093
- const pr = prMap.get(branch);
25094
25310
  if (pr) {
25095
25311
  row.prNumber = pr.number;
25096
25312
  row.prUrl = pr.url;
@@ -25127,6 +25343,7 @@ var init_data = __esm(() => {
25127
25343
  init_forge();
25128
25344
  init_owner();
25129
25345
  init_types2();
25346
+ init_stack();
25130
25347
  prCache = new Map;
25131
25348
  });
25132
25349
 
@@ -25147,14 +25364,16 @@ function matchesFilter(entry, term) {
25147
25364
  return true;
25148
25365
  if (entry.prUrl?.toLowerCase().includes(lower))
25149
25366
  return true;
25367
+ if (entry.base?.toLowerCase().includes(lower))
25368
+ return true;
25150
25369
  return false;
25151
25370
  }
25152
- function toggleSelection(current, path35) {
25371
+ function toggleSelection(current, path36) {
25153
25372
  const next = new Set(current);
25154
- if (next.has(path35)) {
25155
- next.delete(path35);
25373
+ if (next.has(path36)) {
25374
+ next.delete(path36);
25156
25375
  } else {
25157
- next.add(path35);
25376
+ next.add(path36);
25158
25377
  }
25159
25378
  return next;
25160
25379
  }
@@ -25181,6 +25400,13 @@ function rowSort(a2, b) {
25181
25400
  return 1;
25182
25401
  return a2.branch.localeCompare(b.branch);
25183
25402
  }
25403
+ function sortRowsHierarchically(rows) {
25404
+ return buildStackHierarchy(rows, (row) => row.branch, (row) => row.base, rowSort).map(({ item, depth, prefix }) => ({
25405
+ ...item,
25406
+ hierarchyDepth: depth,
25407
+ hierarchyPrefix: prefix
25408
+ }));
25409
+ }
25184
25410
  function sortBlocks(blocks) {
25185
25411
  return [...blocks].sort((a2, b) => a2.repoName.localeCompare(b.repoName));
25186
25412
  }
@@ -25236,9 +25462,12 @@ function withCreatePlaceholders(blocks, creating) {
25236
25462
  if (!branches)
25237
25463
  return block;
25238
25464
  const placeholders = branches.map((br) => makePlaceholderRow(block.repoName, br));
25239
- return { ...block, rows: [...block.rows, ...placeholders].sort(rowSort) };
25465
+ return { ...block, rows: sortRowsHierarchically([...block.rows, ...placeholders]) };
25240
25466
  });
25241
25467
  }
25468
+ var init_utils = __esm(() => {
25469
+ init_stack();
25470
+ });
25242
25471
 
25243
25472
  // src/tui/hooks/useWorktrees.ts
25244
25473
  import { useState, useEffect, useCallback, useRef } from "react";
@@ -25278,8 +25507,7 @@ function useWorktrees(opts) {
25278
25507
  }
25279
25508
  const newBlocks = [];
25280
25509
  for (const [repoName, rows] of byRepo.entries()) {
25281
- rows.sort(rowSort);
25282
- newBlocks.push({ repoName, rows });
25510
+ newBlocks.push({ repoName, rows: sortRowsHierarchically(rows) });
25283
25511
  }
25284
25512
  newBlocks.sort((a2, b) => a2.repoName.localeCompare(b.repoName));
25285
25513
  const scopeSet = scope ? new Set(scope) : undefined;
@@ -25306,6 +25534,7 @@ function useWorktrees(opts) {
25306
25534
  var init_useWorktrees = __esm(() => {
25307
25535
  init_data();
25308
25536
  init_config();
25537
+ init_utils();
25309
25538
  });
25310
25539
 
25311
25540
  // src/tui/theme.ts
@@ -25366,12 +25595,17 @@ function WorktreeItem({ row, isSelected, isMultiSelected, indicator, frame, id }
25366
25595
  row.prChecks ? `(${row.prChecks})` : ""
25367
25596
  ].filter(Boolean).join(" ") : "";
25368
25597
  const ownerSegment = row.owner ? ` · by ${row.owner}` : "";
25598
+ const baseSegment = row.base ? ` · base ${row.base}` : "";
25369
25599
  const rebaseSegment = !row.isMainCheckout && row.rebaseStatus && !row.isPrunable ? ` · ${row.rebaseStatus}` : "";
25600
+ const baseChangedSegment = row.baseChanged ? " · base moved" : "";
25601
+ const hierarchyPrefix = row.hierarchyPrefix ?? "";
25602
+ const secondaryIndent = " ".repeat(SECONDARY_INDENT.length + hierarchyPrefix.length);
25370
25603
  const secondary = [
25371
- `${SECONDARY_INDENT}${row.commitShort}`,
25604
+ `${secondaryIndent}${row.commitShort}`,
25372
25605
  divergence,
25373
25606
  prSegment,
25374
- ownerSegment
25607
+ ownerSegment,
25608
+ baseSegment
25375
25609
  ].filter(Boolean).join(" ").trimEnd();
25376
25610
  return /* @__PURE__ */ jsxs("box", {
25377
25611
  id,
@@ -25389,6 +25623,10 @@ function WorktreeItem({ row, isSelected, isMultiSelected, indicator, frame, id }
25389
25623
  fg: tokens.accent,
25390
25624
  children: isMultiSelected ? "✓ " : " "
25391
25625
  }),
25626
+ /* @__PURE__ */ jsx("span", {
25627
+ fg: tokens.dim,
25628
+ children: hierarchyPrefix
25629
+ }),
25392
25630
  /* @__PURE__ */ jsx("span", {
25393
25631
  fg: primary,
25394
25632
  children: truncateBranch(row.branch)
@@ -25405,6 +25643,10 @@ function WorktreeItem({ row, isSelected, isMultiSelected, indicator, frame, id }
25405
25643
  fg: tokens.dim,
25406
25644
  children: secondary
25407
25645
  }),
25646
+ baseChangedSegment && /* @__PURE__ */ jsx("span", {
25647
+ fg: tokens.warning,
25648
+ children: baseChangedSegment
25649
+ }),
25408
25650
  rebaseSegment && /* @__PURE__ */ jsx("span", {
25409
25651
  fg: tokens.error,
25410
25652
  children: rebaseSegment
@@ -25510,7 +25752,7 @@ function DetailPane({ selectedRow }) {
25510
25752
  const {
25511
25753
  repoName,
25512
25754
  branch,
25513
- path: path35,
25755
+ path: path36,
25514
25756
  commitShort,
25515
25757
  isMainCheckout,
25516
25758
  isLocked,
@@ -25524,7 +25766,9 @@ function DetailPane({ selectedRow }) {
25524
25766
  prUrl,
25525
25767
  owner,
25526
25768
  rebaseStatus,
25527
- depsStrategy
25769
+ depsStrategy,
25770
+ base: base2,
25771
+ baseChanged
25528
25772
  } = selectedRow;
25529
25773
  const aheadBehindStr = ahead !== null && behind !== null ? `${ahead} ahead, ${behind} behind` : "unknown";
25530
25774
  return /* @__PURE__ */ jsxs2("scrollbox", {
@@ -25579,7 +25823,7 @@ function DetailPane({ selectedRow }) {
25579
25823
  fg: tokens.fg,
25580
25824
  children: [
25581
25825
  " ",
25582
- path35
25826
+ path36
25583
25827
  ]
25584
25828
  })
25585
25829
  ]
@@ -25629,12 +25873,39 @@ function DetailPane({ selectedRow }) {
25629
25873
  })
25630
25874
  ]
25631
25875
  }),
25876
+ base2 && /* @__PURE__ */ jsxs2("text", {
25877
+ children: [
25878
+ /* @__PURE__ */ jsx2("span", {
25879
+ fg: tokens.dim,
25880
+ children: "Base:"
25881
+ }),
25882
+ /* @__PURE__ */ jsxs2("span", {
25883
+ fg: tokens.accent,
25884
+ children: [
25885
+ " ",
25886
+ base2
25887
+ ]
25888
+ })
25889
+ ]
25890
+ }),
25891
+ baseChanged && /* @__PURE__ */ jsxs2("text", {
25892
+ children: [
25893
+ /* @__PURE__ */ jsx2("span", {
25894
+ fg: tokens.dim,
25895
+ children: "Base state:"
25896
+ }),
25897
+ /* @__PURE__ */ jsx2("span", {
25898
+ fg: tokens.warning,
25899
+ children: " moved since recorded"
25900
+ })
25901
+ ]
25902
+ }),
25632
25903
  /* @__PURE__ */ jsxs2("text", {
25633
25904
  style: { marginTop: 1 },
25634
25905
  children: [
25635
25906
  /* @__PURE__ */ jsx2("span", {
25636
25907
  fg: tokens.dim,
25637
- children: "vs main:"
25908
+ children: base2 ? "vs base:" : "vs main:"
25638
25909
  }),
25639
25910
  /* @__PURE__ */ jsxs2("span", {
25640
25911
  fg: tokens.fg,
@@ -25960,9 +26231,9 @@ var init_HelpOverlay = __esm(() => {
25960
26231
  ["n", "Create new worktree (pick dependency strategy)"],
25961
26232
  ["f", "Fetch main for selected repo(s)"],
25962
26233
  ["p", "Pull latest changes for selected branch(es)"],
25963
- ["b", "Rebase selected onto main"],
26234
+ ["b", "Rebase selected onto base (or main)"],
25964
26235
  ["s", "Sync selected (env files + hooks)"],
25965
- ["i", "Install dependencies in selected worktree(s)"],
26236
+ ["i", "Install dependencies in selection (worktrees and main)"],
25966
26237
  ["m", "Rename selected worktree (branch + directory)"],
25967
26238
  ["o", "Open selected in IDE"],
25968
26239
  ["a", "Spawn agent in selected"],
@@ -26136,38 +26407,38 @@ var init_ActionLogModal = __esm(() => {
26136
26407
  });
26137
26408
 
26138
26409
  // src/lib/history.ts
26139
- import fs30 from "fs";
26140
- import path35 from "path";
26410
+ import fs31 from "fs";
26411
+ import path36 from "path";
26141
26412
  import os4 from "os";
26142
26413
  function getHistoryDir() {
26143
- const stateHome = process.env.XDG_STATE_HOME ?? path35.join(os4.homedir(), ".local", "state");
26144
- return path35.join(stateHome, "wtx");
26414
+ const stateHome = process.env.XDG_STATE_HOME ?? path36.join(os4.homedir(), ".local", "state");
26415
+ return path36.join(stateHome, "wtx");
26145
26416
  }
26146
26417
  function getHistoryPath() {
26147
- return path35.join(getHistoryDir(), "history.jsonl");
26418
+ return path36.join(getHistoryDir(), "history.jsonl");
26148
26419
  }
26149
26420
  function rotateHistory(maxBytes = HISTORY_MAX_BYTES, keepLines = HISTORY_ROTATE_KEEP_LINES) {
26150
26421
  const historyPath = getHistoryPath();
26151
26422
  let size = 0;
26152
26423
  try {
26153
- size = fs30.statSync(historyPath).size;
26424
+ size = fs31.statSync(historyPath).size;
26154
26425
  } catch {
26155
26426
  return;
26156
26427
  }
26157
26428
  if (size <= maxBytes)
26158
26429
  return;
26159
- const lines = fs30.readFileSync(historyPath, "utf-8").split(`
26430
+ const lines = fs31.readFileSync(historyPath, "utf-8").split(`
26160
26431
  `).filter(Boolean);
26161
26432
  const kept = lines.slice(-keepLines);
26162
26433
  const tmpPath = `${historyPath}.tmp.${process.pid}`;
26163
26434
  try {
26164
- fs30.writeFileSync(tmpPath, kept.length > 0 ? `${kept.join(`
26435
+ fs31.writeFileSync(tmpPath, kept.length > 0 ? `${kept.join(`
26165
26436
  `)}
26166
26437
  ` : "", "utf-8");
26167
- fs30.renameSync(tmpPath, historyPath);
26438
+ fs31.renameSync(tmpPath, historyPath);
26168
26439
  } catch (err) {
26169
- if (fs30.existsSync(tmpPath)) {
26170
- fs30.unlinkSync(tmpPath);
26440
+ if (fs31.existsSync(tmpPath)) {
26441
+ fs31.unlinkSync(tmpPath);
26171
26442
  }
26172
26443
  throw err;
26173
26444
  }
@@ -26175,18 +26446,18 @@ function rotateHistory(maxBytes = HISTORY_MAX_BYTES, keepLines = HISTORY_ROTATE_
26175
26446
  function appendHistory(entry) {
26176
26447
  try {
26177
26448
  const dir = getHistoryDir();
26178
- if (!fs30.existsSync(dir)) {
26179
- fs30.mkdirSync(dir, { recursive: true });
26449
+ if (!fs31.existsSync(dir)) {
26450
+ fs31.mkdirSync(dir, { recursive: true });
26180
26451
  }
26181
26452
  rotateHistory();
26182
- fs30.appendFileSync(getHistoryPath(), `${JSON.stringify(entry)}
26453
+ fs31.appendFileSync(getHistoryPath(), `${JSON.stringify(entry)}
26183
26454
  `, "utf-8");
26184
26455
  } catch {}
26185
26456
  }
26186
26457
  function readRecentHistory(limit = 50) {
26187
26458
  let content;
26188
26459
  try {
26189
- content = fs30.readFileSync(getHistoryPath(), "utf-8");
26460
+ content = fs31.readFileSync(getHistoryPath(), "utf-8");
26190
26461
  } catch {
26191
26462
  return [];
26192
26463
  }
@@ -26787,6 +27058,7 @@ function ConfigOverlay({ onClose, onSaved, onError }) {
26787
27058
  }
26788
27059
  var init_ConfigOverlay = __esm(() => {
26789
27060
  init_Overlay();
27061
+ init_utils();
26790
27062
  init_theme();
26791
27063
  init_config();
26792
27064
  init_InputModal();
@@ -26835,6 +27107,8 @@ function App({ opts }) {
26835
27107
  const nextOpId = useRef3(1);
26836
27108
  const [createModal, setCreateModal] = useState7(false);
26837
27109
  const [createError, setCreateError] = useState7();
27110
+ const [createBaseModal, setCreateBaseModal] = useState7(null);
27111
+ const [createBaseError, setCreateBaseError] = useState7();
26838
27112
  const [createDepsChoice, setCreateDepsChoice] = useState7(null);
26839
27113
  const [renameModal, setRenameModal] = useState7(false);
26840
27114
  const [renameError, setRenameError] = useState7();
@@ -26990,7 +27264,7 @@ function App({ opts }) {
26990
27264
  setOps((prev) => [...prev, op]);
26991
27265
  executeOp(op, ["fetch", "--repo", repoNames.join(",")], repoNames).then(() => setSelection(new Set));
26992
27266
  };
26993
- const startCreate = (branch, repoName, deps) => {
27267
+ const startCreate = (branch, repoName, deps, base2) => {
26994
27268
  const op = {
26995
27269
  id: nextOpId.current++,
26996
27270
  kind: "create",
@@ -27003,6 +27277,8 @@ function App({ opts }) {
27003
27277
  };
27004
27278
  setOps((prev) => [...prev, op]);
27005
27279
  const args = ["create", branch, "--repo", repoName];
27280
+ if (base2)
27281
+ args.push("--base", base2);
27006
27282
  if (deps && deps !== "auto")
27007
27283
  args.push("--deps", deps);
27008
27284
  executeOp(op, args, [repoName]);
@@ -27031,6 +27307,13 @@ function App({ opts }) {
27031
27307
  }
27032
27308
  return;
27033
27309
  }
27310
+ if (createBaseModal) {
27311
+ if (key.name === "escape") {
27312
+ setCreateBaseModal(null);
27313
+ setCreateBaseError(undefined);
27314
+ }
27315
+ return;
27316
+ }
27034
27317
  if (createDepsChoice)
27035
27318
  return;
27036
27319
  if (renameModal) {
@@ -27154,16 +27437,12 @@ function App({ opts }) {
27154
27437
  const targets = getSelectedRows();
27155
27438
  if (targets.length === 0)
27156
27439
  return;
27157
- if (targets.some((r) => r.isMainCheckout)) {
27158
- flash("Cannot install deps on main checkout");
27159
- return;
27160
- }
27161
27440
  const conflict = findConflict(targets);
27162
27441
  if (conflict) {
27163
27442
  flash(conflict);
27164
27443
  return;
27165
27444
  }
27166
- startBatchActions("install", targets, (r) => ["deps", r.branch, "--repo", r.repoName, "--install"]);
27445
+ startBatchActions("install", targets, (r) => r.isMainCheckout ? ["deps", "--repo", r.repoName, "--install"] : ["deps", r.branch, "--repo", r.repoName, "--install"]);
27167
27446
  return;
27168
27447
  }
27169
27448
  if (key.name === "m") {
@@ -27414,16 +27693,33 @@ function App({ opts }) {
27414
27693
  }
27415
27694
  setCreateModal(false);
27416
27695
  setCreateError(undefined);
27417
- setCreateDepsChoice({ branch, repoName });
27696
+ setCreateBaseError(undefined);
27697
+ setCreateBaseModal({ branch, repoName });
27698
+ }
27699
+ }),
27700
+ createBaseModal && /* @__PURE__ */ jsx12(InputModal, {
27701
+ title: `Base ref for ${createBaseModal.branch}`,
27702
+ placeholder: "origin/main (empty for default main)",
27703
+ errorMessage: createBaseError,
27704
+ onSubmit: (value) => {
27705
+ const base2 = value.trim();
27706
+ if (base2 && !validateSafeBranchName(base2)) {
27707
+ setCreateBaseError("Invalid base ref");
27708
+ return;
27709
+ }
27710
+ const { branch, repoName } = createBaseModal;
27711
+ setCreateBaseModal(null);
27712
+ setCreateBaseError(undefined);
27713
+ setCreateDepsChoice({ branch, repoName, base: base2 || undefined });
27418
27714
  }
27419
27715
  }),
27420
27716
  createDepsChoice && /* @__PURE__ */ jsx12(ChoiceModal, {
27421
27717
  title: `Dependencies for ${createDepsChoice.branch}`,
27422
27718
  options: DEPS_CHOICES,
27423
27719
  onSubmit: (choice) => {
27424
- const { branch, repoName } = createDepsChoice;
27720
+ const { branch, repoName, base: base2 } = createDepsChoice;
27425
27721
  setCreateDepsChoice(null);
27426
- startCreate(branch, repoName, choice);
27722
+ startCreate(branch, repoName, choice, base2);
27427
27723
  },
27428
27724
  onCancel: () => setCreateDepsChoice(null)
27429
27725
  }),
@@ -27493,6 +27789,7 @@ var init_App = __esm(() => {
27493
27789
  init_InputModal();
27494
27790
  init_ChoiceModal();
27495
27791
  init_ConfigOverlay();
27792
+ init_utils();
27496
27793
  init_agents();
27497
27794
  init_config();
27498
27795
  init_history();
@@ -29661,8 +29958,8 @@ Run 'wtx config show' to see available repos.`);
29661
29958
  init_config();
29662
29959
  init_log();
29663
29960
  init_git();
29664
- import fs20 from "fs";
29665
- import path26 from "path";
29961
+ import fs21 from "fs";
29962
+ import path27 from "path";
29666
29963
 
29667
29964
  // src/lib/remotes.ts
29668
29965
  init_execa();
@@ -29727,6 +30024,7 @@ function resolveBranchTarget(input) {
29727
30024
  }
29728
30025
 
29729
30026
  // src/commands/create.ts
30027
+ init_stack();
29730
30028
  init_resolver();
29731
30029
 
29732
30030
  // src/lib/ide.ts
@@ -29742,14 +30040,14 @@ function spawnIde(ide, wtPath) {
29742
30040
  // src/lib/worktree-setup.ts
29743
30041
  init_execa();
29744
30042
  init_log();
29745
- import fs9 from "fs";
29746
- import path16 from "path";
30043
+ import fs10 from "fs";
30044
+ import path17 from "path";
29747
30045
 
29748
30046
  // src/lib/ports.ts
29749
30047
  init_git();
29750
30048
  init_path_safety();
29751
30049
  init_config();
29752
- import fs8 from "fs";
30050
+ import fs9 from "fs";
29753
30051
  function hashPort(key, min, max) {
29754
30052
  let h2 = 2166136261;
29755
30053
  for (let i2 = 0;i2 < key.length; i2++) {
@@ -29784,7 +30082,7 @@ async function getWorktreePort(repoName, branch, config2, currentWtPath) {
29784
30082
  const taken = new Set;
29785
30083
  for (const name of allRepos) {
29786
30084
  const mainPath = `${root}/${name}`;
29787
- if (!fs8.existsSync(mainPath)) {
30085
+ if (!fs9.existsSync(mainPath)) {
29788
30086
  continue;
29789
30087
  }
29790
30088
  let wts = [];
@@ -29815,12 +30113,12 @@ async function runPostCreateSetup(params) {
29815
30113
  let hooks = [];
29816
30114
  if (repo.config.sync_files && repo.config.sync_files.length > 0) {
29817
30115
  for (const file2 of repo.config.sync_files) {
29818
- const src = path16.join(repo.mainPath, file2);
29819
- const dest = path16.join(wtPath, file2);
29820
- if (fs9.existsSync(src)) {
30116
+ const src = path17.join(repo.mainPath, file2);
30117
+ const dest = path17.join(wtPath, file2);
30118
+ if (fs10.existsSync(src)) {
29821
30119
  if (!globalOpts.dryRun) {
29822
- fs9.mkdirSync(path16.dirname(dest), { recursive: true });
29823
- fs9.copyFileSync(src, dest);
30120
+ fs10.mkdirSync(path17.dirname(dest), { recursive: true });
30121
+ fs10.copyFileSync(src, dest);
29824
30122
  copiedFiles.push(file2);
29825
30123
  }
29826
30124
  stepSuccess(`Synced ${file2}`);
@@ -29948,9 +30246,6 @@ function registerCreateCommand(program2) {
29948
30246
  openWorktree(wtPath);
29949
30247
  continue;
29950
30248
  }
29951
- if (!globalOpts.dryRun) {
29952
- fs20.mkdirSync(path26.dirname(wtPath), { recursive: true });
29953
- }
29954
30249
  const mainBranch = await resolveMainBranch(repo, config2);
29955
30250
  const resolvedRemote = await resolveBaseRemote(repo.mainPath, mainBranch);
29956
30251
  if (repo.config.fetch_main_on_create) {
@@ -29961,6 +30256,19 @@ function registerCreateCommand(program2) {
29961
30256
  }
29962
30257
  stepProgress("Checking branch status...");
29963
30258
  const baseRef = options.base || `${resolvedRemote}/${mainBranch}`;
30259
+ if (baseRef === branch || baseRef === `refs/heads/${branch}`) {
30260
+ throw new Error(`Base ref '${baseRef}' cannot be the new branch '${branch}'`);
30261
+ }
30262
+ let baseSha = null;
30263
+ if (!globalOpts.dryRun) {
30264
+ baseSha = await resolveCommitSha(repo.mainPath, baseRef, globalOpts);
30265
+ stepSuccess("Base resolved", `${baseRef} at ${baseSha.substring(0, 7)}`);
30266
+ } else {
30267
+ stepProgress("Using base", baseRef);
30268
+ }
30269
+ if (!globalOpts.dryRun) {
30270
+ fs21.mkdirSync(path27.dirname(wtPath), { recursive: true });
30271
+ }
29964
30272
  const localSha = await getLocalBranchSha(repo.mainPath, branch, globalOpts);
29965
30273
  const remoteSha = await getRemoteBranchSha(repo.mainPath, resolvedRemote, branch, globalOpts);
29966
30274
  const localExists = localSha !== null;
@@ -30021,6 +30329,21 @@ function registerCreateCommand(program2) {
30021
30329
  throw err;
30022
30330
  }
30023
30331
  stepSuccess("Worktree created", wtPath);
30332
+ if (!globalOpts.dryRun && baseSha && resolvedAction.kind === "create-new") {
30333
+ try {
30334
+ const metadataBaseRef = options.base ? baseRef : mainBranch;
30335
+ await recordStackEntry(repo.mainPath, branch, {
30336
+ baseRef: metadataBaseRef,
30337
+ baseSha,
30338
+ explicit: options.base !== undefined,
30339
+ createdAt: new Date().toISOString()
30340
+ }, globalOpts);
30341
+ stepSuccess("Base recorded", metadataBaseRef);
30342
+ } catch (err) {
30343
+ const message = err instanceof Error ? err.message : String(err);
30344
+ stepWarning("Base metadata not recorded", message);
30345
+ }
30346
+ }
30024
30347
  const setupResult = await runPostCreateSetup({ config: config2, repo, wtPath, branch, globalOpts });
30025
30348
  if (options.deps && options.deps !== "auto" && options.deps !== "link" && options.deps !== "off") {
30026
30349
  const ok = await applyDepsStrategy(options.deps, wtPath, repo.mainPath, globalOpts);
@@ -30088,9 +30411,10 @@ init_config();
30088
30411
  init_log();
30089
30412
  init_git();
30090
30413
  init_resolver();
30091
- import fs21 from "fs";
30092
- import path27 from "path";
30414
+ import fs22 from "fs";
30415
+ import path28 from "path";
30093
30416
  init_forge();
30417
+ init_stack();
30094
30418
  function registerPullCommand(program2) {
30095
30419
  program2.command("pull <link>").description("Fetch a forge PR and create its worktree").option("-r, --repo <repos...>", "Target specific repo").action(async (link, options) => {
30096
30420
  const globalOpts = program2.opts();
@@ -30112,7 +30436,7 @@ function registerPullCommand(program2) {
30112
30436
  for (const name of Object.keys(config2.repos).sort()) {
30113
30437
  if (config2.repos[name].check_prs === false)
30114
30438
  continue;
30115
- const mainPath = path27.join(expandTilde(config2.root), name);
30439
+ const mainPath = path28.join(expandTilde(config2.root), name);
30116
30440
  const detected = detectRepoForge(mainPath);
30117
30441
  if (!detected)
30118
30442
  continue;
@@ -30210,10 +30534,22 @@ function registerPullCommand(program2) {
30210
30534
  summaryWarning(`Nothing pulled — branch '${branch}' already exists`);
30211
30535
  return;
30212
30536
  }
30213
- const baseRemote = await resolveBaseRemote(target.mainPath, target.config.main_branch === "auto" ? config2.default_main_branch : target.config.main_branch);
30537
+ const mainBranch = await resolveMainBranch(target, config2);
30538
+ const baseRemote = await resolveBaseRemote(target.mainPath, mainBranch);
30539
+ const baseBranch = head.baseRefName || mainBranch;
30540
+ const baseRef = `${baseRemote}/${baseBranch}`;
30214
30541
  const fetch = adapter.buildHeadFetch(head);
30215
30542
  stepProgress(fetch.url ? `Fetching ${fetch.refspec} from fork...` : `Fetching pull/${head.number}/head from ${baseRemote}...`);
30216
30543
  try {
30544
+ if (baseBranch !== mainBranch) {
30545
+ try {
30546
+ await gitExec(["-C", target.mainPath, "fetch", baseRemote, "--", baseBranch], { verbose: globalOpts.verbose, dryRun: globalOpts.dryRun });
30547
+ } catch (err) {
30548
+ const message = err instanceof Error ? err.message : String(err);
30549
+ stepWarning("PR base was not fetched", message.split(`
30550
+ `)[0] ?? message);
30551
+ }
30552
+ }
30217
30553
  await gitExec([
30218
30554
  "-C",
30219
30555
  target.mainPath,
@@ -30227,7 +30563,7 @@ function registerPullCommand(program2) {
30227
30563
  }
30228
30564
  stepSuccess("Fetched");
30229
30565
  if (!globalOpts.dryRun) {
30230
- fs21.mkdirSync(path27.dirname(wtPath), { recursive: true });
30566
+ fs22.mkdirSync(path28.dirname(wtPath), { recursive: true });
30231
30567
  }
30232
30568
  try {
30233
30569
  await gitExec(["-C", target.mainPath, "worktree", "add", "-b", branch, wtPath, "FETCH_HEAD"], { verbose: globalOpts.verbose, dryRun: globalOpts.dryRun });
@@ -30241,6 +30577,22 @@ function registerPullCommand(program2) {
30241
30577
  throw err;
30242
30578
  }
30243
30579
  stepSuccess("Worktree created", wtPath);
30580
+ if (!globalOpts.dryRun) {
30581
+ try {
30582
+ const baseSha = await resolveCommitSha(target.mainPath, baseRef, globalOpts);
30583
+ const metadataBaseRef = baseBranch === mainBranch ? mainBranch : baseRef;
30584
+ await recordStackEntry(target.mainPath, branch, {
30585
+ baseRef: metadataBaseRef,
30586
+ baseSha,
30587
+ explicit: baseBranch !== mainBranch,
30588
+ createdAt: new Date().toISOString()
30589
+ }, globalOpts);
30590
+ stepSuccess("Base recorded", metadataBaseRef);
30591
+ } catch (err) {
30592
+ const message = err instanceof Error ? err.message : String(err);
30593
+ stepWarning("Base metadata not recorded", message);
30594
+ }
30595
+ }
30244
30596
  const setupResult = await runPostCreateSetup({ config: config2, repo: target, wtPath, branch, globalOpts });
30245
30597
  const failedHooks = setupResult.hooks.filter((h2) => !h2.ok);
30246
30598
  if (failedHooks.length > 0) {
@@ -30346,7 +30698,7 @@ init_log();
30346
30698
  init_git();
30347
30699
  init_resolver();
30348
30700
  init_path_safety();
30349
- import path28 from "path";
30701
+ import path29 from "path";
30350
30702
 
30351
30703
  // src/lib/prompts.ts
30352
30704
  import * as readline2 from "readline";
@@ -30389,6 +30741,7 @@ async function confirm(message, io) {
30389
30741
  }
30390
30742
 
30391
30743
  // src/commands/remove.ts
30744
+ init_stack();
30392
30745
  function registerRemoveCommand(program2) {
30393
30746
  program2.command("remove <branch>").description("Remove worktree(s)").option("-r, --repo <repos...>", "Target specific repo(s)").option("-f, --force", "Force removal even if there are uncommitted changes").option("-y, --yes", "Skip confirmation prompt").action(async (branch, options) => {
30394
30747
  const globalOpts = program2.opts();
@@ -30413,6 +30766,16 @@ function registerRemoveCommand(program2) {
30413
30766
  continue;
30414
30767
  }
30415
30768
  const wtPath = target.path;
30769
+ const stackMetadata = await readStackMetadata(repo.mainPath, globalOpts);
30770
+ const children = getStackChildren(stackMetadata, branch);
30771
+ if (children.length > 0 && !options.force) {
30772
+ stepError("Worktree has dependent branches", `${children.join(", ")} — retarget or remove them first (use --force to override)`);
30773
+ skipCount++;
30774
+ continue;
30775
+ }
30776
+ if (children.length > 0) {
30777
+ stepWarning("Removing a parent with dependent branches", children.join(", "));
30778
+ }
30416
30779
  if (!options.force && !globalOpts.dryRun) {
30417
30780
  try {
30418
30781
  const dirtyFiles = await getDirtyFiles(wtPath);
@@ -30443,7 +30806,7 @@ function registerRemoveCommand(program2) {
30443
30806
  const toClean = planEmptyParentRemoval(repo.wtRoot, repo.mainPath, wtPath);
30444
30807
  indented(`Will remove worktree: ${wtPath}`);
30445
30808
  for (const dir of toClean) {
30446
- indented(`Will clean up empty dir: ${path28.relative(repo.wtRoot, dir)}/`);
30809
+ indented(`Will clean up empty dir: ${path29.relative(repo.wtRoot, dir)}/`);
30447
30810
  }
30448
30811
  const proceed = await confirm("Are you sure you want to delete these?");
30449
30812
  if (!proceed) {
@@ -30473,9 +30836,18 @@ function registerRemoveCommand(program2) {
30473
30836
  if (!globalOpts.dryRun) {
30474
30837
  const removedDirs = cleanupEmptyParents(repo.wtRoot, repo.mainPath, wtPath);
30475
30838
  for (const dir of removedDirs) {
30476
- stepSuccess("Cleaned up empty directory", path28.relative(repo.wtRoot, dir) + "/");
30839
+ stepSuccess("Cleaned up empty directory", path29.relative(repo.wtRoot, dir) + "/");
30477
30840
  }
30478
30841
  }
30842
+ try {
30843
+ await removeStackEntry(repo.mainPath, branch, globalOpts);
30844
+ } catch (err) {
30845
+ const message = err instanceof Error ? err.message : String(err);
30846
+ stepWarning("Stack metadata not removed", message);
30847
+ }
30848
+ if (children.length > 0) {
30849
+ stepWarning("Dependent base metadata retained", children.join(", "));
30850
+ }
30479
30851
  successCount++;
30480
30852
  } catch (err) {
30481
30853
  stepError("Failed to remove worktree", err.message);
@@ -30499,7 +30871,7 @@ init_log();
30499
30871
  init_git();
30500
30872
  init_resolver();
30501
30873
  init_forge();
30502
- import path29 from "path";
30874
+ import path30 from "path";
30503
30875
 
30504
30876
  // src/lib/prune.ts
30505
30877
  function selectMergedCandidates(worktrees, mainPath, prMap) {
@@ -30517,6 +30889,7 @@ function selectMergedCandidates(worktrees, mainPath, prMap) {
30517
30889
 
30518
30890
  // src/commands/prune.ts
30519
30891
  init_path_safety();
30892
+ init_stack();
30520
30893
  function registerPruneCommand(program2) {
30521
30894
  program2.command("prune").description("Remove worktrees whose branch has a merged PR").option("-r, --repo <repos...>", "Target specific repo(s)").option("-f, --force", "Remove even if there are uncommitted changes").option("-y, --yes", "Skip confirmation prompt").action(async (options) => {
30522
30895
  const globalOpts = program2.opts();
@@ -30540,6 +30913,7 @@ function registerPruneCommand(program2) {
30540
30913
  }
30541
30914
  try {
30542
30915
  const worktrees = await getWorktreeList(repo.mainPath);
30916
+ const stackMetadata = await readStackMetadata(repo.mainPath, globalOpts);
30543
30917
  const branches = worktrees.filter((wt) => wt.path !== repo.mainPath && wt.branch).map((wt) => wt.branch);
30544
30918
  if (branches.length === 0) {
30545
30919
  continue;
@@ -30564,6 +30938,15 @@ function registerPruneCommand(program2) {
30564
30938
  continue;
30565
30939
  }
30566
30940
  const wtInfo = worktrees.find((wt) => wt.path === candidate.path);
30941
+ const children = getStackChildren(stackMetadata, candidate.branch);
30942
+ if (children.length > 0 && !options.force) {
30943
+ stepWarning("Skipped — branch has dependent worktrees", `${label}: ${children.join(", ")}`);
30944
+ skippedCount++;
30945
+ continue;
30946
+ }
30947
+ if (children.length > 0) {
30948
+ stepWarning("Pruning parent with dependent worktrees", `${label}: ${children.join(", ")}`);
30949
+ }
30567
30950
  if (wtInfo?.isLocked && !options.force) {
30568
30951
  stepWarning("Skipped — worktree is locked \uD83D\uDD12", label);
30569
30952
  skippedCount++;
@@ -30644,9 +31027,15 @@ function registerPruneCommand(program2) {
30644
31027
  if (!globalOpts.dryRun) {
30645
31028
  const removedDirs = cleanupEmptyParents(repo.wtRoot, repo.mainPath, candidate.path);
30646
31029
  for (const dir of removedDirs) {
30647
- stepSuccess("Cleaned up empty directory", path29.relative(repo.wtRoot, dir) + "/");
31030
+ stepSuccess("Cleaned up empty directory", path30.relative(repo.wtRoot, dir) + "/");
30648
31031
  }
30649
31032
  }
31033
+ try {
31034
+ await removeStackEntry(repo.mainPath, candidate.branch, globalOpts);
31035
+ } catch (err) {
31036
+ const message = err instanceof Error ? err.message : String(err);
31037
+ stepWarning("Stack metadata not removed", message);
31038
+ }
30650
31039
  removedCount++;
30651
31040
  }
30652
31041
  if (removedCount === 0 && skippedCount === 0) {
@@ -30713,13 +31102,15 @@ function formatRelativeTime(isoTimestamp) {
30713
31102
  // src/commands/ls.ts
30714
31103
  init_owner();
30715
31104
  init_source();
30716
- import path30 from "path";
30717
- import fs22 from "fs";
31105
+ init_stack();
31106
+ init_stack();
31107
+ import path31 from "path";
31108
+ import fs23 from "fs";
30718
31109
  function buildLsJson(reposData) {
30719
31110
  const result = [];
30720
31111
  for (const repo of reposData) {
30721
31112
  for (const wt of repo.worktrees) {
30722
- const branch = wt.branch || path30.basename(wt.path);
31113
+ const branch = wt.branch || path31.basename(wt.path);
30723
31114
  const sha = (wt.commit || "0000000").substring(0, 7);
30724
31115
  let status = "clean";
30725
31116
  if (wt.path === repo.mainPath) {
@@ -30746,6 +31137,8 @@ function buildLsJson(reposData) {
30746
31137
  }
30747
31138
  }
30748
31139
  const entry = { repo: repo.name, branch, sha, status };
31140
+ if (wt.base)
31141
+ entry.base = wt.base;
30749
31142
  if (pr)
30750
31143
  entry.pr = pr;
30751
31144
  if (ownerStr)
@@ -30784,7 +31177,7 @@ function registerLsCommand(program2) {
30784
31177
  };
30785
31178
  try {
30786
31179
  const worktrees = await getWorktreeList(repo.mainPath);
30787
- const maxBranchLen = Math.max(...worktrees.map((wt) => (wt.branch || "main").length));
31180
+ const stackMetadata = await readStackMetadata(repo.mainPath, globalOpts);
30788
31181
  let prMap = null;
30789
31182
  if (options.pr) {
30790
31183
  const branches = worktrees.filter((wt) => wt.path !== repo.mainPath && wt.branch).map((wt) => wt.branch);
@@ -30799,9 +31192,20 @@ function registerLsCommand(program2) {
30799
31192
  }
30800
31193
  }
30801
31194
  }
30802
- for (const wt of worktrees) {
30803
- const branch = wt.branch || path30.basename(wt.path);
30804
- const paddedBranch = branch.padEnd(maxBranchLen + 2);
31195
+ const displayItems = buildStackHierarchy(worktrees, (wt) => wt.branch || path31.basename(wt.path), (wt) => {
31196
+ const branch = wt.branch || path31.basename(wt.path);
31197
+ return stackMetadata.branches[branch]?.baseRef ?? prMap?.get(branch)?.baseRefName;
31198
+ }, (a2, b) => {
31199
+ if (a2.path === repo.mainPath && b.path !== repo.mainPath)
31200
+ return -1;
31201
+ if (a2.path !== repo.mainPath && b.path === repo.mainPath)
31202
+ return 1;
31203
+ return (a2.branch || path31.basename(a2.path)).localeCompare(b.branch || path31.basename(b.path));
31204
+ });
31205
+ const maxBranchLen = Math.max(0, ...displayItems.map(({ item, prefix }) => `${prefix}${item.branch || path31.basename(item.path)}`.length));
31206
+ for (const { item: wt, prefix } of displayItems) {
31207
+ const branch = wt.branch || path31.basename(wt.path);
31208
+ const paddedBranch = `${prefix}${branch}`.padEnd(maxBranchLen + 2);
30805
31209
  const hash2 = (wt.commit || "0000000").substring(0, 7);
30806
31210
  let statusStr = source_default.dim("clean");
30807
31211
  let isMissing = false;
@@ -30811,7 +31215,7 @@ function registerLsCommand(program2) {
30811
31215
  statusStr = source_default.blue("[main checkout]");
30812
31216
  } else if (wt.isLocked) {
30813
31217
  statusStr = source_default.red("locked \uD83D\uDD12");
30814
- } else if (fs22.existsSync(wt.path)) {
31218
+ } else if (fs23.existsSync(wt.path)) {
30815
31219
  try {
30816
31220
  dirtyFiles = await getDirtyFiles(wt.path);
30817
31221
  if (dirtyFiles.length > 0) {
@@ -30832,6 +31236,8 @@ function registerLsCommand(program2) {
30832
31236
  prSegment = ` #${prInfo.number} ${renderDisplayState(display)} ${source_default.dim(prInfo.url)}`;
30833
31237
  }
30834
31238
  let ownerSuffix = "";
31239
+ const baseRef = wt.branch ? stackMetadata.branches[wt.branch]?.baseRef ?? prInfo?.baseRefName : prInfo?.baseRefName;
31240
+ const baseSuffix = baseRef ? ` base ${baseRef}` : "";
30835
31241
  let ownership = null;
30836
31242
  if (wt.path !== repo.mainPath) {
30837
31243
  ownership = await resolveOwnership({
@@ -30851,13 +31257,14 @@ function registerLsCommand(program2) {
30851
31257
  path: wt.path,
30852
31258
  branch: wt.branch,
30853
31259
  commit: wt.commit,
31260
+ base: baseRef,
30854
31261
  isLocked: wt.isLocked,
30855
31262
  dirtyFiles,
30856
31263
  isMissing,
30857
31264
  isError
30858
31265
  });
30859
31266
  if (!options.json) {
30860
- info(` ${paddedBranch} ${hash2} ${statusStr}${prSegment}${ownerSuffix}`);
31267
+ info(` ${paddedBranch} ${hash2} ${statusStr}${baseSuffix}${prSegment}${ownerSuffix}`);
30861
31268
  }
30862
31269
  }
30863
31270
  } catch (err) {
@@ -30934,10 +31341,11 @@ end
30934
31341
  init_config();
30935
31342
  init_log();
30936
31343
  init_git();
30937
- import fs23 from "fs";
31344
+ import fs24 from "fs";
30938
31345
  init_resolver();
31346
+ init_stack();
30939
31347
  function registerRebaseCommand(program2) {
30940
- program2.command("rebase <branch>").description("fetch + rebase vs main branch").option("--repo <repos...>", "comma-separated list of repos to target").action(async (branch, _options, cmd) => {
31348
+ program2.command("rebase <branch>").description("fetch + rebase worktree onto its base").option("--repo <repos...>", "comma-separated list of repos to target").option("--onto <ref>", "Override the recorded base ref for this rebase").action(async (branch, options, cmd) => {
30941
31349
  const opts = cmd.optsWithGlobals();
30942
31350
  const config2 = loadConfig();
30943
31351
  const targetRepos = parseRepoFlag(opts.repo);
@@ -30948,30 +31356,58 @@ function registerRebaseCommand(program2) {
30948
31356
  repoHeader(repo.name);
30949
31357
  const mainBranch = await resolveMainBranch(repo, config2);
30950
31358
  const wtPath = getWorktreePath(repo, branch);
30951
- if (!fs23.existsSync(wtPath)) {
31359
+ if (!fs24.existsSync(wtPath)) {
30952
31360
  stepError("No worktree found", `${wtPath} (skipped)`);
30953
31361
  failCount++;
30954
31362
  continue;
30955
31363
  }
30956
31364
  let rebaseStarted = false;
30957
31365
  try {
30958
- const resolvedRemote = await resolveBaseRemote(repo.mainPath, mainBranch);
30959
- await gitExec(["-C", repo.mainPath, "fetch", resolvedRemote, "--", mainBranch], opts);
30960
- const commit = await getLatestCommit(repo.mainPath, `${resolvedRemote}/${mainBranch}`);
30961
- stepProgress(`Fetching ${resolvedRemote}/${mainBranch}...`, `${commit.hash} "${commit.subject}"`);
30962
- stepProgress(`Rebasing ${branch} onto main...`);
31366
+ const metadata = await readStackMetadata(repo.mainPath, opts);
31367
+ const recorded = metadata.branches[branch];
31368
+ const resolvedRemote = !options.onto && !recorded?.explicit ? await resolveBaseRemote(repo.mainPath, mainBranch) : undefined;
31369
+ const defaultBase = resolvedRemote ? `${resolvedRemote}/${mainBranch}` : mainBranch;
31370
+ const baseRef = options.onto || (recorded?.explicit ? recorded.baseRef : defaultBase);
31371
+ const shouldFetchMain = !options.onto && (!recorded || !recorded.explicit);
31372
+ if (shouldFetchMain) {
31373
+ if (!resolvedRemote) {
31374
+ throw new Error(`Could not determine the remote for base branch '${mainBranch}'`);
31375
+ }
31376
+ await gitExec(["-C", repo.mainPath, "fetch", resolvedRemote, "--", mainBranch], opts);
31377
+ const commit = await getLatestCommit(repo.mainPath, defaultBase);
31378
+ stepProgress(`Fetching ${resolvedRemote}/${mainBranch}...`, `${commit.hash} "${commit.subject}"`);
31379
+ } else {
31380
+ stepProgress("Using recorded base", baseRef);
31381
+ }
31382
+ const baseSha = await resolveCommitSha(repo.mainPath, baseRef, opts);
31383
+ stepProgress(`Rebasing ${branch} onto ${baseRef}...`);
30963
31384
  rebaseStarted = true;
30964
- const rebaseOut = await gitExec(["-C", wtPath, "rebase", "--", `${resolvedRemote}/${mainBranch}`], opts);
31385
+ const rebaseOut = await gitExec(["-C", wtPath, "rebase", "--", baseRef], opts);
30965
31386
  if (rebaseOut.includes("is up to date") || rebaseOut.includes("up-to-date")) {
30966
31387
  stepSuccess("Up to date", "0 commits replayed");
30967
31388
  } else {
30968
- const count2 = await gitExec(["-C", wtPath, "rev-list", "--count", `${resolvedRemote}/${mainBranch}..HEAD`], opts).then((s) => s.trim());
31389
+ const count2 = await gitExec(["-C", wtPath, "rev-list", "--count", `${baseRef}..HEAD`], opts).then((s) => s.trim());
30969
31390
  stepSuccess("Rebased", `${count2} commits replayed`);
30970
31391
  }
31392
+ if (recorded || options.onto) {
31393
+ const metadataBaseRef = options.onto || (recorded?.explicit ? baseRef : mainBranch);
31394
+ try {
31395
+ await recordStackEntry(repo.mainPath, branch, {
31396
+ baseRef: metadataBaseRef,
31397
+ baseSha,
31398
+ explicit: options.onto ? true : recorded?.explicit ?? true,
31399
+ createdAt: recorded?.createdAt || new Date().toISOString()
31400
+ }, opts);
31401
+ stepSuccess("Base recorded", metadataBaseRef);
31402
+ } catch (err) {
31403
+ const message = err instanceof Error ? err.message : String(err);
31404
+ stepWarning("Base metadata not updated", message);
31405
+ }
31406
+ }
30971
31407
  successCount++;
30972
31408
  } catch (err) {
30973
31409
  if (!rebaseStarted) {
30974
- stepError("Rebase skipped — could not fetch base branch:", err.message.split(`
31410
+ stepError("Rebase skipped — could not resolve base:", err.message.split(`
30975
31411
  `)[0] ?? err.message);
30976
31412
  failCount++;
30977
31413
  continue;
@@ -31046,8 +31482,8 @@ init_execa();
31046
31482
  init_config();
31047
31483
  init_log();
31048
31484
  init_resolver();
31049
- import fs24 from "fs";
31050
- import path31 from "path";
31485
+ import fs25 from "fs";
31486
+ import path32 from "path";
31051
31487
  function registerSyncCommand(program2) {
31052
31488
  program2.command("sync <branch>").description("re-copy sync files + run post_sync").option("--repo <repos...>", "comma-separated list of repos to target").action(async (branch, _options, cmd) => {
31053
31489
  const opts = cmd.optsWithGlobals();
@@ -31058,19 +31494,19 @@ function registerSyncCommand(program2) {
31058
31494
  for (const repo of repos) {
31059
31495
  repoHeader(repo.name);
31060
31496
  const wtPath = getWorktreePath(repo, branch);
31061
- if (!fs24.existsSync(wtPath)) {
31497
+ if (!fs25.existsSync(wtPath)) {
31062
31498
  stepWarning("No worktree found", `${wtPath} (skipped)`);
31063
31499
  continue;
31064
31500
  }
31065
31501
  try {
31066
31502
  if (repo.config.sync_files) {
31067
31503
  for (const file2 of repo.config.sync_files) {
31068
- const src = path31.join(repo.mainPath, file2);
31069
- const dest = path31.join(wtPath, file2);
31070
- if (fs24.existsSync(src)) {
31504
+ const src = path32.join(repo.mainPath, file2);
31505
+ const dest = path32.join(wtPath, file2);
31506
+ if (fs25.existsSync(src)) {
31071
31507
  if (!opts.dryRun) {
31072
- fs24.mkdirSync(path31.dirname(dest), { recursive: true });
31073
- fs24.copyFileSync(src, dest);
31508
+ fs25.mkdirSync(path32.dirname(dest), { recursive: true });
31509
+ fs25.copyFileSync(src, dest);
31074
31510
  }
31075
31511
  stepSuccess(`Synced ${file2}`);
31076
31512
  }
@@ -31120,15 +31556,15 @@ function registerSyncCommand(program2) {
31120
31556
  process.exit(1);
31121
31557
  }
31122
31558
  }
31123
- const wtNodeModules = path31.join(wtPath, "node_modules");
31124
- if (fs24.existsSync(wtNodeModules) && fs24.lstatSync(wtNodeModules).isSymbolicLink()) {
31559
+ const wtNodeModules = path32.join(wtPath, "node_modules");
31560
+ if (fs25.existsSync(wtNodeModules) && fs25.lstatSync(wtNodeModules).isSymbolicLink()) {
31125
31561
  const lockfiles = ["yarn.lock", "package-lock.json", "pnpm-lock.yaml", "bun.lockb", "bun.lock"];
31126
31562
  for (const lock of lockfiles) {
31127
- const mainLock = path31.join(repo.mainPath, lock);
31128
- const wtLock = path31.join(wtPath, lock);
31129
- if (fs24.existsSync(mainLock) && fs24.existsSync(wtLock)) {
31130
- const mainContent = fs24.readFileSync(mainLock);
31131
- const wtContent = fs24.readFileSync(wtLock);
31563
+ const mainLock = path32.join(repo.mainPath, lock);
31564
+ const wtLock = path32.join(wtPath, lock);
31565
+ if (fs25.existsSync(mainLock) && fs25.existsSync(wtLock)) {
31566
+ const mainContent = fs25.readFileSync(mainLock);
31567
+ const wtContent = fs25.readFileSync(wtLock);
31132
31568
  if (!mainContent.equals(wtContent)) {
31133
31569
  stepWarning(`${lock} differs from main — node_modules is symlinked`);
31134
31570
  indented(`Run: wtx deps ${branch} --repo ${repo.name} --install`);
@@ -31157,7 +31593,7 @@ init_config();
31157
31593
  init_log();
31158
31594
  init_resolver();
31159
31595
  init_deps();
31160
- import fs25 from "fs";
31596
+ import fs26 from "fs";
31161
31597
  function registerDepsCommand(program2) {
31162
31598
  program2.command("deps [branch]").description("Manage node_modules strategy per worktree").option("-r, --repo <repos...>", "Target specific repo(s)").option("--install", "Switch to independent node_modules (run install)").option("--symlink", "Switch to legacy symlinked node_modules").option("--json", "Output machine-readable JSON state").action(async (branch, options) => {
31163
31599
  const globalOpts = program2.opts();
@@ -31172,16 +31608,29 @@ function registerDepsCommand(program2) {
31172
31608
  if (!options.json)
31173
31609
  repoHeader(repo.name);
31174
31610
  if (!branch) {
31175
- const state = detectDepsState(repo.mainPath, repo.mainPath);
31176
- if (options.json) {
31177
- jsonResults[repo.name] = { main: state };
31611
+ if (options.install) {
31612
+ const ok = await switchToInstall(repo.mainPath, globalOpts);
31613
+ if (!ok)
31614
+ process.exitCode = 1;
31615
+ if (options.json) {
31616
+ jsonResults[repo.name] = detectDepsState(repo.mainPath, repo.mainPath);
31617
+ }
31618
+ } else if (options.symlink) {
31619
+ if (!options.json) {
31620
+ stepWarning("Symlink strategy requires a worktree", "usage: wtx deps <branch> --symlink");
31621
+ }
31178
31622
  } else {
31179
- info(` Main repo package manager: ${state.packageManager ?? "none detected"}`);
31623
+ const state = detectDepsState(repo.mainPath, repo.mainPath);
31624
+ if (options.json) {
31625
+ jsonResults[repo.name] = { main: state };
31626
+ } else {
31627
+ info(` Main repo package manager: ${state.packageManager ?? "none detected"}`);
31628
+ }
31180
31629
  }
31181
31630
  continue;
31182
31631
  }
31183
31632
  const wtPath = getWorktreePath(repo, branch);
31184
- if (!fs25.existsSync(wtPath)) {
31633
+ if (!fs26.existsSync(wtPath)) {
31185
31634
  if (options.json) {
31186
31635
  jsonResults[repo.name] = { error: "No worktree found", branch };
31187
31636
  } else {
@@ -31239,7 +31688,7 @@ function registerDepsCommand(program2) {
31239
31688
  init_config();
31240
31689
  init_log();
31241
31690
  init_resolver();
31242
- import fs26 from "fs";
31691
+ import fs27 from "fs";
31243
31692
  init_git();
31244
31693
  async function resolveMainCheckoutPath(repoCtx, branch) {
31245
31694
  try {
@@ -31264,7 +31713,7 @@ function registerOpenCommand(program2) {
31264
31713
  for (const repo of repos) {
31265
31714
  const wtPath = getWorktreePath(repo, branch);
31266
31715
  let targetPath = wtPath;
31267
- if (!fs26.existsSync(wtPath)) {
31716
+ if (!fs27.existsSync(wtPath)) {
31268
31717
  const mainCheckoutPath = await resolveMainCheckoutPath(repo, branch);
31269
31718
  if (!mainCheckoutPath) {
31270
31719
  continue;
@@ -31294,14 +31743,14 @@ init_config();
31294
31743
  init_log();
31295
31744
  init_git();
31296
31745
  init_resolver();
31297
- import path33 from "path";
31746
+ import path34 from "path";
31298
31747
 
31299
31748
  // src/lib/rename-worktree.ts
31300
31749
  init_git();
31301
31750
  init_resolver();
31302
31751
  init_path_safety();
31303
- import fs27 from "fs";
31304
- import path32 from "path";
31752
+ import fs28 from "fs";
31753
+ import path33 from "path";
31305
31754
  async function getUpstream(wtPath) {
31306
31755
  try {
31307
31756
  const out = await gitExec(["-C", wtPath, "rev-parse", "--abbrev-ref", "--symbolic-full-name", "@{u}"], {});
@@ -31321,7 +31770,7 @@ async function planRename(repo, oldBranch, newBranch, opts) {
31321
31770
  throw new Error(`Worktree '${oldBranch}' is locked — unlock it before renaming`);
31322
31771
  }
31323
31772
  const newPath = `${repo.wtRoot}/${newBranch}`;
31324
- if (fs27.existsSync(newPath)) {
31773
+ if (fs28.existsSync(newPath)) {
31325
31774
  throw new Error(`Target path already exists: ${newPath}`);
31326
31775
  }
31327
31776
  if (!opts.dryRun && await localBranchExists(repo.mainPath, newBranch, opts)) {
@@ -31345,7 +31794,7 @@ async function renameWorktree(params) {
31345
31794
  }
31346
31795
  await gitExec(["-C", planned.worktreePath, "branch", "-m", oldBranch, newBranch], opts);
31347
31796
  try {
31348
- fs27.mkdirSync(path32.dirname(planned.newPath), { recursive: true });
31797
+ fs28.mkdirSync(path33.dirname(planned.newPath), { recursive: true });
31349
31798
  await gitExec(["-C", repo.mainPath, "worktree", "move", planned.worktreePath, planned.newPath], opts);
31350
31799
  } catch (err) {
31351
31800
  try {
@@ -31369,6 +31818,7 @@ async function renameWorktree(params) {
31369
31818
  }
31370
31819
 
31371
31820
  // src/commands/rename.ts
31821
+ init_stack();
31372
31822
  function registerRenameCommand(program2) {
31373
31823
  program2.command("rename <old-branch> <new-branch>").description("Rename a worktree's branch and move its checkout to the new location").option("-r, --repo <repos...>", "Target specific repo(s)").action(async (oldBranch, newBranch, options) => {
31374
31824
  const globalOpts = program2.opts();
@@ -31406,12 +31856,19 @@ function registerRenameCommand(program2) {
31406
31856
  stepProgress(`Renaming ${oldBranch} → ${newBranch}...`);
31407
31857
  const outcome = await renameWorktree({ repo, oldBranch, newBranch, opts: globalOpts });
31408
31858
  for (const dir of outcome.cleanedDirs) {
31409
- stepSuccess("Cleaned up empty directory", path33.relative(repo.wtRoot, dir) + "/");
31859
+ stepSuccess("Cleaned up empty directory", path34.relative(repo.wtRoot, dir) + "/");
31410
31860
  }
31411
31861
  stepSuccess("Renamed", `${outcome.oldPath} → ${outcome.newPath}`);
31412
31862
  if (outcome.upstream) {
31413
31863
  indented(`Upstream still tracks '${outcome.upstream}' — after pushing run: git push -u origin ${newBranch}`);
31414
31864
  }
31865
+ try {
31866
+ await renameStackEntry(repo.mainPath, oldBranch, newBranch, globalOpts);
31867
+ stepSuccess("Updated stack metadata", newBranch);
31868
+ } catch (err) {
31869
+ const message = err instanceof Error ? err.message : String(err);
31870
+ stepWarning("Stack metadata not updated", message);
31871
+ }
31415
31872
  summary(`Done — renamed ${oldBranch} to ${newBranch}`);
31416
31873
  } catch (err) {
31417
31874
  stepError("Rename failed", err.message);
@@ -31428,8 +31885,9 @@ init_resolver();
31428
31885
  init_deps();
31429
31886
  init_forge();
31430
31887
  init_types2();
31431
- import fs28 from "fs";
31888
+ import fs29 from "fs";
31432
31889
  init_owner();
31890
+ init_stack();
31433
31891
  init_source();
31434
31892
  function buildStatusJson(item) {
31435
31893
  const entry = {
@@ -31446,11 +31904,14 @@ function buildStatusJson(item) {
31446
31904
  entry.behind = item.behind;
31447
31905
  entry.deps = item.deps;
31448
31906
  if (item.prInfo) {
31449
- entry.pr = {
31907
+ const prEntry = {
31450
31908
  number: item.prInfo.number,
31451
31909
  state: item.prInfo.state,
31452
31910
  url: item.prInfo.url
31453
31911
  };
31912
+ if (item.prInfo.baseRefName)
31913
+ prEntry.base = item.prInfo.baseRefName;
31914
+ entry.pr = prEntry;
31454
31915
  }
31455
31916
  if (item.ownership && !item.ownership.mine && item.ownership.author) {
31456
31917
  entry.owner = item.ownership.author;
@@ -31458,10 +31919,14 @@ function buildStatusJson(item) {
31458
31919
  if (item.rebase) {
31459
31920
  entry.rebase = item.rebase;
31460
31921
  }
31922
+ if (item.base)
31923
+ entry.base = item.base;
31924
+ if (item.baseChanged)
31925
+ entry.baseChanged = true;
31461
31926
  return entry;
31462
31927
  }
31463
31928
  function registerStatusCommand(program2) {
31464
- program2.command("status <branch>").description("Show worktree status across repos").option("-r, --repo <repos...>", "Target specific repo(s)").option("--json", "Output machine-readable JSON").action(async (branch, options) => {
31929
+ program2.command("status <branch>").description("Show worktree status across repos").option("-r, --repo <repos...>", "Target specific repo(s)").option("--json", "Output machine-readable JSON").option("--base <ref>", "Override the recorded base ref for this status check").action(async (branch, options) => {
31465
31930
  const globalOpts = program2.opts();
31466
31931
  const config2 = loadConfig();
31467
31932
  const repoFilter = parseRepoFlag(options.repo);
@@ -31470,17 +31935,33 @@ function registerStatusCommand(program2) {
31470
31935
  const jsonOutputs = [];
31471
31936
  for (const repo of repos) {
31472
31937
  const wtPath = getWorktreePath(repo, branch);
31473
- if (!fs28.existsSync(wtPath)) {
31938
+ if (!fs29.existsSync(wtPath)) {
31474
31939
  continue;
31475
31940
  }
31476
31941
  found++;
31477
31942
  const dirtyFiles = await getDirtyFiles(wtPath);
31478
31943
  let ahead = null;
31479
31944
  let behind = null;
31945
+ let baseRef;
31946
+ let baseChanged = false;
31947
+ let usingStackBase = false;
31480
31948
  try {
31481
31949
  const mainBranch = await resolveMainBranch(repo, config2);
31482
- const resolvedRemote = await resolveBaseRemote(repo.mainPath, mainBranch);
31483
- const countOutput = await gitExec(["-C", wtPath, "rev-list", "--left-right", "--count", `${resolvedRemote}/${mainBranch}...HEAD`], { verbose: globalOpts.verbose });
31950
+ const metadata = await readStackMetadata(repo.mainPath, globalOpts);
31951
+ const recorded = metadata.branches[branch];
31952
+ const resolvedRemote = !options.base && !recorded?.explicit ? await resolveBaseRemote(repo.mainPath, mainBranch) : undefined;
31953
+ const defaultBase = resolvedRemote ? `${resolvedRemote}/${mainBranch}` : mainBranch;
31954
+ baseRef = options.base || (recorded?.explicit ? recorded.baseRef : defaultBase);
31955
+ usingStackBase = Boolean(options.base || recorded?.explicit);
31956
+ if (recorded?.explicit && !options.base && !globalOpts.dryRun) {
31957
+ try {
31958
+ const currentBaseSha = await resolveCommitSha(repo.mainPath, recorded.baseRef, globalOpts);
31959
+ baseChanged = currentBaseSha !== recorded.baseSha;
31960
+ } catch {
31961
+ baseChanged = true;
31962
+ }
31963
+ }
31964
+ const countOutput = await gitExec(["-C", wtPath, "rev-list", "--left-right", "--count", `${baseRef}...HEAD`], { verbose: globalOpts.verbose });
31484
31965
  const parts = countOutput.trim().split(/\s+/);
31485
31966
  behind = parts[0] ? parseInt(parts[0], 10) : null;
31486
31967
  ahead = parts[1] ? parseInt(parts[1], 10) : null;
@@ -31519,7 +32000,9 @@ function registerStatusCommand(program2) {
31519
32000
  prInfo,
31520
32001
  ownership,
31521
32002
  deps: depsState,
31522
- rebase: rebaseStatus
32003
+ rebase: rebaseStatus,
32004
+ base: usingStackBase ? baseRef : undefined,
32005
+ baseChanged
31523
32006
  }));
31524
32007
  continue;
31525
32008
  }
@@ -31533,10 +32016,16 @@ function registerStatusCommand(program2) {
31533
32016
  indented(` ${f}`);
31534
32017
  }
31535
32018
  }
32019
+ if (usingStackBase && baseRef) {
32020
+ info(` Base: ${baseRef}`);
32021
+ if (baseChanged) {
32022
+ info(` Base state: moved since stack entry`);
32023
+ }
32024
+ }
31536
32025
  if (ahead !== null && behind !== null) {
31537
- info(` vs main: ${ahead} ahead, ${behind} behind`);
32026
+ info(` ${usingStackBase ? "vs base" : "vs main"}: ${ahead} ahead, ${behind} behind`);
31538
32027
  } else {
31539
- info(` vs main: unknown`);
32028
+ info(` ${usingStackBase ? "vs base" : "vs main"}: unknown`);
31540
32029
  }
31541
32030
  if (prInfo) {
31542
32031
  const display = derivePrDisplay(prInfo);
@@ -31583,6 +32072,7 @@ init_git();
31583
32072
  init_resolver();
31584
32073
  init_forge();
31585
32074
  init_owner();
32075
+ init_stack();
31586
32076
  init_types2();
31587
32077
  var ATTENTION_STATES = new Set([
31588
32078
  PR_DISPLAY_STATES.CONFLICTED,
@@ -31614,6 +32104,7 @@ async function collectPrRows(repos, config2, verboseFlag) {
31614
32104
  continue;
31615
32105
  try {
31616
32106
  const worktrees = await getWorktreeList(repo.mainPath);
32107
+ const stackMetadata = await readStackMetadata(repo.mainPath, { verbose: verboseFlag, dryRun: false });
31617
32108
  const branches = worktrees.filter((wt) => wt.path !== repo.mainPath && wt.branch).map((wt) => wt.branch);
31618
32109
  if (branches.length === 0)
31619
32110
  continue;
@@ -31643,7 +32134,8 @@ async function collectPrRows(repos, config2, verboseFlag) {
31643
32134
  unresolvedThreads: pr.unresolvedThreads,
31644
32135
  updatedAt: pr.updatedAt,
31645
32136
  authorLogin: pr.authorLogin ?? null,
31646
- ownership
32137
+ ownership,
32138
+ baseRef: pr.baseRefName ?? stackMetadata.branches[branch]?.baseRef ?? null
31647
32139
  });
31648
32140
  }
31649
32141
  } catch (err) {
@@ -31669,8 +32161,9 @@ function renderTable(rows) {
31669
32161
  const threads = row.unresolvedThreads > 0 ? `${row.unresolvedThreads} thread${row.unresolvedThreads > 1 ? "s" : ""}` : null;
31670
32162
  const details = [row.checksSummary, threads].filter(Boolean).join(" · ");
31671
32163
  const detailSuffix = details ? ` ${details}` : "";
32164
+ const baseSuffix = row.baseRef ? ` → ${row.baseRef}` : "";
31672
32165
  const authorTag = row.ownership && !row.ownership.mine && row.ownership.author ? ` ${source_default.dim(row.ownership.author)}` : "";
31673
- info(` #${row.prNumber} ${paddedBranch} ${renderDisplayState(row.prDisplay)}${detailSuffix}${authorTag} ${formatRelativeTime(row.updatedAt)} ${source_default.dim(row.url)}`);
32166
+ info(` #${row.prNumber} ${paddedBranch} ${renderDisplayState(row.prDisplay)}${baseSuffix}${detailSuffix}${authorTag} ${formatRelativeTime(row.updatedAt)} ${source_default.dim(row.url)}`);
31674
32167
  }
31675
32168
  }
31676
32169
  }
@@ -31683,7 +32176,8 @@ function toJsonOutput(rows) {
31683
32176
  awaitingReview: row.prDisplay.awaitingReview,
31684
32177
  approved: row.prDisplay.approved,
31685
32178
  prNumber: row.prNumber,
31686
- author: row.authorLogin
32179
+ author: row.authorLogin,
32180
+ base: row.baseRef
31687
32181
  }));
31688
32182
  }
31689
32183
  function registerPrsCommand(program2) {
@@ -31741,8 +32235,8 @@ function registerPrsCommand(program2) {
31741
32235
 
31742
32236
  // src/commands/skill.ts
31743
32237
  init_log();
31744
- import fs29 from "fs";
31745
- import path34 from "path";
32238
+ import fs30 from "fs";
32239
+ import path35 from "path";
31746
32240
  import { URL as URL2 } from "url";
31747
32241
  var __dirname2 = new URL2(".", import.meta.url).pathname;
31748
32242
  var COMMON_MARKDOWN = `# wtx — Worktree Manager
@@ -31771,8 +32265,12 @@ Lists all worktrees across repositories.
31771
32265
  Shows git statuses for all worktrees.
31772
32266
 
31773
32267
  ### \`wtx rebase <branch>\`
31774
- Fetches the main branch from origin and rebases the given worktree's branch onto it.
31775
- - **Flags**: \`-r, --repo <repos...>\`
32268
+ Fetches the configured main branch for an independent worktree, or rebases onto its recorded base for a stacked worktree.
32269
+ - **Flags**: \`-r, --repo <repos...>\`, \`--onto <ref>\` to override the base.
32270
+
32271
+ ### \`wtx stack <branch>\`
32272
+ Shows the recorded parent and descendant branches for a worktree.
32273
+ - **Flags**: \`-r, --repo <repos...>\`, \`--json\`
31776
32274
 
31777
32275
  ### \`wtx sync <branch>\`
31778
32276
  Re-copies \`sync_files\` from the main checkout to the worktree and runs \`post_sync\` hooks. Also checks for package lockfile differences if \`node_modules\` is symlinked.
@@ -31837,15 +32335,23 @@ Hooks (\`post_create\` and \`post_sync\`) support the following template variabl
31837
32335
  \`\`\`bash
31838
32336
  wtx rebase feature-xyz
31839
32337
  \`\`\`
31840
- *Fetches the latest main branch from origin and rebases the \`feature-xyz\` worktrees.*
32338
+ *Fetches the latest main branch for independent work, or uses the recorded parent for a stacked branch.*
32339
+
32340
+ 3. **Creating a stacked branch**:
32341
+ \`\`\`bash
32342
+ wtx create feature-api
32343
+ wtx create feature-ui --base feature-api
32344
+ wtx stack feature-ui
32345
+ \`\`\`
32346
+ *Open the child PR against \`feature-api\`; after the parent merges, retarget the child to main before rebasing it onto main.*
31841
32347
 
31842
- 3. **Syncing environment variables**:
32348
+ 4. **Syncing environment variables**:
31843
32349
  If the \`.env\` file in the main checkout was updated, run:
31844
32350
  \`\`\`bash
31845
32351
  wtx sync feature-xyz
31846
32352
  \`\`\`
31847
32353
 
31848
- 4. **Managing node_modules dependencies**:
32354
+ 5. **Managing node_modules dependencies**:
31849
32355
  If a worktree requires different dependencies than the main branch (e.g. you're testing an upgrade):
31850
32356
  \`\`\`bash
31851
32357
  wtx deps feature-xyz --install
@@ -31884,9 +32390,9 @@ function registerSkillCommand(program2) {
31884
32390
  error51(`Unknown platform: ${platform2}`);
31885
32391
  process.exit(1);
31886
32392
  }
31887
- let projectRoot = path34.resolve(__dirname2, "..", "..");
31888
- let skillPath = path34.join(projectRoot, "skills", `${platform2}.md`);
31889
- if (fs29.existsSync(skillPath)) {
32393
+ let projectRoot = path35.resolve(__dirname2, "..", "..");
32394
+ let skillPath = path35.join(projectRoot, "skills", `${platform2}.md`);
32395
+ if (fs30.existsSync(skillPath)) {
31890
32396
  process.stdout.write(skillPath + `
31891
32397
  `);
31892
32398
  } else {
@@ -31932,7 +32438,8 @@ init_git();
31932
32438
  init_deps();
31933
32439
  import readline3 from "readline";
31934
32440
  init_path_safety();
31935
- import fs31 from "fs";
32441
+ init_stack();
32442
+ import fs32 from "fs";
31936
32443
  async function runMcpServer(opts = {}) {
31937
32444
  const input = opts.input ?? process.stdin;
31938
32445
  const output = opts.output ?? process.stdout;
@@ -32025,7 +32532,7 @@ async function handleRequest(req, send, config2, opts) {
32025
32532
  description: "Get status of a specific worktree",
32026
32533
  inputSchema: {
32027
32534
  type: "object",
32028
- properties: { repo: { type: "string" }, branch: { type: "string" } },
32535
+ properties: { repo: { type: "string" }, branch: { type: "string" }, base: { type: "string" } },
32029
32536
  required: ["repo", "branch"]
32030
32537
  }
32031
32538
  },
@@ -32049,10 +32556,10 @@ async function handleRequest(req, send, config2, opts) {
32049
32556
  },
32050
32557
  {
32051
32558
  name: "rebase_worktree",
32052
- description: "Rebase a worktree against main branch",
32559
+ description: "Rebase a worktree against its recorded base or an explicit ref",
32053
32560
  inputSchema: {
32054
32561
  type: "object",
32055
- properties: { repo: { type: "string" }, branch: { type: "string" } },
32562
+ properties: { repo: { type: "string" }, branch: { type: "string" }, onto: { type: "string" } },
32056
32563
  required: ["repo", "branch"]
32057
32564
  }
32058
32565
  }
@@ -32111,16 +32618,18 @@ async function handleToolCall(name, args, config2, _opts) {
32111
32618
  const items = [];
32112
32619
  for (const repo of repos) {
32113
32620
  const wts = await getWorktreeList(repo.mainPath);
32621
+ const stackMetadata = await readStackMetadata(repo.mainPath, { verbose: _opts.verbose === true, dryRun: false });
32114
32622
  for (const wt of wts) {
32115
32623
  if (!wt.path.startsWith(repo.wtRoot))
32116
32624
  continue;
32117
- const dirtyCount = fs31.existsSync(wt.path) ? (await getDirtyFiles(wt.path)).length : 0;
32625
+ const dirtyCount = fs32.existsSync(wt.path) ? (await getDirtyFiles(wt.path)).length : 0;
32118
32626
  items.push({
32119
32627
  repo: repo.name,
32120
32628
  branch: wt.branch || null,
32121
32629
  path: wt.path,
32122
32630
  sha: wt.commit ? wt.commit.substring(0, 7) : null,
32123
- dirtyFiles: dirtyCount
32631
+ dirtyFiles: dirtyCount,
32632
+ base: wt.branch && stackMetadata.branches[wt.branch]?.explicit ? stackMetadata.branches[wt.branch]?.baseRef : null
32124
32633
  });
32125
32634
  }
32126
32635
  }
@@ -32133,18 +32642,27 @@ async function handleToolCall(name, args, config2, _opts) {
32133
32642
  if (!validateSafeBranchName(args.branch)) {
32134
32643
  throw { isToolError: true, message: "unsafe branch name" };
32135
32644
  }
32645
+ if (args.base !== undefined && typeof args.base !== "string") {
32646
+ throw { isSchemaError: true, message: "base must be a string" };
32647
+ }
32648
+ if (typeof args.base === "string" && !validateSafeBranchName(args.base)) {
32649
+ throw { isToolError: true, message: "unsafe base ref" };
32650
+ }
32136
32651
  const repo = resolveRepos(config2, [args.repo])[0];
32137
32652
  const wtPath = getWorktreePath(repo, args.branch);
32138
- if (!fs31.existsSync(wtPath)) {
32653
+ if (!fs32.existsSync(wtPath)) {
32139
32654
  throw { isToolError: true, message: `Worktree not found for branch ${args.branch}` };
32140
32655
  }
32141
32656
  const mainBranch = await resolveMainBranch(repo, config2);
32142
32657
  const resolvedRemote = await resolveBaseRemote(repo.mainPath, mainBranch);
32658
+ const stackMetadata = await readStackMetadata(repo.mainPath, { verbose: _opts.verbose === true, dryRun: false });
32659
+ const recorded = stackMetadata.branches[args.branch];
32660
+ const baseRef = typeof args.base === "string" ? args.base : recorded?.explicit ? recorded.baseRef : `${resolvedRemote}/${mainBranch}`;
32143
32661
  const dirtyFiles = await getDirtyFiles(wtPath);
32144
32662
  let ahead = null;
32145
32663
  let behind = null;
32146
32664
  try {
32147
- const countOutput = await gitExec(["-C", wtPath, "rev-list", "--left-right", "--count", `${resolvedRemote}/${mainBranch}...HEAD`]);
32665
+ const countOutput = await gitExec(["-C", wtPath, "rev-list", "--left-right", "--count", `${baseRef}...HEAD`]);
32148
32666
  const parts = countOutput.trim().split(/\s+/);
32149
32667
  behind = parts[0] ? parseInt(parts[0], 10) : null;
32150
32668
  ahead = parts[1] ? parseInt(parts[1], 10) : null;
@@ -32160,6 +32678,7 @@ async function handleToolCall(name, args, config2, _opts) {
32160
32678
  dirtyCount: dirtyFiles.length,
32161
32679
  ahead,
32162
32680
  behind,
32681
+ base: recorded?.explicit || typeof args.base === "string" ? baseRef : null,
32163
32682
  depsStrategy: depsState.strategy
32164
32683
  })
32165
32684
  }]
@@ -32187,9 +32706,11 @@ async function handleToolCall(name, args, config2, _opts) {
32187
32706
  }
32188
32707
  const mainBranch = await resolveMainBranch(repo, config2);
32189
32708
  const resolvedRemote = await resolveBaseRemote(repo.mainPath, mainBranch);
32709
+ const baseRef = args.base || `${resolvedRemote}/${mainBranch}`;
32190
32710
  if (repo.config.fetch_main_on_create !== false) {
32191
32711
  await gitExec(["-C", repo.mainPath, "fetch", resolvedRemote, "--", mainBranch]);
32192
32712
  }
32713
+ const baseSha = await resolveCommitSha(repo.mainPath, baseRef, { verbose: _opts.verbose === true, dryRun: false });
32193
32714
  const localExists = await localBranchExists(repo.mainPath, args.branch, { verbose: false, dryRun: false });
32194
32715
  const remoteExists = await branchExistsOnRemote(repo.mainPath, args.branch, { verbose: false, dryRun: false }, resolvedRemote);
32195
32716
  const localSha = localExists ? await getLocalBranchSha(repo.mainPath, args.branch, { verbose: false, dryRun: false }) : null;
@@ -32207,6 +32728,14 @@ async function handleToolCall(name, args, config2, _opts) {
32207
32728
  gitArgs.push(wtPath, args.branch);
32208
32729
  }
32209
32730
  await gitExec(gitArgs);
32731
+ if (resolution.kind === "create-new") {
32732
+ await recordStackEntry(repo.mainPath, args.branch, {
32733
+ baseRef: args.base || mainBranch,
32734
+ baseSha,
32735
+ explicit: args.base !== undefined,
32736
+ createdAt: new Date().toISOString()
32737
+ }, { verbose: _opts.verbose === true, dryRun: false });
32738
+ }
32210
32739
  return {
32211
32740
  content: [{ type: "text", text: JSON.stringify({ path: wtPath }) }]
32212
32741
  };
@@ -32235,7 +32764,12 @@ async function handleToolCall(name, args, config2, _opts) {
32235
32764
  throw { isToolError: true, message: `Worktree for ${args.branch} is not registered` };
32236
32765
  }
32237
32766
  const wtPath = target.path;
32238
- if (!args.force && fs31.existsSync(wtPath)) {
32767
+ const stackMetadata = await readStackMetadata(repo.mainPath, { verbose: _opts.verbose === true, dryRun: false });
32768
+ const children = getStackChildren(stackMetadata, args.branch);
32769
+ if (children.length > 0 && args.force !== true) {
32770
+ throw { isToolError: true, message: `Branch has dependent worktrees: ${children.join(", ")}. Use force:true to override.` };
32771
+ }
32772
+ if (!args.force && fs32.existsSync(wtPath)) {
32239
32773
  const dirty = await getDirtyFiles(wtPath);
32240
32774
  if (dirty.length > 0) {
32241
32775
  throw { isToolError: true, message: `Worktree is dirty. Use force:true to remove it.` };
@@ -32243,6 +32777,7 @@ async function handleToolCall(name, args, config2, _opts) {
32243
32777
  }
32244
32778
  await gitExec(["-C", repo.mainPath, "worktree", "remove", args.force ? "--force" : "", wtPath].filter(Boolean));
32245
32779
  cleanupEmptyParents(repo.wtRoot, repo.mainPath, wtPath);
32780
+ await removeStackEntry(repo.mainPath, args.branch, { verbose: _opts.verbose === true, dryRun: false });
32246
32781
  return {
32247
32782
  content: [{ type: "text", text: JSON.stringify({ removed: true }) }]
32248
32783
  };
@@ -32253,18 +32788,46 @@ async function handleToolCall(name, args, config2, _opts) {
32253
32788
  }
32254
32789
  const repo = resolveRepos(config2, [args.repo])[0];
32255
32790
  const wtPath = getWorktreePath(repo, args.branch);
32256
- if (!fs31.existsSync(wtPath)) {
32791
+ if (!fs32.existsSync(wtPath)) {
32257
32792
  throw { isToolError: true, message: `Worktree not found for branch ${args.branch}` };
32258
32793
  }
32259
32794
  const mainBranch = await resolveMainBranch(repo, config2);
32260
32795
  const resolvedRemote = await resolveBaseRemote(repo.mainPath, mainBranch);
32261
- await gitExec(["-C", repo.mainPath, "fetch", resolvedRemote, "--", mainBranch]);
32796
+ if (args.onto !== undefined && typeof args.onto !== "string") {
32797
+ throw { isSchemaError: true, message: "onto must be a string" };
32798
+ }
32799
+ if (typeof args.onto === "string" && !validateSafeBranchName(args.onto)) {
32800
+ throw { isToolError: true, message: "unsafe base ref" };
32801
+ }
32802
+ const stackMetadata = await readStackMetadata(repo.mainPath, { verbose: _opts.verbose === true, dryRun: false });
32803
+ const recorded = stackMetadata.branches[args.branch];
32804
+ const baseRef = args.onto || (recorded?.explicit ? recorded.baseRef : `${resolvedRemote}/${mainBranch}`);
32805
+ if (!args.onto && (!recorded || !recorded.explicit)) {
32806
+ await gitExec(["-C", repo.mainPath, "fetch", resolvedRemote, "--", mainBranch]);
32807
+ }
32808
+ const baseSha = await resolveCommitSha(repo.mainPath, baseRef, { verbose: _opts.verbose === true, dryRun: false });
32262
32809
  try {
32263
- const rebaseOut = await gitExec(["-C", wtPath, "rebase", "--", `${resolvedRemote}/${mainBranch}`]);
32810
+ const rebaseOut = await gitExec(["-C", wtPath, "rebase", "--", baseRef]);
32264
32811
  if (rebaseOut.includes("is up to date") || rebaseOut.includes("up-to-date")) {
32812
+ if (recorded || args.onto) {
32813
+ await recordStackEntry(repo.mainPath, args.branch, {
32814
+ baseRef: args.onto || (recorded?.explicit ? baseRef : mainBranch),
32815
+ baseSha,
32816
+ explicit: args.onto ? true : recorded?.explicit ?? true,
32817
+ createdAt: recorded?.createdAt || new Date().toISOString()
32818
+ }, { verbose: _opts.verbose === true, dryRun: false });
32819
+ }
32265
32820
  return { content: [{ type: "text", text: JSON.stringify({ status: "up-to-date" }) }] };
32266
32821
  } else {
32267
- const count2 = await gitExec(["-C", wtPath, "rev-list", "--count", `${resolvedRemote}/${mainBranch}..HEAD`]).then((s) => s.trim());
32822
+ const count2 = await gitExec(["-C", wtPath, "rev-list", "--count", `${baseRef}..HEAD`]).then((s) => s.trim());
32823
+ if (recorded || args.onto) {
32824
+ await recordStackEntry(repo.mainPath, args.branch, {
32825
+ baseRef: args.onto || (recorded?.explicit ? baseRef : mainBranch),
32826
+ baseSha,
32827
+ explicit: args.onto ? true : recorded?.explicit ?? true,
32828
+ createdAt: recorded?.createdAt || new Date().toISOString()
32829
+ }, { verbose: _opts.verbose === true, dryRun: false });
32830
+ }
32268
32831
  return { content: [{ type: "text", text: JSON.stringify({ status: "rebased", commits: count2 }) }] };
32269
32832
  }
32270
32833
  } catch (err) {
@@ -32354,12 +32917,100 @@ function registerHistoryCommand(program2) {
32354
32917
  });
32355
32918
  }
32356
32919
 
32920
+ // src/commands/stack.ts
32921
+ init_config();
32922
+ init_git();
32923
+ init_resolver();
32924
+ init_stack();
32925
+ init_log();
32926
+ function nodeFor(branch, metadata, worktrees) {
32927
+ const entry = metadata.branches[branch];
32928
+ const worktree = worktrees.find((wt) => wt.branch === branch)?.path ?? null;
32929
+ return {
32930
+ branch,
32931
+ base: entry?.baseRef ?? null,
32932
+ explicit: entry?.explicit ?? false,
32933
+ baseSha: entry?.baseSha ?? null,
32934
+ worktree
32935
+ };
32936
+ }
32937
+ function collectDescendants(metadata, branch, worktrees, seen) {
32938
+ const nodes = [];
32939
+ for (const child of getStackChildren(metadata, branch)) {
32940
+ if (seen.has(child))
32941
+ continue;
32942
+ seen.add(child);
32943
+ nodes.push(nodeFor(child, metadata, worktrees));
32944
+ nodes.push(...collectDescendants(metadata, child, worktrees, seen));
32945
+ }
32946
+ return nodes;
32947
+ }
32948
+ function renderNode(node, prefix) {
32949
+ const base2 = node.base ? ` base ${node.base}` : " base not recorded";
32950
+ const location = node.worktree ? ` ${node.worktree}` : " no local worktree";
32951
+ info(` ${prefix}${node.branch}`);
32952
+ indented(`${" ".repeat(prefix.length)}${base2} · ${location}`);
32953
+ }
32954
+ function registerStackCommand(program2) {
32955
+ program2.command("stack <branch>").description("Show the recorded branch stack").option("-r, --repo <repos...>", "Target specific repo(s)").option("--json", "Output machine-readable JSON").action(async (branch, options) => {
32956
+ const globalOpts = program2.opts();
32957
+ if (!validateSafeBranchName(branch)) {
32958
+ stepError(`Invalid branch name: '${branch}'`);
32959
+ process.exit(1);
32960
+ }
32961
+ const config2 = loadConfig();
32962
+ const repoFilter = parseRepoFlag(options.repo);
32963
+ const repos = resolveRepos(config2, repoFilter);
32964
+ const jsonResults = [];
32965
+ let successCount = 0;
32966
+ for (const repo of repos) {
32967
+ try {
32968
+ const metadata = await readStackMetadata(repo.mainPath, globalOpts);
32969
+ const worktrees = await getWorktreeList(repo.mainPath);
32970
+ const ancestors = getStackAncestors(metadata, branch);
32971
+ const nodes = ancestors.map((item) => nodeFor(item, metadata, worktrees));
32972
+ const descendants = collectDescendants(metadata, branch, worktrees, new Set(ancestors));
32973
+ const allNodes = [...nodes, ...descendants];
32974
+ const displayNodes = buildStackHierarchy(allNodes, (node) => node.branch, (node) => node.base ?? undefined, (a2, b) => a2.branch.localeCompare(b.branch));
32975
+ if (options.json) {
32976
+ jsonResults.push({ repo: repo.name, branch, nodes: allNodes });
32977
+ } else {
32978
+ repoHeader(repo.name);
32979
+ info(` Stack for ${branch}`);
32980
+ if (allNodes.length === 1 && !metadata.branches[branch]) {
32981
+ const mainBranch = await resolveMainBranch(repo, config2);
32982
+ const defaultRemote = await resolveBaseRemote(repo.mainPath, mainBranch);
32983
+ const defaultBase = `${defaultRemote}/${mainBranch}`;
32984
+ indented(`No recorded parent; default base is ${defaultBase}`);
32985
+ }
32986
+ displayNodes.forEach(({ item, prefix }) => renderNode(item, prefix));
32987
+ }
32988
+ successCount++;
32989
+ } catch (err) {
32990
+ const message = err instanceof Error ? err.message : String(err);
32991
+ if (options.json) {
32992
+ console.error(JSON.stringify({ repo: repo.name, error: message }));
32993
+ } else {
32994
+ stepError("Failed to read stack", `${repo.name}: ${message}`);
32995
+ }
32996
+ }
32997
+ }
32998
+ if (options.json) {
32999
+ console.log(JSON.stringify(jsonResults.length === 1 ? jsonResults[0] : jsonResults, null, 2));
33000
+ } else if (successCount === 0) {
33001
+ summaryWarning("No stacks found");
33002
+ } else {
33003
+ summary(`Done — ${successCount} repo${successCount > 1 ? "s" : ""} checked`);
33004
+ }
33005
+ });
33006
+ }
33007
+
32357
33008
  // src/index.ts
32358
33009
  init_config();
32359
33010
  init_resolver();
32360
33011
  init_log();
32361
33012
  init_history();
32362
- import fs32 from "fs";
33013
+ import fs33 from "fs";
32363
33014
  var program2 = new Command;
32364
33015
  program2.name("wtx").description("Multi-repo git worktree manager").version(VERSION).option("-q, --quiet", "Suppress progress indicators", false).option("--verbose", "Show git commands as they run", false).option("--dry-run", "Show what would happen", false);
32365
33016
  var MUTATING_COMMANDS = new Set([
@@ -32451,6 +33102,7 @@ registerSkillCommand(program2);
32451
33102
  registerTerminalCommand(program2);
32452
33103
  registerMcpCommand(program2);
32453
33104
  registerHistoryCommand(program2);
33105
+ registerStackCommand(program2);
32454
33106
  program2.command("_resolve-path <repo> <branch>", { hidden: true }).description("Internal command to resolve worktree path for shell wrapper cd").action((repoName, branch) => {
32455
33107
  try {
32456
33108
  const config2 = loadConfig();
@@ -32462,7 +33114,7 @@ program2.command("_resolve-path <repo> <branch>", { hidden: true }).description(
32462
33114
  }
32463
33115
  const repo = repos[0];
32464
33116
  const wtPath = getWorktreePath(repo, branch);
32465
- if (!fs32.existsSync(wtPath)) {
33117
+ if (!fs33.existsSync(wtPath)) {
32466
33118
  process.stderr.write(`✗ No worktree at ${wtPath}
32467
33119
  `);
32468
33120
  process.exit(1);