workshell 0.2.0 → 0.4.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.
Files changed (3) hide show
  1. package/README.md +47 -5
  2. package/dist/index.js +151 -11
  3. package/package.json +1 -1
package/README.md CHANGED
@@ -68,6 +68,7 @@ This approach has some nice properties.
68
68
 
69
69
  - 🖥️ **Tab-local workspaces** — Normally a `git checkout`/`git switch` changes your active branch for all terminals. With workshells, you can functionality open branches *in the current tab only*.
70
70
  - 🌳 **Full isolation** — Each workshell is isolated on disk, so the changes you make don't interfere anything else you're doing.
71
+ - 📦 **Instant setup** — Untracked files (`.env.*`, etc) are automatically copied using [copy-on-write](https://notes.billmill.org/blog/2024/03/How_I_use_git_worktrees.html). For JS projects, the package manager is auto-detected and `npm/yarn/pnpm/bun install` runs automatically.
71
72
  - 🙅‍♂️ **Never stash again** — You can `wk open` a branch even with uncommitted changes. When you exit the subshell, things will be exactly the same as they were. ☕️
72
73
  - 🪾 **Consistent with branch semantics** — As with regular `git switch`, `wk close` won't let you close the subshell if you have unstaged/uncommitted changes. This is a feature, not a bug! Regular worktrees make it easy to lose your work in a forgotten corner of your file system.
73
74
  - 🤖 **Agent-ready** — Spin up parallel workshells so multiple agents can work simultaneously without conflicts.
@@ -310,9 +311,48 @@ Choice (1/2): 1
310
311
 
311
312
  <br />
312
313
 
314
+ ## Automatic file copying
315
+
316
+ When you create a workshell, `wk` automatically copies all gitignored files from your repo root to the new worktree using **copy-on-write**. This means:
317
+
318
+ - `.env`, `.venv`, etc. are instantly available
319
+ - Copy-on-write is near-instant and uses zero extra disk space (until files are modified)
320
+
321
+ This works automatically on macOS (APFS) and Linux (Btrfs/XFS). On other filesystems, files are copied normally.
322
+
323
+ ### JS package manager detection
324
+
325
+ For JavaScript projects, `wk` auto-detects your package manager from lockfiles:
326
+
327
+ | Lockfile | Package Manager |
328
+ |----------|-----------------|
329
+ | `pnpm-lock.yaml` | pnpm |
330
+ | `yarn.lock` | yarn |
331
+ | `bun.lockb` / `bun.lock` | bun |
332
+ | `package-lock.json` | npm |
333
+
334
+ When detected, `node_modules` is skipped during copying and `<pm> install` runs automatically. This is faster than copying large `node_modules` directories.
335
+
336
+ ### Python package manager detection
337
+
338
+ For Python projects, `wk` auto-detects your package manager from lockfiles:
339
+
340
+ | Lockfile | Package Manager | Install Command |
341
+ |----------|-----------------|-----------------|
342
+ | `uv.lock` | uv | `uv sync` |
343
+ | `poetry.lock` | poetry | `poetry install` |
344
+ | `Pipfile.lock` | pipenv | `pipenv install` |
345
+ | `pdm.lock` | pdm | `pdm install` |
346
+
347
+ When detected, `.venv` and `venv` are skipped during copying and the install command runs automatically. Mixed JS+Python projects are supported: both ecosystems are detected and their installs run in sequence.
348
+
349
+ To disable auto-detection, add a custom `setup` script in `workshell.toml`.
350
+
351
+ <br />
352
+
313
353
  ## `workshell.toml`
314
354
 
315
- You can configure `wk` with a `workshell.toml` file. This is useful for running setup scripts when opening a workshell (e.g., `npm install`).
355
+ You can optionally configure `wk` with a `workshell.toml` file.
316
356
 
317
357
  `wk` looks for config files in the following order:
318
358
 
@@ -323,13 +363,15 @@ You can configure `wk` with a `workshell.toml` file. This is useful for running
323
363
 
324
364
  <br />
325
365
 
326
- Currently only one setting is supported: `setup`.
366
+ ### Options
327
367
 
328
368
  ```toml
329
- # setup script executed in subshell after initialization
330
- # the following variable substitutions are supported
369
+ # Optional: setup script executed in subshell after initialization
370
+ # Variable substitutions:
331
371
  # `{{ branch }}` — The name of the opened branch, e.g. `feature/auth`
332
372
  # `{{ repo_path }}` — The absolute path to main repo, e.g. `/path/to/repo`
333
373
  # `{{ worktree_path }}` — The absolute path to worktree, e.g. `~/.workshell/worktrees/.../repo@feat`
334
- setup = "npm install && cp {{ repo_path }}/.env {{ worktree_path }}/.env"
374
+ setup = "nvm use"
335
375
  ```
376
+
377
+ Note: Setting `setup` disables automatic package manager detection. If you want both, include the install command in your setup script.
package/dist/index.js CHANGED
@@ -5783,6 +5783,7 @@ var require_src = __commonJS({
5783
5783
 
5784
5784
  // index.ts
5785
5785
  var import_picocolors4 = __toESM(require_picocolors(), 1);
5786
+ import { spawnSync as spawnSync2 } from "child_process";
5786
5787
 
5787
5788
  // commands/new.ts
5788
5789
  import { basename as basename3, dirname as dirname3, join as join3 } from "path";
@@ -6604,7 +6605,7 @@ function loadConfig() {
6604
6605
 
6605
6606
  // utils.ts
6606
6607
  var import_picocolors = __toESM(require_picocolors(), 1);
6607
- import { execSync as execSync2, spawnSync } from "child_process";
6608
+ import { execSync as execSync2, execFileSync, spawnSync } from "child_process";
6608
6609
  import { existsSync as existsSync2, readSync, openSync, mkdirSync as mkdirSync2, writeFileSync as writeFileSync2, rmSync, lstatSync, readdirSync, readlinkSync } from "fs";
6609
6610
  import { basename as basename2, dirname as dirname2, join as join2 } from "path";
6610
6611
  import { tmpdir } from "os";
@@ -6749,6 +6750,9 @@ function isInsideWorktree() {
6749
6750
  return false;
6750
6751
  }
6751
6752
  }
6753
+ function isInsideWorkshell() {
6754
+ return process.env.WK_WORKSHELL === "1";
6755
+ }
6752
6756
  function hasUncommittedChanges() {
6753
6757
  try {
6754
6758
  const status = execSync2("git status --porcelain", { encoding: "utf-8" }).trim();
@@ -6942,13 +6946,14 @@ end
6942
6946
  # wk setup script
6943
6947
  ${setupCommand}
6944
6948
  ` : "";
6949
+ const workshellEnv = { ...process.env, WK_WORKSHELL: "1" };
6945
6950
  if (shell.endsWith("zsh")) {
6946
6951
  const tmpDir = join2(tmpdir(), `wk-${process.pid}`);
6947
6952
  mkdirSync2(tmpDir, { recursive: true });
6948
6953
  try {
6949
6954
  writeFileSync2(join2(tmpDir, ".zshrc"), `[[ -f "$HOME/.zshrc" ]] && source "$HOME/.zshrc"
6950
6955
  ${zshScript}${setupSection}`);
6951
- spawnSync(shell, ["-l"], { cwd, stdio: "inherit", env: { ...process.env, ZDOTDIR: tmpDir } });
6956
+ spawnSync(shell, ["-l"], { cwd, stdio: "inherit", env: { ...workshellEnv, ZDOTDIR: tmpDir } });
6952
6957
  } finally {
6953
6958
  rmSync(tmpDir, { recursive: true, force: true });
6954
6959
  }
@@ -6959,7 +6964,7 @@ ${zshScript}${setupSection}`);
6959
6964
  const rcFile = join2(tmpDir, ".bashrc");
6960
6965
  writeFileSync2(rcFile, `[[ -f "$HOME/.bashrc" ]] && source "$HOME/.bashrc"
6961
6966
  ${bashScript}${setupSection}`);
6962
- spawnSync(shell, ["--rcfile", rcFile, "-il"], { cwd, stdio: "inherit" });
6967
+ spawnSync(shell, ["--rcfile", rcFile, "-il"], { cwd, stdio: "inherit", env: workshellEnv });
6963
6968
  } finally {
6964
6969
  rmSync(tmpDir, { recursive: true, force: true });
6965
6970
  }
@@ -6967,7 +6972,7 @@ ${bashScript}${setupSection}`);
6967
6972
  const fishInit = setupCommand ? `${fishScript}
6968
6973
  # wk setup script
6969
6974
  ${setupCommand}` : fishScript;
6970
- spawnSync(shell, ["--init-command", fishInit], { cwd, stdio: "inherit" });
6975
+ spawnSync(shell, ["--init-command", fishInit], { cwd, stdio: "inherit", env: workshellEnv });
6971
6976
  } else {
6972
6977
  console.error(`Error: Unsupported shell '${shell}'`);
6973
6978
  console.error("wk requires bash, zsh, or fish.");
@@ -6976,6 +6981,47 @@ ${setupCommand}` : fishScript;
6976
6981
  }
6977
6982
  var success = (text) => import_picocolors.default.green(import_picocolors.default.bold("\u2713")) + " " + text;
6978
6983
  var warn = (text) => import_picocolors.default.yellow(import_picocolors.default.bold("\u26A0")) + " " + text;
6984
+ function hasBinary(name) {
6985
+ try {
6986
+ execSync2(`command -v ${name}`, { stdio: "ignore" });
6987
+ return true;
6988
+ } catch {
6989
+ return false;
6990
+ }
6991
+ }
6992
+ function detectJsPackageManager(cwd) {
6993
+ if (existsSync2(join2(cwd, "pnpm-lock.yaml")) && hasBinary("pnpm")) return "pnpm";
6994
+ if (existsSync2(join2(cwd, "yarn.lock")) && hasBinary("yarn")) return "yarn";
6995
+ if ((existsSync2(join2(cwd, "bun.lockb")) || existsSync2(join2(cwd, "bun.lock"))) && hasBinary("bun")) return "bun";
6996
+ if (existsSync2(join2(cwd, "package-lock.json")) && hasBinary("npm")) return "npm";
6997
+ return null;
6998
+ }
6999
+ function detectPythonPackageManager(cwd) {
7000
+ if (existsSync2(join2(cwd, "uv.lock")) && hasBinary("uv")) return "uv";
7001
+ if (existsSync2(join2(cwd, "poetry.lock")) && hasBinary("poetry")) return "poetry";
7002
+ if (existsSync2(join2(cwd, "Pipfile.lock")) && hasBinary("pipenv")) return "pipenv";
7003
+ if (existsSync2(join2(cwd, "pdm.lock")) && hasBinary("pdm")) return "pdm";
7004
+ return null;
7005
+ }
7006
+ var installCmds = {
7007
+ npm: "npm install",
7008
+ yarn: "yarn install",
7009
+ pnpm: "pnpm install",
7010
+ bun: "bun install",
7011
+ uv: "uv sync",
7012
+ poetry: "poetry install",
7013
+ pipenv: "pipenv install",
7014
+ pdm: "pdm install"
7015
+ };
7016
+ function runPackageManagerInstall(pm, cwd) {
7017
+ const cmd2 = installCmds[pm];
7018
+ console.log(dim(`Running ${cmd2}...`));
7019
+ try {
7020
+ execSync2(cmd2, { cwd, stdio: "inherit" });
7021
+ } catch {
7022
+ console.error(warn(`${cmd2} failed \u2014 you may need to run it manually`));
7023
+ }
7024
+ }
6979
7025
  var fail = (text) => import_picocolors.default.red(import_picocolors.default.bold("\u2717")) + " " + text;
6980
7026
  var dim = (text) => import_picocolors.default.dim(text);
6981
7027
  var bold = (text) => import_picocolors.default.bold(text);
@@ -6983,6 +7029,71 @@ var green = (text) => import_picocolors.default.green(text);
6983
7029
  var cyan = (text) => import_picocolors.default.cyan(text);
6984
7030
  var yellow = (text) => import_picocolors.default.yellow(text);
6985
7031
  var red = (text) => import_picocolors.default.red(text);
7032
+ function cpCow(src, dest) {
7033
+ try {
7034
+ if (process.platform === "darwin") {
7035
+ execFileSync("cp", ["-RPc", src, dest], { stdio: "ignore" });
7036
+ } else if (process.platform === "linux") {
7037
+ execFileSync("cp", ["-RP", "--reflink=auto", src, dest], { stdio: "ignore" });
7038
+ } else {
7039
+ execFileSync("cp", ["-RP", src, dest], { stdio: "ignore" });
7040
+ }
7041
+ } catch (err) {
7042
+ if (err && typeof err === "object" && "code" in err && err.code === "ENOENT") {
7043
+ return;
7044
+ }
7045
+ if (process.platform === "darwin") {
7046
+ try {
7047
+ execFileSync("cp", ["-RP", src, dest], { stdio: "ignore" });
7048
+ return;
7049
+ } catch {
7050
+ }
7051
+ }
7052
+ console.error(warn(`Failed to copy ${src}: ${err instanceof Error ? err.message : String(err)}`));
7053
+ }
7054
+ }
7055
+ function getIgnoredPaths(repoPath) {
7056
+ try {
7057
+ const output = execFileSync(
7058
+ "git",
7059
+ ["ls-files", "--others", "--ignored", "--exclude-standard", "--directory"],
7060
+ { cwd: repoPath, encoding: "utf-8" }
7061
+ );
7062
+ const paths = output.split("\n").map((p) => p.replace(/\/$/, "")).filter((p) => p.length > 0 && p !== ".git").sort();
7063
+ const minimal = [];
7064
+ let prev = "";
7065
+ for (const path of paths) {
7066
+ if (prev && path.startsWith(prev + "/")) continue;
7067
+ minimal.push(path);
7068
+ prev = path;
7069
+ }
7070
+ return minimal;
7071
+ } catch {
7072
+ return [];
7073
+ }
7074
+ }
7075
+ var JUNK_FILES = /* @__PURE__ */ new Set([".DS_Store", "Thumbs.db", "desktop.ini"]);
7076
+ function isJunkFile(item) {
7077
+ const base = item.includes("/") ? item.slice(item.lastIndexOf("/") + 1) : item;
7078
+ return JUNK_FILES.has(base);
7079
+ }
7080
+ function copyIgnoredFiles(repoPath, worktreePath, skipDirs) {
7081
+ let items = getIgnoredPaths(repoPath);
7082
+ items = items.filter((item) => !isJunkFile(item));
7083
+ if (skipDirs && skipDirs.length > 0) {
7084
+ items = items.filter(
7085
+ (item) => !skipDirs.some((dir) => item.split("/").includes(dir))
7086
+ );
7087
+ }
7088
+ if (items.length === 0) return;
7089
+ console.log(dim(`Copying untracked files:`));
7090
+ for (const item of items) {
7091
+ console.log(dim(` ${item}`));
7092
+ const dest = join2(worktreePath, item);
7093
+ mkdirSync2(dirname2(dest), { recursive: true });
7094
+ cpCow(join2(repoPath, item), dest);
7095
+ }
7096
+ }
6986
7097
  function autoCleanupWorktree(worktreeId, path, store, saveStore2) {
6987
7098
  const branch = getWorktreeBranch(path);
6988
7099
  const status = getWorktreeStatus(path);
@@ -7075,6 +7186,15 @@ function newCommand(branchName, fromBranch) {
7075
7186
  mkdirSync3(dirname3(worktreePath), { recursive: true });
7076
7187
  createWorktree(branch, worktreePath, fromBranch);
7077
7188
  initSubmodules(worktreePath);
7189
+ const config = loadConfig();
7190
+ const jsPm = config?.setup ? null : detectJsPackageManager(mainWorktree);
7191
+ const pyPm = config?.setup ? null : detectPythonPackageManager(mainWorktree);
7192
+ const skipDirs = [];
7193
+ if (jsPm) skipDirs.push("node_modules");
7194
+ if (pyPm) skipDirs.push(".venv", "venv");
7195
+ copyIgnoredFiles(mainWorktree, worktreePath, skipDirs);
7196
+ if (jsPm) runPackageManagerInstall(jsPm, worktreePath);
7197
+ if (pyPm) runPackageManagerInstall(pyPm, worktreePath);
7078
7198
  setWorktreeMeta(store, worktreeId, {
7079
7199
  created_at: (/* @__PURE__ */ new Date()).toISOString()
7080
7200
  });
@@ -7185,6 +7305,15 @@ function openBranch(branch, prNumber) {
7185
7305
  mkdirSync4(dirname4(worktreePath), { recursive: true });
7186
7306
  createWorktreeForExistingBranch(branch, worktreePath);
7187
7307
  initSubmodules(worktreePath);
7308
+ const config = loadConfig();
7309
+ const jsPm = config?.setup ? null : detectJsPackageManager(mainWorktree);
7310
+ const pyPm = config?.setup ? null : detectPythonPackageManager(mainWorktree);
7311
+ const skipDirs = [];
7312
+ if (jsPm) skipDirs.push("node_modules");
7313
+ if (pyPm) skipDirs.push(".venv", "venv");
7314
+ copyIgnoredFiles(mainWorktree, worktreePath, skipDirs);
7315
+ if (jsPm) runPackageManagerInstall(jsPm, worktreePath);
7316
+ if (pyPm) runPackageManagerInstall(pyPm, worktreePath);
7188
7317
  setWorktreeMeta(store, worktreeId, {
7189
7318
  created_at: (/* @__PURE__ */ new Date()).toISOString()
7190
7319
  });
@@ -7211,10 +7340,10 @@ function openBranch(branch, prNumber) {
7211
7340
  console.log();
7212
7341
  }
7213
7342
  function openCommand(ref) {
7214
- if (isInsideWorktree()) {
7343
+ if (isInsideWorkshell()) {
7215
7344
  const currentBranch = getCurrentBranch();
7216
7345
  console.log();
7217
- console.log(warn(`You're inside a worktree (branch: ${cyan(currentBranch)})`));
7346
+ console.log(warn(`You're inside a workshell (branch: ${cyan(currentBranch)})`));
7218
7347
  console.log(` This will nest subshells (subshell inside subshell).`);
7219
7348
  console.log();
7220
7349
  if (!confirm("Continue?")) {
@@ -8316,7 +8445,7 @@ function promptChoice() {
8316
8445
  }
8317
8446
 
8318
8447
  // index.ts
8319
- var VERSION = "0.2.0";
8448
+ var VERSION = "0.4.0";
8320
8449
  function printHelp() {
8321
8450
  const dim2 = import_picocolors4.default.dim;
8322
8451
  const cyan2 = import_picocolors4.default.cyan;
@@ -8328,7 +8457,7 @@ ${import_picocolors4.default.bold("Usage:")} ${cyan2("wk")} ${dim2("<command>")}
8328
8457
  ${import_picocolors4.default.bold("Commands:")}
8329
8458
  ${green2("new")} ${dim2("[branch]")} Create a branch and open it ${dim2("[--from <branch>]")}
8330
8459
  ${green2("open")} ${dim2("<ref>")} Open a branch or PR in an ephemeral subshell
8331
- ${green2("close")} Exit current subshell
8460
+ ${green2("close")} Exit current workshell
8332
8461
  ${green2("ls")} List open branches
8333
8462
  ${green2("status")} Show current branch
8334
8463
  ${green2("rm")} ${dim2("<branch>")} Remove a branch's worktree
@@ -8459,7 +8588,7 @@ ${import_picocolors4.default.bold("Description:")}
8459
8588
  function printCloseHelp() {
8460
8589
  const dim2 = import_picocolors4.default.dim;
8461
8590
  const cyan2 = import_picocolors4.default.cyan;
8462
- console.log(`${import_picocolors4.default.bold("wk close")} - Exit current subshell
8591
+ console.log(`${import_picocolors4.default.bold("wk close")} - Exit current workshell
8463
8592
 
8464
8593
  ${import_picocolors4.default.bold("Usage:")} ${cyan2("wk close")} ${dim2("[options]")}
8465
8594
 
@@ -8467,7 +8596,7 @@ ${import_picocolors4.default.bold("Options:")}
8467
8596
  ${cyan2("-f")}, ${cyan2("--force")} Discard uncommitted changes and exit
8468
8597
 
8469
8598
  ${import_picocolors4.default.bold("Description:")}
8470
- Exits the current subshell and returns to the parent.
8599
+ Exits the current workshell and returns to the parent.
8471
8600
 
8472
8601
  On exit, if the branch is clean:
8473
8602
  - Worktree is pruned (branch is kept)
@@ -8575,7 +8704,18 @@ switch (cmd) {
8575
8704
  printCloseHelp();
8576
8705
  process.exit(0);
8577
8706
  }
8578
- console.error(fail("'wk close' only works inside a wk subshell."));
8707
+ if (!isInsideWorkshell() && isInsideWorktree()) {
8708
+ const currentBranch = getCurrentBranch();
8709
+ const mainWorktree = getMainWorktree();
8710
+ const shell = process.env.SHELL || "/bin/bash";
8711
+ console.log();
8712
+ console.log(dim(`You're in a worktree (branch: ${cyan(currentBranch)}) but not a workshell.`));
8713
+ console.log(dim(`Opening repo root in a fresh shell...`));
8714
+ console.log();
8715
+ spawnSync2(shell, [], { cwd: mainWorktree, stdio: "inherit" });
8716
+ process.exit(0);
8717
+ }
8718
+ console.error(fail("'wk close' only works inside a workshell."));
8579
8719
  process.exit(1);
8580
8720
  break;
8581
8721
  case "__preclose":
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "workshell",
3
- "version": "0.2.0",
3
+ "version": "0.4.0",
4
4
  "description": "Agent- and human-friendly Git multitasking, powered by worktrees",
5
5
  "type": "module",
6
6
  "license": "MIT",