@paleo/workspace 0.17.0 → 0.19.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/cli.d.ts CHANGED
@@ -2,6 +2,7 @@ export type WorkspaceCommand = {
2
2
  kind: "setup";
3
3
  branch?: string;
4
4
  newBranch: boolean;
5
+ from?: string;
5
6
  owner?: string;
6
7
  slot?: string;
7
8
  force: boolean;
@@ -9,7 +10,7 @@ export type WorkspaceCommand = {
9
10
  } | {
10
11
  kind: "remove";
11
12
  branch?: string;
12
- noRemoteCheck: boolean;
13
+ force: boolean;
13
14
  } | {
14
15
  kind: "list";
15
16
  } | {
package/dist/cli.js CHANGED
@@ -43,6 +43,7 @@ function parseSetup(tokens) {
43
43
  args: tokens,
44
44
  options: {
45
45
  "new-branch": { type: "boolean", short: "c" },
46
+ from: { type: "string" },
46
47
  owner: { type: "string" },
47
48
  slot: { type: "string", short: "s" },
48
49
  force: { type: "boolean" },
@@ -57,11 +58,15 @@ function parseSetup(tokens) {
57
58
  if (newBranch && branch === undefined) {
58
59
  throw new ConfigError("`workspace setup <branch> -c` requires a branch name.");
59
60
  }
61
+ if (values.from !== undefined && !newBranch) {
62
+ throw new ConfigError("`--from` requires `-c`/`--new-branch`.");
63
+ }
60
64
  return {
61
65
  command: {
62
66
  kind: "setup",
63
67
  branch,
64
68
  newBranch,
69
+ from: values.from,
65
70
  owner: values.owner,
66
71
  slot: values.slot,
67
72
  force: values.force ?? false,
@@ -74,7 +79,7 @@ function parseRemove(tokens) {
74
79
  const { values, positionals } = parseArgs({
75
80
  args: tokens,
76
81
  options: {
77
- "no-remote-check": { type: "boolean" },
82
+ force: { type: "boolean" },
78
83
  verbose: { type: "boolean", short: "v" },
79
84
  },
80
85
  allowPositionals: true,
@@ -82,7 +87,7 @@ function parseRemove(tokens) {
82
87
  });
83
88
  const branch = takeOptionalPositional(positionals, "remove");
84
89
  return {
85
- command: { kind: "remove", branch, noRemoteCheck: values["no-remote-check"] ?? false },
90
+ command: { kind: "remove", branch, force: values.force ?? false },
86
91
  verbose: values.verbose ?? false,
87
92
  };
88
93
  }
@@ -176,13 +181,15 @@ export function printWorkspaceHelp() {
176
181
  "Manage workspaces: a git worktree plus its own dev setup (ports, config, database, dev server).",
177
182
  "",
178
183
  "Commands:",
179
- " setup [<branch>] [-c|--new-branch] [--owner <name>] [-s|--slot <port>] [--force] [--wait]",
184
+ " setup [<branch>] [-c|--new-branch] [--from <ref>] [--owner <name>] [-s|--slot <port>] [--force] [--wait]",
180
185
  " Set up the workspace. With <branch>, create a sibling worktree for it",
181
186
  " (add -c to create the branch first). Without, set up the current worktree",
182
187
  " (idempotent; bootstrap and retry path).",
188
+ " With -c, the new branch starts at the current worktree's HEAD, or at <ref> with --from.",
183
189
  " Finalize runs in the background; add --wait to block until it reaches READY.",
184
- " remove [<branch>] [--no-remote-check]",
190
+ " remove [<branch>] [--force]",
185
191
  " Remove a workspace by branch, or the current one when omitted.",
192
+ " Refuses on uncommitted changes unless --force.",
186
193
  " list",
187
194
  " List all registered workspaces (slot, status, branch, path, owner, created).",
188
195
  " status [-s|--slot <port>]",
package/dist/slots.d.ts CHANGED
@@ -1,5 +1,5 @@
1
1
  import { type PortScheme } from "./ports.js";
2
- export declare const REGISTRY_SUBDIR = "shared-registry";
2
+ export declare const REGISTRY_SUBDIR = "workspace-registry";
3
3
  export declare function registryDirFor(runtimeDir: string): string;
4
4
  /** `registryDir` is gone from the config types but may linger in a consumer's config file. */
5
5
  export declare function warnLegacyRegistryDir(config: {
package/dist/slots.js CHANGED
@@ -3,7 +3,7 @@ import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
3
3
  import { dirname, join, resolve } from "node:path";
4
4
  import { allPorts, isValidPort } from "./ports.js";
5
5
  import { getWorktreeBranch } from "./worktree.js";
6
- export const REGISTRY_SUBDIR = "shared-registry";
6
+ export const REGISTRY_SUBDIR = "workspace-registry";
7
7
  const SLOTS_FILENAME = "slots.json";
8
8
  export function registryDirFor(runtimeDir) {
9
9
  return join(runtimeDir, REGISTRY_SUBDIR);
package/dist/workspace.js CHANGED
@@ -8,7 +8,7 @@ import { copyAndPatchFile, formatDuration, setupLogPath, } from "./helpers.js";
8
8
  import { isProcessAlive } from "./process-control.js";
9
9
  import { defaultComputePorts, isValidPort, resolvePortScheme } from "./ports.js";
10
10
  import { handleSetOwner, markSlotFailed, markSlotReady, mergeSlots, readSlots, REGISTRY_SUBDIR, registryDirFor, resolveAndRegisterSlot, resolveCurrentSlot, validateSlotAvailability, warnLegacyRegistryDir, writeSlots, } from "./slots.js";
11
- import { createBranch, detectWorktree, enforceWorktreeMode, getWorktreeBranch, removeWorktree, useExistingBranch, verifyBranchAbsentFromRemote, } from "./worktree.js";
11
+ import { createBranch, detectWorktree, getWorktreeBranch, isWorktreeDirty, removeWorktree, useExistingBranch, } from "./worktree.js";
12
12
  export async function runWorkspace(config) {
13
13
  let command;
14
14
  let verbose;
@@ -53,7 +53,6 @@ export async function runWorkspace(config) {
53
53
  return;
54
54
  }
55
55
  const ctx = detectWorktree();
56
- enforceWorktreeMode(command, ctx);
57
56
  const run = { verbose };
58
57
  switch (command.kind) {
59
58
  case "remove":
@@ -123,7 +122,7 @@ async function runSetup(command, ctx, run, config, registryDir) {
123
122
  });
124
123
  }
125
124
  linkSharedDirectories(setupCtx, config.sharedDirs, verboseLog);
126
- linkSharedRegistry(setupCtx, config.runtimeDir, verboseLog);
125
+ linkWorkspaceRegistry(setupCtx, config.runtimeDir, verboseLog);
127
126
  generateConfigFiles(setupCtx, config.configFiles, slot, ports, command.force, verboseLog);
128
127
  teeLog(config.printSummary({
129
128
  slot,
@@ -390,9 +389,6 @@ async function handleRemove(command, ctx, run, config, registryDir) {
390
389
  `Run 'workspace wait --slot ${target.slotPort}' to wait for it to finish (or fail), then retry the removal.`);
391
390
  process.exit(1);
392
391
  }
393
- if (!command.noRemoteCheck) {
394
- verifyBranchAbsentFromRemote(target.branch, run);
395
- }
396
392
  const ownerSuffix = target.owner ? `, owner ${target.owner}` : "";
397
393
  if (!existsSync(target.worktreePath)) {
398
394
  console.warn(`Warning: Worktree directory ${target.worktreePath} not found. Cleaning up registry only.`);
@@ -401,6 +397,10 @@ async function handleRemove(command, ctx, run, config, registryDir) {
401
397
  console.log(`Removed registry entry for branch "${target.branch}" (slot ${target.slotPort}${ownerSuffix}).`);
402
398
  return;
403
399
  }
400
+ if (!command.force && isWorktreeDirty(target.worktreePath)) {
401
+ console.error(`Error: Uncommitted changes in ${target.worktreePath}. Commit or stash them, or pass --force.`);
402
+ process.exit(1);
403
+ }
404
404
  const targetEntry = findOwnEntry(ctx.mainWorktree, registryDir, target.worktreePath);
405
405
  if (targetEntry) {
406
406
  stopTargetDevServer(config.devServerScript, target.worktreePath, verboseLog);
@@ -459,7 +459,7 @@ function handleSetOwnerMode(command, ctx, registryDir) {
459
459
  }
460
460
  console.log(`Owner for slot ${slotPort}: ${newOwner ?? "(none)"}`);
461
461
  }
462
- /** Transitional (0.16 only): merge a pre-0.16 registry into `${runtimeDir}/shared-registry`. */
462
+ /** Transitional (0.16 only): merge a pre-0.16 registry into `${runtimeDir}/workspace-registry`. */
463
463
  function handleMigrate(command, config, newRel) {
464
464
  const ctx = detectWorktree();
465
465
  const oldRel = command.oldRegistryDir;
@@ -504,7 +504,7 @@ function relinkWorktrees(slots, mainWorktree, runtimeDir) {
504
504
  console.warn(`Warning: worktree ${entry.worktree} not found; skipping symlink.`);
505
505
  continue;
506
506
  }
507
- linkSharedRegistry({ currentWorktree: entry.worktree, mainWorktree, isMainWorktree: false }, runtimeDir, console.log, { force: true });
507
+ linkWorkspaceRegistry({ currentWorktree: entry.worktree, mainWorktree, isMainWorktree: false }, runtimeDir, console.log, { force: true });
508
508
  ++count;
509
509
  }
510
510
  return count;
@@ -513,7 +513,7 @@ function ensureWorktree(command, ctx, run, dirNameFn) {
513
513
  if (command.branch === undefined)
514
514
  return ctx;
515
515
  if (command.newBranch)
516
- return createBranch(command.branch, ctx, run, dirNameFn);
516
+ return createBranch(command.branch, ctx, run, dirNameFn, command.from);
517
517
  return useExistingBranch(command.branch, ctx, run, dirNameFn);
518
518
  }
519
519
  function linkSharedDirectories(ctx, dirs, log) {
@@ -534,11 +534,11 @@ function linkSharedDirectories(ctx, dirs, log) {
534
534
  }
535
535
  }
536
536
  /**
537
- * Symlinks the linked worktree's `${runtimeDir}/shared-registry` to the main worktree's, so the
537
+ * Symlinks the linked worktree's `${runtimeDir}/workspace-registry` to the main worktree's, so the
538
538
  * cwd-relative registry read in `resolveCurrentSlot` reaches main. `runtimeDir` is per-worktree and
539
539
  * not in `sharedDirs`, so this is distinct from {@link linkSharedDirectories}.
540
540
  */
541
- function linkSharedRegistry(ctx, runtimeDir, log, opts) {
541
+ function linkWorkspaceRegistry(ctx, runtimeDir, log, opts) {
542
542
  if (ctx.isMainWorktree)
543
543
  return;
544
544
  const mainDir = join(ctx.mainWorktree, runtimeDir, REGISTRY_SUBDIR);
@@ -551,17 +551,17 @@ function linkSharedRegistry(ctx, runtimeDir, log, opts) {
551
551
  const linkStat = lstatSync(link, { throwIfNoEntry: false });
552
552
  if (linkStat) {
553
553
  if (!linkStat.isSymbolicLink()) {
554
- log("Skipped shared-registry symlink (a real directory exists here).");
554
+ log("Skipped workspace-registry symlink (a real directory exists here).");
555
555
  return;
556
556
  }
557
557
  if (!opts?.force && existsSync(link)) {
558
- log("Skipped shared-registry symlink (already exists).");
558
+ log("Skipped workspace-registry symlink (already exists).");
559
559
  return;
560
560
  }
561
561
  rmSync(link);
562
562
  }
563
563
  symlinkSync(relative(runtimeRoot, mainDir), link);
564
- log("Created shared-registry symlink → main worktree.");
564
+ log("Created workspace-registry symlink → main worktree.");
565
565
  }
566
566
  function generateConfigFiles(ctx, entries, slot, ports, force, log) {
567
567
  for (const entry of entries) {
@@ -1,4 +1,3 @@
1
- import type { WorkspaceCommand } from "./cli.js";
2
1
  export interface WorktreeContext {
3
2
  currentWorktree: string;
4
3
  mainWorktree: string;
@@ -8,10 +7,9 @@ export interface RunCtx {
8
7
  verbose: boolean;
9
8
  }
10
9
  export declare function detectWorktree(): WorktreeContext;
11
- export declare function enforceWorktreeMode(command: WorkspaceCommand, ctx: WorktreeContext): void;
12
10
  export declare function useExistingBranch(branch: string, ctx: WorktreeContext, run: RunCtx, dirNameFn?: WorktreeDirNameFn): WorktreeContext;
13
- export declare function createBranch(requestedBranch: string, ctx: WorktreeContext, run: RunCtx, dirNameFn?: WorktreeDirNameFn): WorktreeContext;
14
- export declare function verifyBranchAbsentFromRemote(branch: string, run: RunCtx): void;
11
+ export declare function createBranch(requestedBranch: string, ctx: WorktreeContext, run: RunCtx, dirNameFn?: WorktreeDirNameFn, from?: string): WorktreeContext;
12
+ export declare function isWorktreeDirty(worktreePath: string): boolean;
15
13
  export declare function getWorktreeBranch(worktreePath: string): string | undefined;
16
14
  export declare function removeWorktree(worktreePath: string, run: RunCtx): void;
17
15
  /** Pure function that produces the basename of a worktree directory from a branch. */
package/dist/worktree.js CHANGED
@@ -10,14 +10,6 @@ export function detectWorktree() {
10
10
  const isMainWorktree = resolve(currentWorktree) === resolve(mainWorktree);
11
11
  return { currentWorktree, mainWorktree, isMainWorktree };
12
12
  }
13
- export function enforceWorktreeMode(command, ctx) {
14
- // Adding a worktree for a branch must happen from the main worktree. A branch-less
15
- // `workspace setup` runs anywhere: linked worktree (retry path) or main (initial bootstrap).
16
- if (command.kind === "setup" && command.branch !== undefined && !ctx.isMainWorktree) {
17
- console.error("Error: Adding a workspace for a branch must be run from the main worktree.");
18
- process.exit(1);
19
- }
20
- }
21
13
  export function useExistingBranch(branch, ctx, run, dirNameFn = defaultWorktreeDirName) {
22
14
  if (!branchExists(branch)) {
23
15
  console.error(`Error: Branch "${branch}" does not exist locally or on the remote.`);
@@ -27,7 +19,9 @@ export function useExistingBranch(branch, ctx, run, dirNameFn = defaultWorktreeD
27
19
  execFileSync("git", ["worktree", "add", worktreePath, branch], { stdio: stdioFor(run) });
28
20
  return { ...ctx, currentWorktree: worktreePath, isMainWorktree: false };
29
21
  }
30
- export function createBranch(requestedBranch, ctx, run, dirNameFn = defaultWorktreeDirName) {
22
+ export function createBranch(requestedBranch, ctx, run, dirNameFn = defaultWorktreeDirName, from) {
23
+ if (from !== undefined)
24
+ verifyFromRef(from);
31
25
  let finalBranch = requestedBranch;
32
26
  if (branchExists(finalBranch)) {
33
27
  let suffix = 2;
@@ -38,18 +32,36 @@ export function createBranch(requestedBranch, ctx, run, dirNameFn = defaultWorkt
38
32
  console.warn(`Warning: Branch "${requestedBranch}" already exists; using "${finalBranch}" instead.`);
39
33
  }
40
34
  const worktreePath = dedupeWorktreePath(computeWorktreePath(ctx.mainWorktree, finalBranch, dirNameFn));
41
- execFileSync("git", ["worktree", "add", "-b", finalBranch, worktreePath], {
42
- stdio: stdioFor(run),
43
- });
35
+ const addArgs = ["worktree", "add", "-b", finalBranch, "--end-of-options", worktreePath];
36
+ if (from !== undefined)
37
+ addArgs.push(from);
38
+ execFileSync("git", addArgs, { stdio: stdioFor(run) });
44
39
  return { ...ctx, currentWorktree: worktreePath, isMainWorktree: false };
45
40
  }
46
- export function verifyBranchAbsentFromRemote(branch, run) {
47
- execFileSync("git", ["fetch"], { stdio: stdioFor(run) });
48
- const remoteBranches = execFileSync("git", ["branch", "-r", "--list", `origin/${branch}`], {
49
- encoding: "utf-8",
50
- }).trim();
51
- if (remoteBranches.length > 0) {
52
- console.error(`Error: Branch "${branch}" still exists on the remote. Use --no-remote-check to skip this verification.`);
41
+ function verifyFromRef(from) {
42
+ try {
43
+ // `^{commit}` accepts any commit-ish: branch, origin/x, tag, SHA.
44
+ // `--end-of-options` guards against option-like refs (rev-parse treats args after `--` as paths).
45
+ execFileSync("git", ["rev-parse", "--verify", "--end-of-options", `${from}^{commit}`], {
46
+ stdio: "pipe",
47
+ });
48
+ }
49
+ catch {
50
+ console.error(`Error: --from ref "${from}" does not resolve to a commit.`);
51
+ process.exit(1);
52
+ }
53
+ }
54
+ export function isWorktreeDirty(worktreePath) {
55
+ try {
56
+ const out = execFileSync("git", ["status", "--porcelain"], {
57
+ stdio: "pipe",
58
+ cwd: worktreePath,
59
+ encoding: "utf-8",
60
+ });
61
+ return out.trim().length > 0;
62
+ }
63
+ catch {
64
+ console.error(`Error: Cannot check for uncommitted changes in ${worktreePath}. Pass --force to remove anyway.`);
53
65
  process.exit(1);
54
66
  }
55
67
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@paleo/workspace",
3
- "version": "0.17.0",
3
+ "version": "0.19.0",
4
4
  "description": "Run multiple git-worktree dev environments side by side.",
5
5
  "keywords": [
6
6
  "workspace",