@ogpoyraz/wtx 0.8.3 → 0.8.5

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.
@@ -137,6 +137,7 @@ _wtx() {
137
137
  _arguments \
138
138
  ':pr-link:' \
139
139
  {-r,--repo}'[Target specific repo(s)]:repo:_wtx_repos' \
140
+ {-f,--force}'[Override existing local branch/worktree]' \
140
141
  {-q,--quiet}'[Suppress output]' \
141
142
  '--verbose[Show git commands]' \
142
143
  '--dry-run[Show what would happen]'
@@ -97,7 +97,7 @@ _wtx_completions() {
97
97
  fetch) flags="--repo -q --quiet --verbose --dry-run --help" ;;
98
98
  sync) flags="--repo -q --quiet --verbose --dry-run --help" ;;
99
99
  deps) flags="-r --repo --install --symlink --json -q --quiet --verbose --dry-run --help" ;;
100
- pull) flags="-r --repo -q --quiet --verbose --dry-run --help" ;;
100
+ pull) flags="-r --repo -f --force -q --quiet --verbose --dry-run --help" ;;
101
101
  ls) flags="-r --repo --pr --json -q --quiet --verbose --dry-run --help" ;;
102
102
  status) flags="-r --repo --base --json -q --quiet --verbose --dry-run --help" ;;
103
103
  stack) flags="-r --repo --json -q --quiet --verbose --dry-run --help" ;;
