@ogpoyraz/wtx 0.8.1 → 0.8.3

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.
Files changed (3) hide show
  1. package/README.md +1 -1
  2. package/dist/cli.mjs +108 -28
  3. package/package.json +1 -1
package/README.md CHANGED
@@ -169,7 +169,7 @@ Config lives at `~/.config/wtx/config.json`.
169
169
  | `agents.<name>.command` | – | Shell command template for `--agent`; `{wt}`, `{branch}`, `{repo}` expanded |
170
170
  | `repos.<name>.main_branch` | `"auto"` | Auto-detects via `git symbolic-ref` |
171
171
  | `repos.<name>.fetch_main_on_create` | `true` | Fetch before creating so branches start fresh |
172
- | `repos.<name>.sync_files` | `[]` | Files copied from main checkout on create and sync |
172
+ | `repos.<name>.sync_files` | `[]` | Files or folders copied from main checkout on create and sync — directories are copied recursively (`build/`) |
173
173
  | `repos.<name>.post_create` / `post_sync` | `[]` | Hook commands; failures fail the command with a rerun hint |
174
174
  | `repos.<name>.install_script` | `null` | Command run inside a worktree (or the main checkout via `wtx deps --install`) for dependency installs (`{wt}`, `{branch}`, `{main}` expanded); when unset, the detected package manager performs a real install |
175
175
  | `repos.<name>.deps.manager` | `"auto"` | Force a manager: `npm` `bun` `pnpm` `yarn` `go` `python` `cargo` |
