workshell 0.1.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 +287 -267
  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
  });
@@ -7098,37 +7218,78 @@ function newCommand(branchName, fromBranch) {
7098
7218
  }
7099
7219
 
7100
7220
  // commands/open.ts
7221
+ import { execSync as execSync3 } from "child_process";
7101
7222
  import { mkdirSync as mkdirSync4 } from "fs";
7102
7223
  import { basename as basename4, dirname as dirname4, join as join4 } from "path";
7103
- function openCommand(branch) {
7104
- if (isInsideWorktree()) {
7105
- const currentBranch = getCurrentBranch();
7106
- console.log();
7107
- console.log(warn(`You're inside a worktree (branch: ${cyan(currentBranch)})`));
7108
- console.log(` This will nest subshells (subshell inside subshell).`);
7109
- console.log();
7110
- if (!confirm("Continue?")) {
7111
- process.exit(0);
7224
+ function isGhInstalled() {
7225
+ try {
7226
+ execSync3("gh --version", { stdio: "pipe", encoding: "utf-8" });
7227
+ return true;
7228
+ } catch {
7229
+ return false;
7230
+ }
7231
+ }
7232
+ function isGhAuthenticated() {
7233
+ try {
7234
+ execSync3("gh auth status", { stdio: "pipe", encoding: "utf-8" });
7235
+ return true;
7236
+ } catch {
7237
+ return false;
7238
+ }
7239
+ }
7240
+ function getPRInfo(prRef) {
7241
+ try {
7242
+ const output = execSync3(
7243
+ `gh pr view ${prRef} --json number,headRefName`,
7244
+ { encoding: "utf-8", stdio: ["pipe", "pipe", "pipe"] }
7245
+ );
7246
+ return JSON.parse(output);
7247
+ } catch {
7248
+ return null;
7249
+ }
7250
+ }
7251
+ function fetchPRBranch(prRef, prInfo) {
7252
+ try {
7253
+ execSync3(`git fetch origin ${prInfo.headRefName}:${prInfo.headRefName}`, { stdio: "pipe" });
7254
+ return true;
7255
+ } catch {
7256
+ }
7257
+ const currentBranch = getCurrentBranch();
7258
+ try {
7259
+ execSync3(`gh pr checkout ${prRef}`, { stdio: "pipe" });
7260
+ execSync3(`git checkout "${currentBranch}"`, { stdio: "pipe" });
7261
+ return true;
7262
+ } catch {
7263
+ try {
7264
+ execSync3(`git checkout "${currentBranch}"`, { stdio: "pipe" });
7265
+ } catch {
7112
7266
  }
7113
- console.log();
7114
7267
  }
7115
- if (!branchExists(branch)) {
7116
- console.error(`Error: branch '${branch}' not found`);
7117
- process.exit(1);
7268
+ try {
7269
+ execSync3(`git fetch origin pull/${prInfo.number}/head:${prInfo.headRefName}`, { stdio: "pipe" });
7270
+ return true;
7271
+ } catch {
7272
+ return false;
7118
7273
  }
7274
+ }
7275
+ function openBranch(branch, prNumber) {
7119
7276
  const parentBranch = getCurrentBranch();
7277
+ const mainWorktree = getMainWorktree();
7120
7278
  const existingWorktreePath = getWorktreeForBranch(branch);
7121
7279
  if (existingWorktreePath) {
7122
7280
  const worktreeId2 = getWorktreeId(existingWorktreePath);
7123
- const mainWorktree2 = getMainWorktree();
7124
7281
  console.log();
7125
- console.log(success(bold(branch)), dim(`(existing worktree)`));
7282
+ if (prNumber) {
7283
+ console.log(success(bold(`PR #${prNumber}`)), dim(`branch: ${cyan(branch)} (existing worktree)`));
7284
+ } else {
7285
+ console.log(success(bold(branch)), dim(`(existing worktree)`));
7286
+ }
7126
7287
  console.log(dim("Type 'wk close' to return."));
7127
7288
  console.log();
7128
7289
  spawnShell(existingWorktreePath, {
7129
7290
  branch,
7130
7291
  worktreePath: existingWorktreePath,
7131
- repoPath: mainWorktree2
7292
+ repoPath: mainWorktree
7132
7293
  });
7133
7294
  console.log();
7134
7295
  console.log(success(`Back in ${bold(parentBranch)}`));
@@ -7138,20 +7299,33 @@ function openCommand(branch) {
7138
7299
  return;
7139
7300
  }
7140
7301
  const store = loadStore();
7141
- const mainWorktree = getMainWorktree();
7142
7302
  const repoName = basename4(mainWorktree);
7143
7303
  const worktreeId = `${repoName}@${slugify(branch)}`;
7144
7304
  const worktreePath = join4(getWorktreesDir(), worktreeId);
7145
7305
  mkdirSync4(dirname4(worktreePath), { recursive: true });
7146
7306
  createWorktreeForExistingBranch(branch, worktreePath);
7147
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);
7148
7317
  setWorktreeMeta(store, worktreeId, {
7149
7318
  created_at: (/* @__PURE__ */ new Date()).toISOString()
7150
7319
  });
7151
7320
  saveStore(store);
7152
7321
  console.log();
7153
- console.log(success(bold(branch)));
7154
- console.log(dim(`Opened branch in ephemeral subshell`));
7322
+ if (prNumber) {
7323
+ console.log(success(bold(`PR #${prNumber}`)), dim(`branch: ${cyan(branch)}`));
7324
+ console.log(dim(`Opened PR in ephemeral subshell`));
7325
+ } else {
7326
+ console.log(success(bold(branch)));
7327
+ console.log(dim(`Opened branch in ephemeral subshell`));
7328
+ }
7155
7329
  console.log(dim("Type 'exit' or 'wk close' to return."));
7156
7330
  console.log();
7157
7331
  spawnShell(worktreePath, {
@@ -7165,6 +7339,55 @@ function openCommand(branch) {
7165
7339
  autoCleanupWorktree(worktreeId, worktreePath, currentStore, saveStore);
7166
7340
  console.log();
7167
7341
  }
7342
+ function openCommand(ref) {
7343
+ if (isInsideWorkshell()) {
7344
+ const currentBranch = getCurrentBranch();
7345
+ console.log();
7346
+ console.log(warn(`You're inside a workshell (branch: ${cyan(currentBranch)})`));
7347
+ console.log(` This will nest subshells (subshell inside subshell).`);
7348
+ console.log();
7349
+ if (!confirm("Continue?")) {
7350
+ process.exit(0);
7351
+ }
7352
+ console.log();
7353
+ }
7354
+ if (branchExists(ref)) {
7355
+ openBranch(ref);
7356
+ return;
7357
+ }
7358
+ if (!isGhInstalled()) {
7359
+ console.error(fail(`Branch '${ref}' not found.`));
7360
+ console.error(dim(" Install GitHub CLI (gh) to open PRs: https://cli.github.com/"));
7361
+ process.exit(1);
7362
+ }
7363
+ if (!isGhAuthenticated()) {
7364
+ console.error(fail(`Branch '${ref}' not found.`));
7365
+ console.error(dim(" Run 'gh auth login' to open PRs from GitHub."));
7366
+ process.exit(1);
7367
+ }
7368
+ const prInfo = getPRInfo(ref);
7369
+ if (!prInfo) {
7370
+ console.error(fail(`'${ref}' is not a local branch or GitHub PR.`));
7371
+ process.exit(1);
7372
+ }
7373
+ const branch = prInfo.headRefName;
7374
+ const prNumber = prInfo.number;
7375
+ if (branchExists(branch)) {
7376
+ console.log(dim(`PR #${prNumber} \u2192 ${branch}`));
7377
+ openBranch(branch, prNumber);
7378
+ return;
7379
+ }
7380
+ console.log(dim(`Fetching PR #${prNumber}...`));
7381
+ if (!fetchPRBranch(ref, prInfo)) {
7382
+ console.error(fail(`Failed to fetch branch '${branch}' for PR #${prNumber}`));
7383
+ process.exit(1);
7384
+ }
7385
+ if (!branchExists(branch)) {
7386
+ console.error(fail(`Failed to fetch branch '${branch}' for PR #${prNumber}`));
7387
+ process.exit(1);
7388
+ }
7389
+ openBranch(branch, prNumber);
7390
+ }
7168
7391
 
7169
7392
  // commands/ls.ts
7170
7393
  var import_table = __toESM(require_src(), 1);
@@ -8015,7 +8238,7 @@ function lsCommand(plain = false) {
8015
8238
  }
8016
8239
 
8017
8240
  // commands/rm.ts
8018
- import { execSync as execSync3 } from "child_process";
8241
+ import { execSync as execSync4 } from "child_process";
8019
8242
  import { resolve as resolve2 } from "path";
8020
8243
  function rmCommand(branch, force = false) {
8021
8244
  const mainWorktree = getMainWorktree();
@@ -8046,8 +8269,8 @@ function rmCommand(branch, force = false) {
8046
8269
  }
8047
8270
  if (isDirty && force) {
8048
8271
  try {
8049
- execSync3(`git -C "${worktreePath}" reset --hard HEAD`, { stdio: "ignore" });
8050
- execSync3(`git -C "${worktreePath}" clean -fd`, { stdio: "ignore" });
8272
+ execSync4(`git -C "${worktreePath}" reset --hard HEAD`, { stdio: "ignore" });
8273
+ execSync4(`git -C "${worktreePath}" clean -fd`, { stdio: "ignore" });
8051
8274
  } catch {
8052
8275
  }
8053
8276
  }
@@ -8065,7 +8288,7 @@ function rmCommand(branch, force = false) {
8065
8288
  }
8066
8289
 
8067
8290
  // commands/status.ts
8068
- import { execSync as execSync4 } from "child_process";
8291
+ import { execSync as execSync5 } from "child_process";
8069
8292
  import { existsSync as existsSync4, readdirSync as readdirSync3 } from "fs";
8070
8293
  import { resolve as resolve3 } from "path";
8071
8294
  function getDetailedStatus(path) {
@@ -8073,7 +8296,7 @@ function getDetailedStatus(path) {
8073
8296
  return [];
8074
8297
  }
8075
8298
  try {
8076
- const status = execSync4(`git -C "${path}" status --short`, { encoding: "utf-8" }).trim();
8299
+ const status = execSync5(`git -C "${path}" status --short`, { encoding: "utf-8" }).trim();
8077
8300
  if (!status) {
8078
8301
  return [];
8079
8302
  }
@@ -8139,17 +8362,17 @@ function statusCommand() {
8139
8362
  }
8140
8363
 
8141
8364
  // commands/preclose.ts
8142
- import { execSync as execSync5 } from "child_process";
8365
+ import { execSync as execSync6 } from "child_process";
8143
8366
  function precloseCommand(force) {
8144
8367
  if (!hasUncommittedChanges()) {
8145
8368
  process.exit(0);
8146
8369
  }
8147
8370
  if (force) {
8148
8371
  try {
8149
- execSync5("git reset --hard HEAD", { stdio: "ignore" });
8150
- execSync5("git clean -ffd", { stdio: "ignore" });
8151
- execSync5("git submodule foreach --recursive 'git reset --hard HEAD; git clean -ffd'", { stdio: "ignore" });
8152
- execSync5("git submodule update --init --recursive --force", { stdio: "ignore" });
8372
+ execSync6("git reset --hard HEAD", { stdio: "ignore" });
8373
+ execSync6("git clean -ffd", { stdio: "ignore" });
8374
+ execSync6("git submodule foreach --recursive 'git reset --hard HEAD; git clean -ffd'", { stdio: "ignore" });
8375
+ execSync6("git submodule update --init --recursive --force", { stdio: "ignore" });
8153
8376
  } catch {
8154
8377
  }
8155
8378
  process.exit(0);
@@ -8221,162 +8444,8 @@ function promptChoice() {
8221
8444
  }
8222
8445
  }
8223
8446
 
8224
- // commands/pr.ts
8225
- import { execSync as execSync6 } from "child_process";
8226
- import { mkdirSync as mkdirSync5 } from "fs";
8227
- import { basename as basename5, dirname as dirname5, join as join6 } from "path";
8228
- function checkGhInstalled() {
8229
- try {
8230
- execSync6("gh --version", { stdio: "pipe", encoding: "utf-8" });
8231
- } catch {
8232
- console.error(fail("GitHub CLI (gh) is not installed."));
8233
- console.error(dim(" Install it from: https://cli.github.com/"));
8234
- process.exit(1);
8235
- }
8236
- }
8237
- function checkGhAuthenticated() {
8238
- try {
8239
- execSync6("gh auth status", { stdio: "pipe", encoding: "utf-8" });
8240
- } catch {
8241
- console.error(fail("Not authenticated with GitHub CLI."));
8242
- console.error(dim(" Run: gh auth login"));
8243
- process.exit(1);
8244
- }
8245
- }
8246
- function getPRInfo(prRef) {
8247
- try {
8248
- const output = execSync6(
8249
- `gh pr view ${prRef} --json number,headRefName,headRepository,headRepositoryOwner,isCrossRepository`,
8250
- { encoding: "utf-8", stdio: ["pipe", "pipe", "pipe"] }
8251
- );
8252
- return JSON.parse(output);
8253
- } catch (err) {
8254
- const message = err instanceof Error && "stderr" in err ? err.stderr : String(err);
8255
- if (message.includes("Could not resolve")) {
8256
- console.error(fail(`PR '${prRef}' not found.`));
8257
- console.error(dim(" Make sure you're in a GitHub repository and the PR exists."));
8258
- } else if (message.includes("no pull requests found")) {
8259
- console.error(fail(`No pull request found for '${prRef}'.`));
8260
- } else {
8261
- console.error(fail(`Failed to get PR info: ${message}`));
8262
- }
8263
- process.exit(1);
8264
- }
8265
- }
8266
- function fetchPRBranch(prRef, prInfo) {
8267
- try {
8268
- execSync6(`git fetch origin ${prInfo.headRefName}:${prInfo.headRefName}`, { stdio: "pipe" });
8269
- return;
8270
- } catch {
8271
- }
8272
- const currentBranch = getCurrentBranch();
8273
- try {
8274
- execSync6(`gh pr checkout ${prRef}`, { stdio: "pipe" });
8275
- execSync6(`git checkout "${currentBranch}"`, { stdio: "pipe" });
8276
- return;
8277
- } catch {
8278
- try {
8279
- execSync6(`git checkout "${currentBranch}"`, { stdio: "pipe" });
8280
- } catch {
8281
- }
8282
- }
8283
- try {
8284
- execSync6(`git fetch origin pull/${prInfo.number}/head:${prInfo.headRefName}`, { stdio: "pipe" });
8285
- return;
8286
- } catch (fetchErr) {
8287
- const message = fetchErr instanceof Error ? fetchErr.message : String(fetchErr);
8288
- console.error(fail(`Failed to fetch PR branch: ${message}`));
8289
- process.exit(1);
8290
- }
8291
- }
8292
- function branchExistsLocally(branch) {
8293
- try {
8294
- execSync6(`git show-ref --verify --quiet refs/heads/${branch}`);
8295
- return true;
8296
- } catch {
8297
- return false;
8298
- }
8299
- }
8300
- function createWorktreeForPR(branch, path) {
8301
- execSync6(`git worktree add "${path}" "${branch}"`, { stdio: "ignore" });
8302
- }
8303
- function prOpenCommand(prRef) {
8304
- checkGhInstalled();
8305
- checkGhAuthenticated();
8306
- const prInfo = getPRInfo(prRef);
8307
- const branch = prInfo.headRefName;
8308
- const prNumber = prInfo.number;
8309
- if (isInsideWorktree()) {
8310
- const currentBranch = getCurrentBranch();
8311
- console.log();
8312
- console.log(warn(`You're inside a worktree (branch: ${cyan(currentBranch)})`));
8313
- console.log(` This will nest subshells (subshell inside subshell).`);
8314
- console.log();
8315
- if (!confirm("Continue?")) {
8316
- process.exit(0);
8317
- }
8318
- console.log();
8319
- }
8320
- const parentBranch = getCurrentBranch();
8321
- const existingWorktreePath = getWorktreeForBranch(branch);
8322
- if (existingWorktreePath) {
8323
- const worktreeId2 = getWorktreeId(existingWorktreePath);
8324
- const mainWorktree2 = getMainWorktree();
8325
- console.log();
8326
- console.log(success(bold(`PR #${prNumber}`)), dim(`branch: ${cyan(branch)} (existing worktree)`));
8327
- console.log(dim("Type 'wk close' to return."));
8328
- console.log();
8329
- spawnShell(existingWorktreePath, {
8330
- branch,
8331
- worktreePath: existingWorktreePath,
8332
- repoPath: mainWorktree2
8333
- });
8334
- console.log();
8335
- console.log(success(`Back in ${bold(parentBranch)}`));
8336
- const currentStore2 = loadStore();
8337
- autoCleanupWorktree(worktreeId2, existingWorktreePath, currentStore2, saveStore);
8338
- console.log();
8339
- return;
8340
- }
8341
- if (!branchExistsLocally(branch)) {
8342
- console.log(dim(`Fetching PR #${prNumber}...`));
8343
- fetchPRBranch(prRef, prInfo);
8344
- }
8345
- if (!branchExistsLocally(branch)) {
8346
- console.error(fail(`Failed to fetch branch '${branch}' for PR #${prNumber}`));
8347
- process.exit(1);
8348
- }
8349
- const store = loadStore();
8350
- const mainWorktree = getMainWorktree();
8351
- const repoName = basename5(mainWorktree);
8352
- const worktreeId = `${repoName}@${slugify(branch)}`;
8353
- const worktreePath = join6(getWorktreesDir(), worktreeId);
8354
- mkdirSync5(dirname5(worktreePath), { recursive: true });
8355
- createWorktreeForPR(branch, worktreePath);
8356
- initSubmodules(worktreePath);
8357
- setWorktreeMeta(store, worktreeId, {
8358
- created_at: (/* @__PURE__ */ new Date()).toISOString()
8359
- });
8360
- saveStore(store);
8361
- console.log();
8362
- console.log(success(bold(`PR #${prNumber}`)), dim(`branch: ${cyan(branch)}`));
8363
- console.log(dim(`Opened PR in ephemeral subshell`));
8364
- console.log(dim("Type 'exit' or 'wk close' to return."));
8365
- console.log();
8366
- spawnShell(worktreePath, {
8367
- branch,
8368
- worktreePath,
8369
- repoPath: mainWorktree
8370
- });
8371
- console.log();
8372
- console.log(success(`Back in ${bold(parentBranch)}`));
8373
- const currentStore = loadStore();
8374
- autoCleanupWorktree(worktreeId, worktreePath, currentStore, saveStore);
8375
- console.log();
8376
- }
8377
-
8378
8447
  // index.ts
8379
- var VERSION = "0.1.0";
8448
+ var VERSION = "0.4.0";
8380
8449
  function printHelp() {
8381
8450
  const dim2 = import_picocolors4.default.dim;
8382
8451
  const cyan2 = import_picocolors4.default.cyan;
@@ -8387,9 +8456,8 @@ ${import_picocolors4.default.bold("Usage:")} ${cyan2("wk")} ${dim2("<command>")}
8387
8456
 
8388
8457
  ${import_picocolors4.default.bold("Commands:")}
8389
8458
  ${green2("new")} ${dim2("[branch]")} Create a branch and open it ${dim2("[--from <branch>]")}
8390
- ${green2("open")} ${dim2("<branch>")} Open a branch in an ephemeral subshell
8391
- ${green2("pr open")} ${dim2("<ref>")} Open a GitHub PR in an ephemeral subshell
8392
- ${green2("close")} Exit current subshell
8459
+ ${green2("open")} ${dim2("<ref>")} Open a branch or PR in an ephemeral subshell
8460
+ ${green2("close")} Exit current workshell
8393
8461
  ${green2("ls")} List open branches
8394
8462
  ${green2("status")} Show current branch
8395
8463
  ${green2("rm")} ${dim2("<branch>")} Remove a branch's worktree
@@ -8453,15 +8521,24 @@ ${import_picocolors4.default.bold("Description:")}
8453
8521
  function printOpenHelp() {
8454
8522
  const dim2 = import_picocolors4.default.dim;
8455
8523
  const cyan2 = import_picocolors4.default.cyan;
8456
- console.log(`${import_picocolors4.default.bold("wk open")} - Open a branch in an ephemeral subshell
8524
+ console.log(`${import_picocolors4.default.bold("wk open")} - Open a branch or PR in an ephemeral subshell
8457
8525
 
8458
- ${import_picocolors4.default.bold("Usage:")} ${cyan2("wk open")} ${dim2("<branch>")}
8526
+ ${import_picocolors4.default.bold("Usage:")} ${cyan2("wk open")} ${dim2("<ref>")}
8459
8527
 
8460
8528
  ${import_picocolors4.default.bold("Arguments:")}
8461
- ${dim2("<branch>")} Branch name ${dim2("(required)")}
8529
+ ${dim2("<ref>")} Branch name, PR number, or PR URL ${dim2("(required)")}
8530
+
8531
+ ${import_picocolors4.default.bold("Examples:")}
8532
+ ${dim2("$")} ${cyan2("wk open feature-branch")} ${dim2("# Open local branch")}
8533
+ ${dim2("$")} ${cyan2("wk open 123")} ${dim2("# Open PR #123")}
8534
+ ${dim2("$")} ${cyan2("wk open https://github.com/o/r/pull/123")}
8462
8535
 
8463
8536
  ${import_picocolors4.default.bold("Description:")}
8464
- Opens the specified branch in an ephemeral subshell.
8537
+ Opens the specified ref in an ephemeral subshell.
8538
+
8539
+ First tries to open as a local branch. If not found, tries to fetch
8540
+ as a GitHub PR (requires gh CLI to be installed and authenticated).
8541
+
8465
8542
  If a worktree already exists for the branch, uses that.
8466
8543
  Otherwise, creates a new worktree automatically.
8467
8544
  Type 'wk close' to return.`);
@@ -8511,7 +8588,7 @@ ${import_picocolors4.default.bold("Description:")}
8511
8588
  function printCloseHelp() {
8512
8589
  const dim2 = import_picocolors4.default.dim;
8513
8590
  const cyan2 = import_picocolors4.default.cyan;
8514
- console.log(`${import_picocolors4.default.bold("wk close")} - Exit current subshell
8591
+ console.log(`${import_picocolors4.default.bold("wk close")} - Exit current workshell
8515
8592
 
8516
8593
  ${import_picocolors4.default.bold("Usage:")} ${cyan2("wk close")} ${dim2("[options]")}
8517
8594
 
@@ -8519,7 +8596,7 @@ ${import_picocolors4.default.bold("Options:")}
8519
8596
  ${cyan2("-f")}, ${cyan2("--force")} Discard uncommitted changes and exit
8520
8597
 
8521
8598
  ${import_picocolors4.default.bold("Description:")}
8522
- Exits the current subshell and returns to the parent.
8599
+ Exits the current workshell and returns to the parent.
8523
8600
 
8524
8601
  On exit, if the branch is clean:
8525
8602
  - Worktree is pruned (branch is kept)
@@ -8544,51 +8621,6 @@ ${import_picocolors4.default.bold("Config locations")} ${dim2("(in precedence or
8544
8621
  ${cyan2(".git/workshell.toml")} Local only, not committed
8545
8622
  ${cyan2("workshell.toml")} Project root, can be committed`);
8546
8623
  }
8547
- function printPrHelp() {
8548
- const dim2 = import_picocolors4.default.dim;
8549
- const cyan2 = import_picocolors4.default.cyan;
8550
- console.log(`${import_picocolors4.default.bold("wk pr")} - Work with GitHub Pull Requests
8551
-
8552
- ${import_picocolors4.default.bold("Usage:")} ${cyan2("wk pr")} ${dim2("<subcommand>")} ${dim2("[options]")}
8553
-
8554
- ${import_picocolors4.default.bold("Subcommands:")}
8555
- ${cyan2("open")} ${dim2("<ref>")} Open a PR in an ephemeral subshell
8556
-
8557
- ${import_picocolors4.default.bold("Examples:")}
8558
- ${dim2("$")} ${cyan2("wk pr open 123")} ${dim2("# Open PR by number")}
8559
- ${dim2("$")} ${cyan2("wk pr open https://github.com/o/r/pull/123")} ${dim2("# Open PR by URL")}
8560
-
8561
- ${import_picocolors4.default.bold("Requirements:")}
8562
- Requires GitHub CLI (gh) to be installed and authenticated.
8563
- ${dim2("Install: https://cli.github.com/")}
8564
- ${dim2("Auth: gh auth login")}`);
8565
- }
8566
- function printPrOpenHelp() {
8567
- const dim2 = import_picocolors4.default.dim;
8568
- const cyan2 = import_picocolors4.default.cyan;
8569
- console.log(`${import_picocolors4.default.bold("wk pr open")} - Open a GitHub PR in an ephemeral subshell
8570
-
8571
- ${import_picocolors4.default.bold("Usage:")} ${cyan2("wk pr open")} ${dim2("<ref>")}
8572
-
8573
- ${import_picocolors4.default.bold("Arguments:")}
8574
- ${dim2("<ref>")} PR number, URL, or branch name ${dim2("(required)")}
8575
-
8576
- ${import_picocolors4.default.bold("Examples:")}
8577
- ${dim2("$")} ${cyan2("wk pr open 123")}
8578
- ${dim2("$")} ${cyan2("wk pr open https://github.com/owner/repo/pull/123")}
8579
- ${dim2("$")} ${cyan2("wk pr open feature-branch")}
8580
-
8581
- ${import_picocolors4.default.bold("Description:")}
8582
- Fetches the PR branch and opens it in an ephemeral subshell.
8583
- If a worktree already exists for the branch, uses that.
8584
- Otherwise, creates a new worktree automatically.
8585
- Type 'wk close' to return.
8586
-
8587
- ${import_picocolors4.default.bold("Requirements:")}
8588
- Requires GitHub CLI (gh) to be installed and authenticated.
8589
- ${dim2("Install: https://cli.github.com/")}
8590
- ${dim2("Auth: gh auth login")}`);
8591
- }
8592
8624
  function isHelp(arg) {
8593
8625
  return arg === "--help" || arg === "-h";
8594
8626
  }
@@ -8630,7 +8662,7 @@ switch (cmd) {
8630
8662
  process.exit(0);
8631
8663
  }
8632
8664
  if (!args[1]) {
8633
- console.error("Usage: wk open <branch>");
8665
+ console.error("Usage: wk open <branch|pr-number|pr-url>");
8634
8666
  process.exit(1);
8635
8667
  }
8636
8668
  openCommand(args[1]);
@@ -8667,35 +8699,23 @@ switch (cmd) {
8667
8699
  }
8668
8700
  configCommand();
8669
8701
  break;
8670
- case "pr": {
8671
- const subCmd = args[1];
8672
- if (!subCmd || isHelp(subCmd)) {
8673
- printPrHelp();
8674
- process.exit(0);
8675
- }
8676
- if (subCmd === "open") {
8677
- if (isHelp(args[2])) {
8678
- printPrOpenHelp();
8679
- process.exit(0);
8680
- }
8681
- if (!args[2]) {
8682
- console.error("Usage: wk pr open <pr-number|url|branch>");
8683
- process.exit(1);
8684
- }
8685
- prOpenCommand(args[2]);
8686
- } else {
8687
- console.error(`Unknown pr subcommand: ${subCmd}`);
8688
- console.error("Run 'wk pr --help' for usage.");
8689
- process.exit(1);
8690
- }
8691
- break;
8692
- }
8693
8702
  case "close":
8694
8703
  if (isHelp(args[1])) {
8695
8704
  printCloseHelp();
8696
8705
  process.exit(0);
8697
8706
  }
8698
- 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."));
8699
8719
  process.exit(1);
8700
8720
  break;
8701
8721
  case "__preclose":
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "workshell",
3
- "version": "0.1.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",