package/dist/cli.mjs CHANGED
@@ -26378,6 +26378,7 @@ var init_HelpOverlay = __esm(() => {
26378
26378
  ["n", "Create new worktree (pick dependency strategy)"],
26379
26379
  ["f", "Fetch main for selected repo(s)"],
26380
26380
  ["p", "Pull latest changes for selected branch(es)"],
26381
+ ["P (shift+p)", "Pull PR by link (force-override if exists)"],
26381
26382
  ["b", "Rebase selected onto base (or main)"],
26382
26383
  ["s", "Sync selected (env files + hooks)"],
26383
26384
  ["i", "Install dependencies in selection (worktrees and main)"],
@@ -26558,7 +26559,7 @@ var init_ActionLogModal = __esm(() => {
26558
26559
  });
26559
26560
 
26560
26561
  // src/lib/history.ts
26561
- import fs31 from "fs";
26562
+ import fs32 from "fs";
26562
26563
  import path36 from "path";
26563
26564
  import os4 from "os";
26564
26565
  function getHistoryDir() {
@@ -26572,24 +26573,24 @@ function rotateHistory(maxBytes = HISTORY_MAX_BYTES, keepLines = HISTORY_ROTATE_
26572
26573
  const historyPath = getHistoryPath();
26573
26574
  let size = 0;
26574
26575
  try {
26575
- size = fs31.statSync(historyPath).size;
26576
+ size = fs32.statSync(historyPath).size;
26576
26577
  } catch {
26577
26578
  return;
26578
26579
  }
26579
26580
  if (size <= maxBytes)
26580
26581
  return;
26581
- const lines = fs31.readFileSync(historyPath, "utf-8").split(`
26582
+ const lines = fs32.readFileSync(historyPath, "utf-8").split(`
26582
26583
  `).filter(Boolean);
26583
26584
  const kept = lines.slice(-keepLines);
26584
26585
  const tmpPath = `${historyPath}.tmp.${process.pid}`;
26585
26586
  try {
26586
- fs31.writeFileSync(tmpPath, kept.length > 0 ? `${kept.join(`
26587
+ fs32.writeFileSync(tmpPath, kept.length > 0 ? `${kept.join(`
26587
26588
  `)}
26588
26589
  ` : "", "utf-8");
26589
- fs31.renameSync(tmpPath, historyPath);
26590
+ fs32.renameSync(tmpPath, historyPath);
26590
26591
  } catch (err) {
26591
- if (fs31.existsSync(tmpPath)) {
26592
- fs31.unlinkSync(tmpPath);
26592
+ if (fs32.existsSync(tmpPath)) {
26593
+ fs32.unlinkSync(tmpPath);
26593
26594
  }
26594
26595
  throw err;
26595
26596
  }
@@ -26597,18 +26598,18 @@ function rotateHistory(maxBytes = HISTORY_MAX_BYTES, keepLines = HISTORY_ROTATE_
26597
26598
  function appendHistory(entry) {
26598
26599
  try {
26599
26600
  const dir = getHistoryDir();
26600
- if (!fs31.existsSync(dir)) {
26601
- fs31.mkdirSync(dir, { recursive: true });
26601
+ if (!fs32.existsSync(dir)) {
26602
+ fs32.mkdirSync(dir, { recursive: true });
26602
26603
  }
26603
26604
  rotateHistory();
26604
- fs31.appendFileSync(getHistoryPath(), `${JSON.stringify(entry)}
26605
+ fs32.appendFileSync(getHistoryPath(), `${JSON.stringify(entry)}
26605
26606
  `, "utf-8");
26606
26607
  } catch {}
26607
26608
  }
26608
26609
  function readRecentHistory(limit = 50) {
26609
26610
  let content;
26610
26611
  try {
26611
- content = fs31.readFileSync(getHistoryPath(), "utf-8");
26612
+ content = fs32.readFileSync(getHistoryPath(), "utf-8");
26612
26613
  } catch {
26613
26614
  return [];
26614
26615
  }
@@ -26739,7 +26740,7 @@ var init_HistoryOverlay = __esm(() => {
26739
26740
  // src/tui/components/InputModal.tsx
26740
26741
  import { useState as useState3 } from "react";
26741
26742
  import { jsx as jsx9, jsxs as jsxs8 } from "@opentui/react/jsx-runtime";
26742
- function InputModal({ title, placeholder, initialValue, errorMessage, onSubmit }) {
26743
+ function InputModal({ title, placeholder, initialValue, errorMessage: errorMessage2, onSubmit }) {
26743
26744
  const [value, setValue] = useState3(initialValue ?? "");
26744
26745
  return /* @__PURE__ */ jsx9(Overlay, {
26745
26746
  title,
@@ -26754,10 +26755,10 @@ function InputModal({ title, placeholder, initialValue, errorMessage, onSubmit }
26754
26755
  onInput: (val) => setValue(val),
26755
26756
  onSubmit: () => onSubmit(value)
26756
26757
  }),
26757
- errorMessage ? /* @__PURE__ */ jsx9("text", {
26758
+ errorMessage2 ? /* @__PURE__ */ jsx9("text", {
26758
26759
  fg: tokens.error,
26759
26760
  style: { marginTop: 1 },
26760
- children: errorMessage
26761
+ children: errorMessage2
26761
26762
  }) : /* @__PURE__ */ jsx9("text", {
26762
26763
  fg: tokens.dim,
26763
26764
  style: { marginTop: 1 },
@@ -27278,6 +27279,7 @@ var init_useSpinnerFrame = __esm(() => {
27278
27279
 
27279
27280
  // src/tui/components/App.tsx
27280
27281
  import { useState as useState7, useEffect as useEffect6, useMemo, useRef as useRef4, useCallback as useCallback3 } from "react";
27282
+ import { existsSync as existsSync4 } from "node:fs";
27281
27283
  import { useKeyboard as useKeyboard3, useRenderer, useSelectionHandler } from "@opentui/react";
27282
27284
  import { jsx as jsx13, jsxs as jsxs12 } from "@opentui/react/jsx-runtime";
27283
27285
  function getWorktreePathFor(repoName, branch) {
@@ -27304,6 +27306,9 @@ function App({ opts }) {
27304
27306
  const [createBaseModal, setCreateBaseModal] = useState7(null);
27305
27307
  const [createBaseError, setCreateBaseError] = useState7();
27306
27308
  const [createDepsChoice, setCreateDepsChoice] = useState7(null);
27309
+ const [pullPrModal, setPullPrModal] = useState7(false);
27310
+ const [pullPrError, setPullPrError] = useState7();
27311
+ const [pullForceChoice, setPullForceChoice] = useState7(null);
27307
27312
  const [renameModal, setRenameModal] = useState7(false);
27308
27313
  const [renameError, setRenameError] = useState7();
27309
27314
  const [configOpen, setConfigOpen] = useState7(false);
@@ -27487,6 +27492,22 @@ function App({ opts }) {
27487
27492
  args.push("--deps", deps);
27488
27493
  executeOp(op, args, [repoName]);
27489
27494
  };
27495
+ const startPullPr = (link, repoName, force) => {
27496
+ const op = {
27497
+ id: nextOpId.current++,
27498
+ kind: "pull",
27499
+ repoNames: [repoName],
27500
+ label: `PR ${link.split("/").pop()}`,
27501
+ title: `Pull ${link}`,
27502
+ status: "queued",
27503
+ lines: []
27504
+ };
27505
+ setOps((prev) => [...prev, op]);
27506
+ const args = ["pull", link, "--repo", repoName];
27507
+ if (force)
27508
+ args.push("--force");
27509
+ executeOp(op, args, [repoName]);
27510
+ };
27490
27511
  useKeyboard3((key) => {
27491
27512
  if (isFiltering) {
27492
27513
  if (key.name === "escape") {
@@ -27520,6 +27541,15 @@ function App({ opts }) {
27520
27541
  }
27521
27542
  if (createDepsChoice)
27522
27543
  return;
27544
+ if (pullPrModal) {
27545
+ if (key.name === "escape") {
27546
+ setPullPrModal(false);
27547
+ setPullPrError(undefined);
27548
+ }
27549
+ return;
27550
+ }
27551
+ if (pullForceChoice)
27552
+ return;
27523
27553
  if (renameModal) {
27524
27554
  if (key.name === "escape") {
27525
27555
  setRenameModal(false);
@@ -27544,7 +27574,8 @@ function App({ opts }) {
27544
27574
  if (action === "confirm_remove") {
27545
27575
  startBatchActions("remove", rows, (r) => {
27546
27576
  const args = ["remove", r.branch, "--repo", r.repoName, "--yes"];
27547
- if (r.dirtyFiles.length > 0)
27577
+ const needsForce = r.dirtyFiles.length > 0 || !existsSync4(r.path);
27578
+ if (needsForce)
27548
27579
  args.push("--force");
27549
27580
  return args;
27550
27581
  });
@@ -27633,6 +27664,20 @@ function App({ opts }) {
27633
27664
  setCreateError(undefined);
27634
27665
  return;
27635
27666
  }
27667
+ if (key.name === "P" || key.name === "p" && key.shift) {
27668
+ const repoName = selectedRow?.repoName ?? getSelectedRows()[0]?.repoName;
27669
+ if (!repoName) {
27670
+ flash("No repo selected");
27671
+ return;
27672
+ }
27673
+ if (busyRepos.has(repoName)) {
27674
+ flash(`${repoName} is busy`);
27675
+ return;
27676
+ }
27677
+ setPullPrModal(true);
27678
+ setPullPrError(undefined);
27679
+ return;
27680
+ }
27636
27681
  if (key.name === "p") {
27637
27682
  const targets = getSelectedRows();
27638
27683
  if (targets.length === 0)
@@ -27938,6 +27983,47 @@ function App({ opts }) {
27938
27983
  },
27939
27984
  onCancel: () => setCreateDepsChoice(null)
27940
27985
  }),
27986
+ pullPrModal && /* @__PURE__ */ jsx13(InputModal, {
27987
+ title: `Pull PR into ${selectedRow?.repoName ?? ""}`,
27988
+ placeholder: "https://github.com/owner/repo/pull/123",
27989
+ errorMessage: pullPrError,
27990
+ onSubmit: (value) => {
27991
+ const link = value.trim();
27992
+ if (!link) {
27993
+ setPullPrModal(false);
27994
+ return;
27995
+ }
27996
+ if (!link.includes("github.com") || !link.includes("/pull/")) {
27997
+ setPullPrError("Invalid PR link: expected https://github.com/{owner}/{repo}/pull/{N}");
27998
+ return;
27999
+ }
28000
+ const repoName = selectedRow?.repoName;
28001
+ if (!repoName) {
28002
+ setPullPrError("No repo selected");
28003
+ return;
28004
+ }
28005
+ if (busyRepos.has(repoName)) {
28006
+ setPullPrError(`${repoName} is busy`);
28007
+ return;
28008
+ }
28009
+ setPullPrModal(false);
28010
+ setPullPrError(undefined);
28011
+ setPullForceChoice({ link, repoName });
28012
+ }
28013
+ }),
28014
+ pullForceChoice && /* @__PURE__ */ jsx13(ChoiceModal, {
28015
+ title: `Pull ${pullForceChoice.link.split("/").pop()}?`,
28016
+ options: [
28017
+ { value: "normal", label: "Pull (skip if exists)", desc: "Fails if branch already exists" },
28018
+ { value: "force", label: "Force Override", desc: "Remove existing branch/worktree and recreate" }
28019
+ ],
28020
+ onSubmit: (choice) => {
28021
+ const { link, repoName } = pullForceChoice;
28022
+ setPullForceChoice(null);
28023
+ startPullPr(link, repoName, choice === "force");
28024
+ },
28025
+ onCancel: () => setPullForceChoice(null)
28026
+ }),
27941
28027
  renameModal && selectedRow && /* @__PURE__ */ jsx13(InputModal, {
27942
28028
  title: `Rename branch ${selectedRow.branch}`,
27943
28029
  initialValue: selectedRow.branch,
@@ -30642,7 +30728,7 @@ import path28 from "path";
30642
30728
  init_forge();
30643
30729
  init_stack();
30644
30730
  function registerPullCommand(program2) {
30645
- program2.command("pull <link>").description("Fetch a forge PR and create its worktree").option("-r, --repo <repos...>", "Target specific repo").action(async (link, options) => {
30731
+ program2.command("pull <link>").description("Fetch a forge PR and create its worktree").option("-r, --repo <repos...>", "Target specific repo").option("-f, --force", "Override existing local branch/worktree if it exists").action(async (link, options) => {
30646
30732
  const globalOpts = program2.opts();
30647
30733
  const config2 = loadConfig();
30648
30734
  const repoFilter = parseRepoFlag(options.repo);
@@ -30753,12 +30839,45 @@ function registerPullCommand(program2) {
30753
30839
  }
30754
30840
  const wtPath = getWorktreePath(target, branch);
30755
30841
  const worktrees = await getWorktreeList(target.mainPath);
30842
+ const existingWorktree = worktrees.find((w) => w.path === wtPath || w.branch === branch);
30756
30843
  const localBranchFound = await localBranchExists(target.mainPath, branch, globalOpts);
30757
- const dirExists = worktrees.some((w) => w.path === wtPath || w.branch === branch);
30844
+ const dirExists = Boolean(existingWorktree) || fs22.existsSync(wtPath);
30758
30845
  if (localBranchFound || dirExists) {
30759
- stepWarning(`Branch '${branch}' already exists`, dirExists ? wtPath : "local branch exists");
30760
- summaryWarning(`Nothing pulled — branch '${branch}' already exists`);
30761
- return;
30846
+ if (options.force) {
30847
+ stepWarning(`Branch '${branch}' already exists — --force: will override`, dirExists ? wtPath : "local branch exists");
30848
+ } else {
30849
+ stepWarning(`Branch '${branch}' already exists`, dirExists ? wtPath : "local branch exists");
30850
+ summaryWarning(`Nothing pulled — branch '${branch}' already exists`);
30851
+ return;
30852
+ }
30853
+ }
30854
+ if (options.force && (localBranchFound || dirExists)) {
30855
+ if (existingWorktree) {
30856
+ try {
30857
+ await gitExec(["-C", target.mainPath, "worktree", "remove", "--force", existingWorktree.path], { verbose: globalOpts.verbose, dryRun: globalOpts.dryRun });
30858
+ } catch (err) {
30859
+ const message = err instanceof Error ? err.message : String(err);
30860
+ if (!message.includes("not a working tree")) {
30861
+ stepWarning("Worktree removal failed", message.split(`
30862
+ `)[0] ?? message);
30863
+ }
30864
+ }
30865
+ }
30866
+ if (!globalOpts.dryRun && fs22.existsSync(wtPath)) {
30867
+ fs22.rmSync(wtPath, { recursive: true, force: true });
30868
+ }
30869
+ if (localBranchFound) {
30870
+ try {
30871
+ await gitExec(["-C", target.mainPath, "branch", "-D", branch], { verbose: globalOpts.verbose, dryRun: globalOpts.dryRun });
30872
+ } catch (err) {
30873
+ const message = err instanceof Error ? err.message : String(err);
30874
+ if (!message.includes("branch not found") && !message.includes("not found")) {
30875
+ stepWarning("Branch deletion failed", message.split(`
30876
+ `)[0] ?? message);
30877
+ }
30878
+ }
30879
+ }
30880
+ stepSuccess("Existing branch/worktree removed", branch);
30762
30881
  }
30763
30882
  const mainBranch = await resolveMainBranch(target, config2);
30764
30883
  const baseRemote = await resolveBaseRemote(target.mainPath, mainBranch);
@@ -30792,7 +30911,8 @@ function registerPullCommand(program2) {
30792
30911
  fs22.mkdirSync(path28.dirname(wtPath), { recursive: true });
30793
30912
  }
30794
30913
  try {
30795
- await gitExec(["-C", target.mainPath, "worktree", "add", "-b", branch, wtPath, "FETCH_HEAD"], { verbose: globalOpts.verbose, dryRun: globalOpts.dryRun });
30914
+ const createBranchFlag = options.force ? "-B" : "-b";
30915
+ await gitExec(["-C", target.mainPath, "worktree", "add", createBranchFlag, branch, wtPath, "FETCH_HEAD"], { verbose: globalOpts.verbose, dryRun: globalOpts.dryRun });
30796
30916
  } catch (err) {
30797
30917
  const msg = err.message || "";
30798
30918
  if (msg.includes("already exists") || msg.includes("not a working tree")) {
@@ -30924,6 +31044,7 @@ init_log();
30924
31044
  init_git();
30925
31045
  init_resolver();
30926
31046
  init_path_safety();
31047
+ import fs23 from "fs";
30927
31048
  import path29 from "path";
30928
31049
 
30929
31050
  // src/lib/prompts.ts
@@ -30968,6 +31089,69 @@ async function confirm(message, io) {
30968
31089
 
30969
31090
  // src/commands/remove.ts
30970
31091
  init_stack();
31092
+ function errorMessage(err) {
31093
+ return err instanceof Error ? err.message : String(err);
31094
+ }
31095
+ function isMissingWorktreePathError(message) {
31096
+ return message.includes("ENOENT") || message.includes("No such file") || message.includes("does not exist") || message.includes("not a git repository") || message.includes("cannot change to");
31097
+ }
31098
+ function isRecoverableWorktreeRemoveError(message) {
31099
+ return message.includes("not a working tree") || message.includes("already removed") || message.includes("submodule") || message.includes("containing submodules") || isMissingWorktreePathError(message);
31100
+ }
31101
+ function removeExistingPath(wtPath, opts) {
31102
+ if (!opts.dryRun && fs23.existsSync(wtPath)) {
31103
+ fs23.rmSync(wtPath, { recursive: true, force: true });
31104
+ }
31105
+ }
31106
+ async function removeLocalBranchIfExists(repoPath, branch, opts) {
31107
+ if (await localBranchExists(repoPath, branch, opts)) {
31108
+ await gitExec(["-C", repoPath, "branch", "-D", branch], opts);
31109
+ }
31110
+ }
31111
+ async function removeLocalBranchBestEffort(repoPath, branch, opts) {
31112
+ try {
31113
+ await removeLocalBranchIfExists(repoPath, branch, opts);
31114
+ } catch (err) {
31115
+ stepWarning("Local branch not removed", errorMessage(err));
31116
+ }
31117
+ }
31118
+ async function finishCleanup(repo, wtPath, branch, opts) {
31119
+ if (!opts.dryRun) {
31120
+ const removedDirs = cleanupEmptyParents(repo.wtRoot, repo.mainPath, wtPath);
31121
+ for (const dir of removedDirs) {
31122
+ stepSuccess("Cleaned up empty directory", path29.relative(repo.wtRoot, dir) + "/");
31123
+ }
31124
+ }
31125
+ try {
31126
+ await removeStackEntry(repo.mainPath, branch, opts);
31127
+ } catch (err) {
31128
+ stepWarning("Stack metadata not removed", errorMessage(err));
31129
+ }
31130
+ }
31131
+ async function confirmDeletionIfNeeded(repo, wtPath, options, globalOpts) {
31132
+ if (globalOpts.dryRun)
31133
+ return true;
31134
+ const interactive = isInteractive();
31135
+ const yesFlag = !!options.yes;
31136
+ const envYes = process.env.WTX_YES === "1";
31137
+ if (!canProceedDeletion({ interactive, yesFlag, envYes })) {
31138
+ stepError("Non-interactive terminal requires --yes flag or WTX_YES=1 for scripts");
31139
+ process.exit(1);
31140
+ }
31141
+ if (interactive && !yesFlag && !envYes) {
31142
+ const toClean = planEmptyParentRemoval(repo.wtRoot, repo.mainPath, wtPath);
31143
+ indented(`Will remove worktree: ${wtPath}`);
31144
+ for (const dir of toClean) {
31145
+ indented(`Will clean up empty dir: ${path29.relative(repo.wtRoot, dir)}/`);
31146
+ }
31147
+ const proceed = await confirm("Are you sure you want to delete these?");
31148
+ if (!proceed) {
31149
+ stepWarning("Skipped by user", wtPath);
31150
+ return false;
31151
+ }
31152
+ }
31153
+ return true;
31154
+ }
30971
31155
  function registerRemoveCommand(program2) {
30972
31156
  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) => {
30973
31157
  const globalOpts = program2.opts();
@@ -30987,6 +31171,18 @@ function registerRemoveCommand(program2) {
30987
31171
  const worktrees = await getWorktreeList(repo.mainPath);
30988
31172
  const target = findWorktreeForBranch(worktrees, branch, repo.mainPath, candidatePath);
30989
31173
  if (!target) {
31174
+ if (options.force && (fs23.existsSync(candidatePath) || await localBranchExists(repo.mainPath, branch, globalOpts))) {
31175
+ if (!await confirmDeletionIfNeeded(repo, candidatePath, options, globalOpts)) {
31176
+ skipCount++;
31177
+ continue;
31178
+ }
31179
+ removeExistingPath(candidatePath, globalOpts);
31180
+ await removeLocalBranchBestEffort(repo.mainPath, branch, globalOpts);
31181
+ await finishCleanup(repo, candidatePath, branch, globalOpts);
31182
+ stepSuccess("Worktree removed", candidatePath);
31183
+ successCount++;
31184
+ continue;
31185
+ }
30990
31186
  stepWarning("No worktree found", `${branch} (skipped)`);
30991
31187
  skipCount++;
30992
31188
  continue;
@@ -31015,32 +31211,38 @@ function registerRemoveCommand(program2) {
31015
31211
  continue;
31016
31212
  }
31017
31213
  } catch (err) {
31018
- stepError("Failed to check for uncommitted changes", err.message);
31214
+ const msg = errorMessage(err);
31215
+ if (isMissingWorktreePathError(msg) || !fs23.existsSync(wtPath)) {
31216
+ stepWarning("Worktree path missing", "continuing with removal cleanup");
31217
+ } else {
31218
+ stepError("Failed to check for uncommitted changes", msg);
31219
+ skipCount++;
31220
+ continue;
31221
+ }
31222
+ }
31223
+ }
31224
+ if (options.force && !globalOpts.dryRun && !fs23.existsSync(wtPath)) {
31225
+ if (!await confirmDeletionIfNeeded(repo, wtPath, options, globalOpts)) {
31019
31226
  skipCount++;
31020
31227
  continue;
31021
31228
  }
31022
- }
31023
- if (!globalOpts.dryRun) {
31024
- const interactive = isInteractive();
31025
- const yesFlag = !!options.yes;
31026
- const envYes = process.env.WTX_YES === "1";
31027
- if (!canProceedDeletion({ interactive, yesFlag, envYes })) {
31028
- stepError("Non-interactive terminal requires --yes flag or WTX_YES=1 for scripts");
31029
- process.exit(1);
31229
+ try {
31230
+ await gitExec(["-C", repo.mainPath, "worktree", "prune"], globalOpts);
31231
+ } catch (err) {
31232
+ stepWarning("Worktree prune failed", errorMessage(err));
31030
31233
  }
31031
- if (interactive && !yesFlag && !envYes) {
31032
- const toClean = planEmptyParentRemoval(repo.wtRoot, repo.mainPath, wtPath);
31033
- indented(`Will remove worktree: ${wtPath}`);
31034
- for (const dir of toClean) {
31035
- indented(`Will clean up empty dir: ${path29.relative(repo.wtRoot, dir)}/`);
31036
- }
31037
- const proceed = await confirm("Are you sure you want to delete these?");
31038
- if (!proceed) {
31039
- stepWarning("Skipped by user", wtPath);
31040
- skipCount++;
31041
- continue;
31042
- }
31234
+ await removeLocalBranchBestEffort(repo.mainPath, branch, globalOpts);
31235
+ await finishCleanup(repo, wtPath, branch, globalOpts);
31236
+ stepSuccess("Worktree removed", wtPath);
31237
+ if (children.length > 0) {
31238
+ stepWarning("Dependent base metadata retained", children.join(", "));
31043
31239
  }
31240
+ successCount++;
31241
+ continue;
31242
+ }
31243
+ if (!await confirmDeletionIfNeeded(repo, wtPath, options, globalOpts)) {
31244
+ skipCount++;
31245
+ continue;
31044
31246
  }
31045
31247
  const args = ["-C", repo.mainPath, "worktree", "remove", wtPath];
31046
31248
  if (options.force) {
@@ -31050,33 +31252,51 @@ function registerRemoveCommand(program2) {
31050
31252
  await gitExec(args, globalOpts);
31051
31253
  stepSuccess("Worktree removed", wtPath);
31052
31254
  } catch (err) {
31053
- const msg = err.message || "";
31054
- if (msg.includes("not a working tree") || msg.includes("already removed")) {
31255
+ const msg = errorMessage(err);
31256
+ const isSubmodule = msg.includes("submodule") || msg.includes("containing submodules");
31257
+ if (isSubmodule && !options.force && !args.includes("--force")) {
31258
+ stepWarning("Worktree contains submodules — retrying with --force", msg.split(`
31259
+ `)[0] ?? msg);
31260
+ try {
31261
+ await gitExec(["-C", repo.mainPath, "worktree", "remove", "--force", wtPath], globalOpts);
31262
+ stepSuccess("Worktree removed", wtPath);
31263
+ } catch (forceErr) {
31264
+ const forceMsg = errorMessage(forceErr);
31265
+ if (isRecoverableWorktreeRemoveError(forceMsg)) {
31266
+ stepWarning("Worktree already removed or invalid", forceMsg);
31267
+ try {
31268
+ await gitExec(["-C", repo.mainPath, "worktree", "prune"], globalOpts);
31269
+ } catch (pruneErr) {
31270
+ stepWarning("Worktree prune failed", errorMessage(pruneErr));
31271
+ }
31272
+ removeExistingPath(wtPath, globalOpts);
31273
+ await removeLocalBranchBestEffort(repo.mainPath, branch, globalOpts);
31274
+ stepSuccess("Worktree removed", wtPath);
31275
+ } else {
31276
+ throw forceErr;
31277
+ }
31278
+ }
31279
+ } else if (isRecoverableWorktreeRemoveError(msg)) {
31055
31280
  stepWarning("Worktree already removed or invalid", msg);
31056
- skipCount++;
31057
- continue;
31281
+ try {
31282
+ await gitExec(["-C", repo.mainPath, "worktree", "prune"], globalOpts);
31283
+ } catch (pruneErr) {
31284
+ stepWarning("Worktree prune failed", errorMessage(pruneErr));
31285
+ }
31286
+ removeExistingPath(wtPath, globalOpts);
31287
+ await removeLocalBranchBestEffort(repo.mainPath, branch, globalOpts);
31288
+ stepSuccess("Worktree removed", wtPath);
31058
31289
  } else {
31059
31290
  throw err;
31060
31291
  }
31061
31292
  }
31062
- if (!globalOpts.dryRun) {
31063
- const removedDirs = cleanupEmptyParents(repo.wtRoot, repo.mainPath, wtPath);
31064
- for (const dir of removedDirs) {
31065
- stepSuccess("Cleaned up empty directory", path29.relative(repo.wtRoot, dir) + "/");
31066
- }
31067
- }
31068
- try {
31069
- await removeStackEntry(repo.mainPath, branch, globalOpts);
31070
- } catch (err) {
31071
- const message = err instanceof Error ? err.message : String(err);
31072
- stepWarning("Stack metadata not removed", message);
31073
- }
31293
+ await finishCleanup(repo, wtPath, branch, globalOpts);
31074
31294
  if (children.length > 0) {
31075
31295
  stepWarning("Dependent base metadata retained", children.join(", "));
31076
31296
  }
31077
31297
  successCount++;
31078
31298
  } catch (err) {
31079
- stepError("Failed to remove worktree", err.message);
31299
+ stepError("Failed to remove worktree", errorMessage(err));
31080
31300
  skipCount++;
31081
31301
  }
31082
31302
  }
@@ -31331,7 +31551,7 @@ init_source();
31331
31551
  init_stack();
31332
31552
  init_stack();
31333
31553
  import path31 from "path";
31334
- import fs23 from "fs";
31554
+ import fs24 from "fs";
31335
31555
  function buildLsJson(reposData) {
31336
31556
  const result = [];
31337
31557
  for (const repo of reposData) {
@@ -31441,7 +31661,7 @@ function registerLsCommand(program2) {
31441
31661
  statusStr = source_default.blue("[main checkout]");
31442
31662
  } else if (wt.isLocked) {
31443
31663
  statusStr = source_default.red("locked \uD83D\uDD12");
31444
- } else if (fs23.existsSync(wt.path)) {
31664
+ } else if (fs24.existsSync(wt.path)) {
31445
31665
  try {
31446
31666
  dirtyFiles = await getDirtyFiles(wt.path);
31447
31667
  if (dirtyFiles.length > 0) {
@@ -31567,7 +31787,7 @@ end
31567
31787
  init_config();
31568
31788
  init_log();
31569
31789
  init_git();
31570
- import fs24 from "fs";
31790
+ import fs25 from "fs";
31571
31791
  init_resolver();
31572
31792
  init_stack();
31573
31793
  function registerRebaseCommand(program2) {
@@ -31582,7 +31802,7 @@ function registerRebaseCommand(program2) {
31582
31802
  repoHeader(repo.name);
31583
31803
  const mainBranch = await resolveMainBranch(repo, config2);
31584
31804
  const wtPath = getWorktreePath(repo, branch);
31585
- if (!fs24.existsSync(wtPath)) {
31805
+ if (!fs25.existsSync(wtPath)) {
31586
31806
  stepError("No worktree found", `${wtPath} (skipped)`);
31587
31807
  failCount++;
31588
31808
  continue;
@@ -31708,7 +31928,7 @@ init_execa();
31708
31928
  init_config();
31709
31929
  init_log();
31710
31930
  init_resolver();
31711
- import fs25 from "fs";
31931
+ import fs26 from "fs";
31712
31932
  import path32 from "path";
31713
31933
  function registerSyncCommand(program2) {
31714
31934
  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) => {
@@ -31720,14 +31940,14 @@ function registerSyncCommand(program2) {
31720
31940
  for (const repo of repos) {
31721
31941
  repoHeader(repo.name);
31722
31942
  const wtPath = getWorktreePath(repo, branch);
31723
- if (!fs25.existsSync(wtPath)) {
31943
+ if (!fs26.existsSync(wtPath)) {
31724
31944
  stepWarning("No worktree found", `${wtPath} (skipped)`);
31725
31945
  continue;
31726
31946
  }
31727
31947
  try {
31728
31948
  if (repo.config.sync_files) {
31729
31949
  for (const file2 of repo.config.sync_files) {
31730
- if (fs25.existsSync(path32.join(repo.mainPath, file2))) {
31950
+ if (fs26.existsSync(path32.join(repo.mainPath, file2))) {
31731
31951
  if (!opts.dryRun) {
31732
31952
  syncEntry(repo.mainPath, wtPath, file2);
31733
31953
  }
@@ -31780,14 +32000,14 @@ function registerSyncCommand(program2) {
31780
32000
  }
31781
32001
  }
31782
32002
  const wtNodeModules = path32.join(wtPath, "node_modules");
31783
- if (fs25.existsSync(wtNodeModules) && fs25.lstatSync(wtNodeModules).isSymbolicLink()) {
32003
+ if (fs26.existsSync(wtNodeModules) && fs26.lstatSync(wtNodeModules).isSymbolicLink()) {
31784
32004
  const lockfiles = ["yarn.lock", "package-lock.json", "pnpm-lock.yaml", "bun.lockb", "bun.lock"];
31785
32005
  for (const lock of lockfiles) {
31786
32006
  const mainLock = path32.join(repo.mainPath, lock);
31787
32007
  const wtLock = path32.join(wtPath, lock);
31788
- if (fs25.existsSync(mainLock) && fs25.existsSync(wtLock)) {
31789
- const mainContent = fs25.readFileSync(mainLock);
31790
- const wtContent = fs25.readFileSync(wtLock);
32008
+ if (fs26.existsSync(mainLock) && fs26.existsSync(wtLock)) {
32009
+ const mainContent = fs26.readFileSync(mainLock);
32010
+ const wtContent = fs26.readFileSync(wtLock);
31791
32011
  if (!mainContent.equals(wtContent)) {
31792
32012
  stepWarning(`${lock} differs from main — node_modules is symlinked`);
31793
32013
  indented(`Run: wtx deps ${branch} --repo ${repo.name} --install`);
@@ -31816,7 +32036,7 @@ init_config();
31816
32036
  init_log();
31817
32037
  init_resolver();
31818
32038
  init_deps();
31819
- import fs26 from "fs";
32039
+ import fs27 from "fs";
31820
32040
  function registerDepsCommand(program2) {
31821
32041
  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) => {
31822
32042
  const globalOpts = program2.opts();
@@ -31853,7 +32073,7 @@ function registerDepsCommand(program2) {
31853
32073
  continue;
31854
32074
  }
31855
32075
  const wtPath = getWorktreePath(repo, branch);
31856
- if (!fs26.existsSync(wtPath)) {
32076
+ if (!fs27.existsSync(wtPath)) {
31857
32077
  if (options.json) {
31858
32078
  jsonResults[repo.name] = { error: "No worktree found", branch };
31859
32079
  } else {
@@ -31911,7 +32131,7 @@ function registerDepsCommand(program2) {
31911
32131
  init_config();
31912
32132
  init_log();
31913
32133
  init_resolver();
31914
- import fs27 from "fs";
32134
+ import fs28 from "fs";
31915
32135
  init_git();
31916
32136
  async function resolveMainCheckoutPath(repoCtx, branch) {
31917
32137
  try {
@@ -31936,7 +32156,7 @@ function registerOpenCommand(program2) {
31936
32156
  for (const repo of repos) {
31937
32157
  const wtPath = getWorktreePath(repo, branch);
31938
32158
  let targetPath = wtPath;
31939
- if (!fs27.existsSync(wtPath)) {
32159
+ if (!fs28.existsSync(wtPath)) {
31940
32160
  const mainCheckoutPath = await resolveMainCheckoutPath(repo, branch);
31941
32161
  if (!mainCheckoutPath) {
31942
32162
  continue;
@@ -31972,7 +32192,7 @@ import path34 from "path";
31972
32192
  init_git();
31973
32193
  init_resolver();
31974
32194
  init_path_safety();
31975
- import fs28 from "fs";
32195
+ import fs29 from "fs";
31976
32196
  import path33 from "path";
31977
32197
  async function getUpstream(wtPath) {
31978
32198
  try {
@@ -32014,7 +32234,7 @@ async function planRename(repo, oldBranch, newBranch, opts) {
32014
32234
  throw new Error(`Worktree '${oldBranch}' is locked — unlock it before renaming`);
32015
32235
  }
32016
32236
  const newPath = `${repo.wtRoot}/${newBranch}`;
32017
- if (fs28.existsSync(newPath)) {
32237
+ if (fs29.existsSync(newPath)) {
32018
32238
  throw new Error(`Target path already exists: ${newPath}`);
32019
32239
  }
32020
32240
  if (!opts.dryRun && await localBranchExists(repo.mainPath, newBranch, opts)) {
@@ -32043,7 +32263,7 @@ async function renameWorktree(params) {
32043
32263
  }
32044
32264
  await gitExec(["-C", planned.worktreePath, "branch", "-m", oldBranch, newBranch], opts);
32045
32265
  try {
32046
- fs28.mkdirSync(path33.dirname(planned.newPath), { recursive: true });
32266
+ fs29.mkdirSync(path33.dirname(planned.newPath), { recursive: true });
32047
32267
  await gitExec(["-C", repo.mainPath, "worktree", "move", planned.worktreePath, planned.newPath], opts);
32048
32268
  } catch (err) {
32049
32269
  try {
@@ -32061,10 +32281,10 @@ async function renameWorktree(params) {
32061
32281
  const resyncedFiles = [];
32062
32282
  const keptLocalSyncFiles = [];
32063
32283
  for (const entry of repo.config.sync_files ?? []) {
32064
- if (!fs28.existsSync(path33.join(repo.mainPath, entry)))
32284
+ if (!fs29.existsSync(path33.join(repo.mainPath, entry)))
32065
32285
  continue;
32066
32286
  const destPath = path33.join(planned.newPath, entry);
32067
- if (!fs28.existsSync(destPath)) {
32287
+ if (!fs29.existsSync(destPath)) {
32068
32288
  if (syncEntry(repo.mainPath, planned.newPath, entry)) {
32069
32289
  resyncedFiles.push(entry);
32070
32290
  }
@@ -32173,7 +32393,7 @@ init_resolver();
32173
32393
  init_deps();
32174
32394
  init_forge();
32175
32395
  init_types2();
32176
- import fs29 from "fs";
32396
+ import fs30 from "fs";
32177
32397
  init_owner();
32178
32398
  init_stack();
32179
32399
  init_source();
@@ -32223,7 +32443,7 @@ function registerStatusCommand(program2) {
32223
32443
  const jsonOutputs = [];
32224
32444
  for (const repo of repos) {
32225
32445
  const wtPath = getWorktreePath(repo, branch);
32226
- if (!fs29.existsSync(wtPath)) {
32446
+ if (!fs30.existsSync(wtPath)) {
32227
32447
  continue;
32228
32448
  }
32229
32449
  found++;
@@ -32523,7 +32743,7 @@ function registerPrsCommand(program2) {
32523
32743
 
32524
32744
  // src/commands/skill.ts
32525
32745
  init_log();
32526
- import fs30 from "fs";
32746
+ import fs31 from "fs";
32527
32747
  import path35 from "path";
32528
32748
  import { URL as URL2 } from "url";
32529
32749
  var __dirname2 = new URL2(".", import.meta.url).pathname;
@@ -32680,7 +32900,7 @@ function registerSkillCommand(program2) {
32680
32900
  }
32681
32901
  let projectRoot = path35.resolve(__dirname2, "..", "..");
32682
32902
  let skillPath = path35.join(projectRoot, "skills", `${platform2}.md`);
32683
- if (fs30.existsSync(skillPath)) {
32903
+ if (fs31.existsSync(skillPath)) {
32684
32904
  process.stdout.write(skillPath + `
32685
32905
  `);
32686
32906
  } else {
@@ -32727,7 +32947,7 @@ init_deps();
32727
32947
  import readline3 from "readline";
32728
32948
  init_path_safety();
32729
32949
  init_stack();
32730
- import fs32 from "fs";
32950
+ import fs33 from "fs";
32731
32951
  async function runMcpServer(opts = {}) {
32732
32952
  const input = opts.input ?? process.stdin;
32733
32953
  const output = opts.output ?? process.stdout;
@@ -32910,7 +33130,7 @@ async function handleToolCall(name, args, config2, _opts) {
32910
33130
  for (const wt of wts) {
32911
33131
  if (!wt.path.startsWith(repo.wtRoot))
32912
33132
  continue;
32913
- const dirtyCount = fs32.existsSync(wt.path) ? (await getDirtyFiles(wt.path)).length : 0;
33133
+ const dirtyCount = fs33.existsSync(wt.path) ? (await getDirtyFiles(wt.path)).length : 0;
32914
33134
  items.push({
32915
33135
  repo: repo.name,
32916
33136
  branch: wt.branch || null,
@@ -32938,7 +33158,7 @@ async function handleToolCall(name, args, config2, _opts) {
32938
33158
  }
32939
33159
  const repo = resolveRepos(config2, [args.repo])[0];
32940
33160
  const wtPath = getWorktreePath(repo, args.branch);
32941
- if (!fs32.existsSync(wtPath)) {
33161
+ if (!fs33.existsSync(wtPath)) {
32942
33162
  throw { isToolError: true, message: `Worktree not found for branch ${args.branch}` };
32943
33163
  }
32944
33164
  const mainBranch = await resolveMainBranch(repo, config2);
@@ -33057,7 +33277,7 @@ async function handleToolCall(name, args, config2, _opts) {
33057
33277
  if (children.length > 0 && args.force !== true) {
33058
33278
  throw { isToolError: true, message: `Branch has dependent worktrees: ${children.join(", ")}. Use force:true to override.` };
33059
33279
  }
33060
- if (!args.force && fs32.existsSync(wtPath)) {
33280
+ if (!args.force && fs33.existsSync(wtPath)) {
33061
33281
  const dirty = await getDirtyFiles(wtPath);
33062
33282
  if (dirty.length > 0) {
33063
33283
  throw { isToolError: true, message: `Worktree is dirty. Use force:true to remove it.` };
@@ -33076,7 +33296,7 @@ async function handleToolCall(name, args, config2, _opts) {
33076
33296
  }
33077
33297
  const repo = resolveRepos(config2, [args.repo])[0];
33078
33298
  const wtPath = getWorktreePath(repo, args.branch);
33079
- if (!fs32.existsSync(wtPath)) {
33299
+ if (!fs33.existsSync(wtPath)) {
33080
33300
  throw { isToolError: true, message: `Worktree not found for branch ${args.branch}` };
33081
33301
  }
33082
33302
  const mainBranch = await resolveMainBranch(repo, config2);
@@ -33298,7 +33518,7 @@ init_config();
33298
33518
  init_resolver();
33299
33519
  init_log();
33300
33520
  init_history();
33301
- import fs33 from "fs";
33521
+ import fs34 from "fs";
33302
33522
  var program2 = new Command;
33303
33523
  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);
33304
33524
  var MUTATING_COMMANDS = new Set([
@@ -33402,7 +33622,7 @@ program2.command("_resolve-path <repo> <branch>", { hidden: true }).description(
33402
33622
  }
33403
33623
  const repo = repos[0];
33404
33624
  const wtPath = getWorktreePath(repo, branch);
33405
- if (!fs33.existsSync(wtPath)) {
33625
+ if (!fs34.existsSync(wtPath)) {
33406
33626
  process.stderr.write(`✗ No worktree at ${wtPath}
33407
33627
  `);
33408
33628
  process.exit(1);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ogpoyraz/wtx",
3
- "version": "0.8.3",
3
+ "version": "0.8.5",
4
4
  "description": "Multi-repo git worktree manager",
5
5
  "type": "module",
6
6
  "bin": {