@paleo/workspace 0.17.0 → 0.18.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/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":
@@ -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);
@@ -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) {
@@ -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.18.0",
4
4
  "description": "Run multiple git-worktree dev environments side by side.",
5
5
  "keywords": [
6
6
  "workspace",