package/dist/cli.mjs CHANGED
@@ -26388,7 +26388,8 @@ var init_HelpOverlay = __esm(() => {
26388
26388
  ["e", "View data warnings (when count > 0)"],
26389
26389
  ["r", "Refresh data"],
26390
26390
  ["H", "Action history"],
26391
- ["cmd+c / ctrl+shift+c", "Copy selected text to clipboard"],
26391
+ ["mouse drag", "Select text — copied to clipboard automatically"],
26392
+ ["ctrl+shift+c", "Copy selection again (terminals reserve cmd+c)"],
26392
26393
  ["click PR #/URL", "Open pull request in browser"],
26393
26394
  ["?", "Toggle help"],
26394
26395
  ["q/esc", "Quit"]
@@ -26738,8 +26739,8 @@ var init_HistoryOverlay = __esm(() => {
26738
26739
  // src/tui/components/InputModal.tsx
26739
26740
  import { useState as useState3 } from "react";
26740
26741
  import { jsx as jsx9, jsxs as jsxs8 } from "@opentui/react/jsx-runtime";
26741
- function InputModal({ title, placeholder, errorMessage, onSubmit }) {
26742
- const [value, setValue] = useState3("");
26742
+ function InputModal({ title, placeholder, initialValue, errorMessage, onSubmit }) {
26743
+ const [value, setValue] = useState3(initialValue ?? "");
26743
26744
  return /* @__PURE__ */ jsx9(Overlay, {
26744
26745
  title,
26745
26746
  borderColor: tokens.accent,
@@ -26748,6 +26749,7 @@ function InputModal({ title, placeholder, errorMessage, onSubmit }) {
26748
26749
  children: [
26749
26750
  /* @__PURE__ */ jsx9("input", {
26750
26751
  focused: true,
26752
+ value: initialValue ?? "",
26751
26753
  placeholder,
26752
26754
  onInput: (val) => setValue(val),
26753
26755
  onSubmit: () => onSubmit(value)
@@ -27195,7 +27197,8 @@ function ConfigOverlay({ onClose, onSaved, onError }) {
27195
27197
  }),
27196
27198
  editState.type === "input" && /* @__PURE__ */ jsx11(InputModal, {
27197
27199
  title: editState.title,
27198
- placeholder: editState.currentValue || "Enter value...",
27200
+ initialValue: editState.currentValue,
27201
+ placeholder: "Enter value...",
27199
27202
  errorMessage: editState.error,
27200
27203
  onSubmit: handleInputSubmit
27201
27204
  }),
@@ -27275,7 +27278,7 @@ var init_useSpinnerFrame = __esm(() => {
27275
27278
 
27276
27279
  // src/tui/components/App.tsx
27277
27280
  import { useState as useState7, useEffect as useEffect6, useMemo, useRef as useRef4, useCallback as useCallback3 } from "react";
27278
- import { useKeyboard as useKeyboard3, useRenderer } from "@opentui/react";
27281
+ import { useKeyboard as useKeyboard3, useRenderer, useSelectionHandler } from "@opentui/react";
27279
27282
  import { jsx as jsx13, jsxs as jsxs12 } from "@opentui/react/jsx-runtime";
27280
27283
  function getWorktreePathFor(repoName, branch) {
27281
27284
  try {
@@ -27330,6 +27333,16 @@ function App({ opts }) {
27330
27333
  setActionMessage(message);
27331
27334
  messageTimer.current = setTimeout(() => setActionMessage(undefined), ms);
27332
27335
  }, []);
27336
+ const copySelectedText = useCallback3((warnOnEmpty) => {
27337
+ const text = renderer.getSelection()?.getSelectedText() ?? "";
27338
+ if (!text) {
27339
+ if (warnOnEmpty)
27340
+ flash("Nothing selected to copy");
27341
+ return;
27342
+ }
27343
+ copyTextToClipboard(renderer, text).then((ok) => flash(ok ? `Copied ${text.length} character${text.length !== 1 ? "s" : ""}` : "Copy failed"));
27344
+ }, [renderer, flash]);
27345
+ useSelectionHandler(() => copySelectedText(false));
27333
27346
  const busyRepos = useMemo(() => new Set(ops.flatMap((o2) => o2.repoNames)), [ops]);
27334
27347
  const busyRowPaths = useMemo(() => new Set(ops.map((o2) => o2.rowPath).filter((p) => p !== undefined)), [ops]);
27335
27348
  const anyRunning = ops.some((o2) => o2.status === "running");
@@ -27564,12 +27577,7 @@ function App({ opts }) {
27564
27577
  }
27565
27578
  }
27566
27579
  if ((key.super || key.meta || key.ctrl && key.shift) && key.name === "c") {
27567
- const text = renderer.getSelection()?.getSelectedText() ?? "";
27568
- if (!text) {
27569
- flash("Nothing selected to copy");
27570
- return;
27571
- }
27572
- copyTextToClipboard(renderer, text).then((ok) => flash(ok ? `Copied ${text.length} character${text.length !== 1 ? "s" : ""}` : "Copy failed"));
27580
+ copySelectedText(true);
27573
27581
  return;
27574
27582
  }
27575
27583
  if (key.name === "q" || key.name === "escape" || key.name === "c" && key.ctrl) {
@@ -27932,7 +27940,8 @@ function App({ opts }) {
27932
27940
  }),
27933
27941
  renameModal && selectedRow && /* @__PURE__ */ jsx13(InputModal, {
27934
27942
  title: `Rename branch ${selectedRow.branch}`,
27935
- placeholder: `New branch name (${selectedRow.branch})`,
27943
+ initialValue: selectedRow.branch,
27944
+ placeholder: "New branch name",
27936
27945
  errorMessage: renameError,
27937
27946
  onSubmit: (value) => {
27938
27947
  const target = selectedRow;
@@ -27962,7 +27971,7 @@ function App({ opts }) {
27962
27971
  `Branch will be renamed and the checkout moved to:`,
27963
27972
  getWorktreePathFor(modal.row.repoName, modal.to),
27964
27973
  "",
27965
- "The old directory will be removed.",
27974
+ "Uncommitted changes and synced files move with it.",
27966
27975
  "Proceed?"
27967
27976
  ].join(`
27968
27977
  `)
@@ -30315,20 +30324,29 @@ async function getWorktreePort(repoName, branch, config2, currentWtPath) {
30315
30324
  }
30316
30325
 
30317
30326
  // src/lib/worktree-setup.ts
30327
+ function syncEntry(mainPath, wtPath, entry) {
30328
+ const src = path17.join(mainPath, entry);
30329
+ const dest = path17.join(wtPath, entry);
30330
+ if (!fs10.existsSync(src))
30331
+ return false;
30332
+ fs10.mkdirSync(path17.dirname(dest), { recursive: true });
30333
+ if (fs10.statSync(src).isDirectory()) {
30334
+ fs10.cpSync(src, dest, { recursive: true });
30335
+ } else {
30336
+ fs10.copyFileSync(src, dest);
30337
+ }
30338
+ return true;
30339
+ }
30318
30340
  async function runPostCreateSetup(params) {
30319
30341
  const { config: config2, repo, wtPath, branch, globalOpts } = params;
30320
30342
  let copiedFiles = [];
30321
30343
  let hooks = [];
30322
30344
  if (repo.config.sync_files && repo.config.sync_files.length > 0) {
30323
30345
  for (const file2 of repo.config.sync_files) {
30324
- const src = path17.join(repo.mainPath, file2);
30325
- const dest = path17.join(wtPath, file2);
30326
- if (fs10.existsSync(src)) {
30327
- if (!globalOpts.dryRun) {
30328
- fs10.mkdirSync(path17.dirname(dest), { recursive: true });
30329
- fs10.copyFileSync(src, dest);
30330
- copiedFiles.push(file2);
30331
- }
30346
+ if (!globalOpts.dryRun && syncEntry(repo.mainPath, wtPath, file2)) {
30347
+ copiedFiles.push(file2);
30348
+ }
30349
+ if (fs10.existsSync(path17.join(repo.mainPath, file2))) {
30332
30350
  stepSuccess(`Synced ${file2}`);
30333
30351
  } else {
30334
30352
  stepWarning(`Could not sync ${file2}`, "file not found in main checkout");
@@ -31709,12 +31727,9 @@ function registerSyncCommand(program2) {
31709
31727
  try {
31710
31728
  if (repo.config.sync_files) {
31711
31729
  for (const file2 of repo.config.sync_files) {
31712
- const src = path32.join(repo.mainPath, file2);
31713
- const dest = path32.join(wtPath, file2);
31714
- if (fs25.existsSync(src)) {
31730
+ if (fs25.existsSync(path32.join(repo.mainPath, file2))) {
31715
31731
  if (!opts.dryRun) {
31716
- fs25.mkdirSync(path32.dirname(dest), { recursive: true });
31717
- fs25.copyFileSync(src, dest);
31732
+ syncEntry(repo.mainPath, wtPath, file2);
31718
31733
  }
31719
31734
  stepSuccess(`Synced ${file2}`);
31720
31735
  }
@@ -31967,6 +31982,27 @@ async function getUpstream(wtPath) {
31967
31982
  return null;
31968
31983
  }
31969
31984
  }
31985
+ async function getDirtyEntries(wtPath) {
31986
+ try {
31987
+ const out = await gitExec(["-C", wtPath, "status", "--porcelain"], {});
31988
+ return out.split(`
31989
+ `).map((line) => line.trimEnd()).filter((line) => line.length > 0);
31990
+ } catch {
31991
+ return [];
31992
+ }
31993
+ }
31994
+ function touchesEntry(porcelainLine, entry) {
31995
+ const entryPath = porcelainLine.slice(3);
31996
+ return entryPath === entry || entryPath.startsWith(`${entry}/`);
31997
+ }
31998
+ async function isTrackedInWorktree(wtPath, entry) {
31999
+ try {
32000
+ await gitExec(["-C", wtPath, "ls-files", "--error-unmatch", "--", entry], {});
32001
+ return true;
32002
+ } catch {
32003
+ return false;
32004
+ }
32005
+ }
31970
32006
  async function planRename(repo, oldBranch, newBranch, opts) {
31971
32007
  const list = await getWorktreeList(repo.mainPath);
31972
32008
  const candidatePath = getWorktreePath(repo, oldBranch);
@@ -31990,6 +32026,7 @@ async function renameWorktree(params) {
31990
32026
  const { repo, oldBranch, newBranch, opts } = params;
31991
32027
  const planned = await planRename(repo, oldBranch, newBranch, opts);
31992
32028
  const cleanedDirs = [];
32029
+ const dirtyBefore = await getDirtyEntries(planned.worktreePath);
31993
32030
  if (opts.dryRun) {
31994
32031
  return {
31995
32032
  oldBranch,
@@ -31997,7 +32034,11 @@ async function renameWorktree(params) {
31997
32034
  oldPath: planned.worktreePath,
31998
32035
  newPath: planned.newPath,
31999
32036
  upstream: planned.upstream,
32000
- cleanedDirs
32037
+ cleanedDirs,
32038
+ dirtyFiles: dirtyBefore,
32039
+ lostDirtyFiles: [],
32040
+ resyncedFiles: [],
32041
+ keptLocalSyncFiles: []
32001
32042
  };
32002
32043
  }
32003
32044
  await gitExec(["-C", planned.worktreePath, "branch", "-m", oldBranch, newBranch], opts);
@@ -32015,13 +32056,40 @@ async function renameWorktree(params) {
32015
32056
  for (const dir of cleanupEmptyParents(repo.wtRoot, repo.mainPath, planned.worktreePath)) {
32016
32057
  cleanedDirs.push(dir);
32017
32058
  }
32059
+ const dirtyAfter = await getDirtyEntries(planned.newPath);
32060
+ const lostDirtyFiles = dirtyBefore.filter((line) => !dirtyAfter.includes(line));
32061
+ const resyncedFiles = [];
32062
+ const keptLocalSyncFiles = [];
32063
+ for (const entry of repo.config.sync_files ?? []) {
32064
+ if (!fs28.existsSync(path33.join(repo.mainPath, entry)))
32065
+ continue;
32066
+ const destPath = path33.join(planned.newPath, entry);
32067
+ if (!fs28.existsSync(destPath)) {
32068
+ if (syncEntry(repo.mainPath, planned.newPath, entry)) {
32069
+ resyncedFiles.push(entry);
32070
+ }
32071
+ continue;
32072
+ }
32073
+ const tracked = await isTrackedInWorktree(planned.newPath, entry);
32074
+ if (tracked && !dirtyAfter.some((line) => touchesEntry(line, entry))) {
32075
+ if (syncEntry(repo.mainPath, planned.newPath, entry)) {
32076
+ resyncedFiles.push(entry);
32077
+ }
32078
+ continue;
32079
+ }
32080
+ keptLocalSyncFiles.push(entry);
32081
+ }
32018
32082
  return {
32019
32083
  oldBranch,
32020
32084
  newBranch,
32021
32085
  oldPath: planned.worktreePath,
32022
32086
  newPath: planned.newPath,
32023
32087
  upstream: planned.upstream,
32024
- cleanedDirs
32088
+ cleanedDirs,
32089
+ dirtyFiles: dirtyAfter,
32090
+ lostDirtyFiles,
32091
+ resyncedFiles,
32092
+ keptLocalSyncFiles
32025
32093
  };
32026
32094
  }
32027
32095
 
@@ -32067,6 +32135,18 @@ function registerRenameCommand(program2) {
32067
32135
  stepSuccess("Cleaned up empty directory", path34.relative(repo.wtRoot, dir) + "/");
32068
32136
  }
32069
32137
  stepSuccess("Renamed", `${outcome.oldPath} → ${outcome.newPath}`);
32138
+ if (outcome.dirtyFiles.length > 0) {
32139
+ stepSuccess("Carried uncommitted files", `${outcome.dirtyFiles.length} entr${outcome.dirtyFiles.length === 1 ? "y" : "ies"} moved with the checkout`);
32140
+ }
32141
+ if (outcome.lostDirtyFiles.length > 0) {
32142
+ stepError("Uncommitted changes missing after rename", outcome.lostDirtyFiles.join(", "));
32143
+ }
32144
+ if (outcome.resyncedFiles.length > 0) {
32145
+ stepSuccess("Synced files", outcome.resyncedFiles.join(", "));
32146
+ }
32147
+ for (const entry of outcome.keptLocalSyncFiles) {
32148
+ indented(`Kept local version of ${entry} — it has uncommitted edits`);
32149
+ }
32070
32150
  if (outcome.upstream) {
32071
32151
  indented(`Upstream still tracks '${outcome.upstream}' — after pushing run: git push -u origin ${newBranch}`);
32072
32152
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ogpoyraz/wtx",
3
- "version": "0.8.1",
3
+ "version": "0.8.3",
4
4
  "description": "Multi-repo git worktree manager",
5
5
  "type": "module",
6
6
  "bin": {