deepspace 0.9.2 → 0.9.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.
package/CHANGELOG.md CHANGED
@@ -1,5 +1,11 @@
1
1
  # deepspace
2
2
 
3
+ ## 0.9.3
4
+
5
+ ### Patch Changes
6
+
7
+ - Prevent workspace cleanup from deleting unpublished or concurrently advanced commits.
8
+
3
9
  ## 0.9.2
4
10
 
5
11
  ### Patch Changes
package/dist/cli.js CHANGED
@@ -10305,6 +10305,36 @@ function inOwnLinkedWorktree(appDir, id) {
10305
10305
  return false;
10306
10306
  }
10307
10307
  }
10308
+ function inspectWorkspaceCleanup(appDir, id) {
10309
+ const branch = workspaceBranchName(id);
10310
+ const branchOid = resolveCommit(appDir, `refs/heads/${branch}`);
10311
+ const worktrees = listWorktrees(appDir);
10312
+ const checkoutIndex = worktrees.findIndex((worktree) => worktree.branch === branch);
10313
+ if (checkoutIndex < 0) {
10314
+ return {
10315
+ branch,
10316
+ branchOid,
10317
+ checkout: { kind: "none", dir: null },
10318
+ willDeleteBranch: branchOid !== null
10319
+ };
10320
+ }
10321
+ const dir = worktrees[checkoutIndex].path;
10322
+ if (checkoutIndex === 0) {
10323
+ return {
10324
+ branch,
10325
+ branchOid,
10326
+ checkout: { kind: "primary", dir },
10327
+ willDeleteBranch: branchOid !== null
10328
+ };
10329
+ }
10330
+ const managed = isManagedWorkspaceWorktree(dir, id);
10331
+ return {
10332
+ branch,
10333
+ branchOid,
10334
+ checkout: { kind: managed ? "managed-linked" : "external-linked", dir },
10335
+ willDeleteBranch: branchOid !== null && managed
10336
+ };
10337
+ }
10308
10338
  function cleanupJson(outcome) {
10309
10339
  return {
10310
10340
  worktreeRemoved: outcome.worktreeRemoved,
@@ -10314,11 +10344,27 @@ function cleanupJson(outcome) {
10314
10344
  ...outcome.error ? { error: outcome.error } : {}
10315
10345
  };
10316
10346
  }
10317
- function deleteWsBranch(cwd, branch, out) {
10318
- const del3 = runGit(cwd, ["branch", "-D", branch], { allowFail: true });
10319
- if (del3.status === 0) out.branchDeleted = branch;
10320
- else
10321
- out.error ??= `git branch -D ${branch} failed: ${del3.stderr.toString("utf-8").trim() || "unknown git error"}`;
10347
+ function deleteWsBranch(cwd, branch, expectedOid, out) {
10348
+ const ref = `refs/heads/${branch}`;
10349
+ const currentOid = resolveCommit(cwd, ref);
10350
+ if (!currentOid) return;
10351
+ const approvedOid = expectedOid ?? currentOid;
10352
+ if (currentOid !== approvedOid) {
10353
+ out.error ??= `branch ${branch} advanced from ${approvedOid.slice(0, 10)} to ${currentOid.slice(0, 10)} during cleanup; retained the branch`;
10354
+ return;
10355
+ }
10356
+ const del3 = runGit(cwd, ["update-ref", "-d", ref, approvedOid], { allowFail: true });
10357
+ if (del3.status === 0) {
10358
+ out.branchDeleted = branch;
10359
+ runGit(cwd, ["config", "--remove-section", `branch.${branch}`], { allowFail: true });
10360
+ return;
10361
+ }
10362
+ const afterOid = resolveCommit(cwd, ref);
10363
+ if (afterOid && afterOid !== approvedOid) {
10364
+ out.error ??= `branch ${branch} advanced from ${approvedOid.slice(0, 10)} to ${afterOid.slice(0, 10)} during cleanup; retained the branch`;
10365
+ } else if (afterOid) {
10366
+ out.error ??= `could not delete branch ${branch}: ${del3.stderr.toString("utf-8").trim() || "unknown git error"}`;
10367
+ }
10322
10368
  }
10323
10369
  function switchOffWsBranch(mainDir, trunkBranch) {
10324
10370
  if (trunkBranch) {
@@ -10332,8 +10378,10 @@ function switchOffWsBranch(mainDir, trunkBranch) {
10332
10378
  }
10333
10379
  return runGit(mainDir, ["switch", "--quiet", "--detach"], { allowFail: true }).status === 0;
10334
10380
  }
10335
- function cleanupWorkspaceLocal(appDir, id, trunkBranch) {
10336
- const branch = workspaceBranchName(id);
10381
+ function cleanupWorkspaceLocal(appDir, id, trunkBranch, options = {}) {
10382
+ const inspection = inspectWorkspaceCleanup(appDir, id);
10383
+ const { branch } = inspection;
10384
+ const expectedBranchOid = options.expectedBranchOid ?? inspection.branchOid;
10337
10385
  const out = {
10338
10386
  worktreeRemoved: null,
10339
10387
  worktreeRetained: null,
@@ -10341,6 +10389,10 @@ function cleanupWorkspaceLocal(appDir, id, trunkBranch) {
10341
10389
  worktreeDir: null,
10342
10390
  branch
10343
10391
  };
10392
+ if (options.retainLocal) {
10393
+ out.worktreeRetained = inspection.checkout.dir;
10394
+ return out;
10395
+ }
10344
10396
  try {
10345
10397
  const worktrees = listWorktrees(appDir);
10346
10398
  const mainDir = worktrees[0]?.path ?? repoToplevel(appDir);
@@ -10367,14 +10419,15 @@ function cleanupWorkspaceLocal(appDir, id, trunkBranch) {
10367
10419
  return out;
10368
10420
  }
10369
10421
  out.worktreeRemoved = linked.path;
10370
- deleteWsBranch(mainDir, branch, out);
10422
+ out.worktreeDir = null;
10423
+ deleteWsBranch(mainDir, branch, expectedBranchOid, out);
10371
10424
  return out;
10372
10425
  }
10373
10426
  if (currentBranch(mainDir) === branch && !switchOffWsBranch(mainDir, trunkBranch)) {
10374
10427
  out.error = `could not switch off ${branch} to delete it`;
10375
10428
  return out;
10376
10429
  }
10377
- if (resolveCommit(mainDir, `refs/heads/${branch}`)) deleteWsBranch(mainDir, branch, out);
10430
+ deleteWsBranch(mainDir, branch, expectedBranchOid, out);
10378
10431
  return out;
10379
10432
  } catch (err) {
10380
10433
  out.error = err instanceof Error ? err.message : String(err);
@@ -11344,7 +11397,7 @@ var landWorkspaceCommand = defineDeepspaceCommand({
11344
11397
  }
11345
11398
  await api4.landWorkspace(id, { landedOid, intoRef });
11346
11399
  spinner2?.stop(`Landed ${id} into ${intoBranch} at ${landedOid.slice(0, 10)}.`);
11347
- const cleanup = keepWorktree ? null : cleanupWorkspaceLocal(appDir, id, intoBranch);
11400
+ const cleanup = keepWorktree ? null : cleanupWorkspaceLocal(appDir, id, intoBranch, { expectedBranchOid: landedOid });
11348
11401
  const inOwnWorktree = !cleanup && inOwnLinkedWorktree(appDir, id);
11349
11402
  const retainedWorktree = cleanup?.worktreeRetained ?? (keepWorktree || inOwnWorktree ? appDir : null);
11350
11403
  const data = {
@@ -11381,6 +11434,24 @@ var landWorkspaceCommand = defineDeepspaceCommand({
11381
11434
 
11382
11435
  // src/cli/commands/workspace/drop.ts
11383
11436
  import * as p32 from "@clack/prompts";
11437
+ function isWorkspaceTipPublished(appDir, localTip, publishedOids) {
11438
+ return publishedOids.some(
11439
+ (publishedOid) => publishedOid === localTip || isAncestor(appDir, localTip, publishedOid)
11440
+ );
11441
+ }
11442
+ function workspaceUnsyncedRefusal(args) {
11443
+ const syncArgv = ["deepspace", "workspace", "sync", "--app", args.appId, "--workspace", args.id];
11444
+ const dropArgv = ["deepspace", "workspace", "drop", args.id, "--app", args.appId];
11445
+ const publish2 = args.workspaceDir ? `Publish them first with \`${humanCommand(syncArgv)}\`, then re-run \`${humanCommand(dropArgv)}\`.` : `Recreate a worktree from the branch (\`git worktree add <dir> ${args.branch}\`), publish from there with \`${humanCommand(syncArgv)}\`, then re-run \`${humanCommand(dropArgv)}\`.`;
11446
+ return new Refusal(
11447
+ `${args.id} has committed work that isn't published yet. ${publish2} Alternatively, pass \`--keep-worktree\` to drop the cloud workspace while retaining the local branch/worktree for manual disposal. Nothing was dropped.`,
11448
+ "workspace_unsynced",
11449
+ {
11450
+ ...args.workspaceDir ? { action: { cwd: args.workspaceDir, argv: syncArgv } } : {},
11451
+ extra: { workspaceId: args.id }
11452
+ }
11453
+ );
11454
+ }
11384
11455
  var dropWorkspaceCommand = defineDeepspaceCommand({
11385
11456
  meta: { name: "drop", description: "Abandon a workspace (ref retained briefly, then GC)" },
11386
11457
  args: {
@@ -11414,24 +11485,44 @@ var dropWorkspaceCommand = defineDeepspaceCommand({
11414
11485
  }
11415
11486
  return { data: { workspaceId: explicitId, status: view2.workspace.status } };
11416
11487
  }
11417
- const { appDir, api: api4 } = await resolveTarget2(appArg);
11488
+ const { appDir, appId, api: api4 } = await resolveTarget2(appArg);
11418
11489
  const id = inferWorkspaceId(appDir, explicitId);
11419
- const ownLinkedWorktree = inOwnLinkedWorktree(appDir, id);
11420
- const cleanupOwnsCheckout = !ownLinkedWorktree || isManagedWorkspaceWorktree(appDir, id);
11421
- if (!keepWorktree && cleanupOwnsCheckout && !isWorkTreeClean(appDir)) {
11422
- const dropArgv = ["deepspace", "workspace", "drop", ...explicitId ? [explicitId] : []];
11490
+ const inspection = inspectWorkspaceCleanup(appDir, id);
11491
+ let approvedBranchOid;
11492
+ if (!keepWorktree && inspection.willDeleteBranch && inspection.branchOid) {
11493
+ const { view: before } = await api4.getWorkspace(id);
11494
+ const publishedOids = [
11495
+ before.tipOid,
11496
+ before.workspace.landedOid,
11497
+ before.workspace.baseOid
11498
+ ].filter((oid) => oid !== null);
11499
+ if (!isWorkspaceTipPublished(appDir, inspection.branchOid, publishedOids)) {
11500
+ throw workspaceUnsyncedRefusal({
11501
+ appId,
11502
+ id,
11503
+ branch: inspection.branch,
11504
+ workspaceDir: inspection.checkout.dir
11505
+ });
11506
+ }
11507
+ approvedBranchOid = inspection.branchOid;
11508
+ }
11509
+ const cleanupDir = inspection.willDeleteBranch ? inspection.checkout.dir : null;
11510
+ if (!keepWorktree && cleanupDir && !isWorkTreeClean(cleanupDir)) {
11511
+ const dropArgv = ["deepspace", "workspace", "drop", id, "--app", appId];
11423
11512
  const dropNext = humanCommand(dropArgv);
11424
11513
  throw new Refusal(
11425
- `The worktree has uncommitted changes and cleanup would remove it. Commit them to the workspace branch and re-run \`${dropNext}\`, or pass \`--keep-worktree\` to drop while keeping the worktree. Nothing was dropped.`,
11514
+ `The workspace checkout has uncommitted changes and cleanup would remove it or switch it off ${inspection.branch}. Commit them and re-run \`${dropNext}\`, or pass \`--keep-worktree\` to drop while retaining the checkout. Nothing was dropped.`,
11426
11515
  "dirty_worktree",
11427
11516
  { extra: { workspaceId: id } }
11428
11517
  );
11429
11518
  }
11430
11519
  const { view } = await api4.dropWorkspace(id);
11431
11520
  spinner2?.message(`Cleaning up ${id} locally\u2026`);
11432
- const cleanup = keepWorktree ? null : cleanupWorkspaceLocal(appDir, id, null);
11433
- const inOwnWorktree = !cleanup && ownLinkedWorktree;
11434
- const retainedWorktree = cleanup?.worktreeRetained ?? (keepWorktree || inOwnWorktree ? appDir : null);
11521
+ const cleanup = keepWorktree ? null : cleanupWorkspaceLocal(appDir, id, null, {
11522
+ expectedBranchOid: approvedBranchOid,
11523
+ retainLocal: !inspection.willDeleteBranch
11524
+ });
11525
+ const retainedWorktree = cleanup?.worktreeRetained ?? (keepWorktree ? inspection.checkout.dir : null);
11435
11526
  const data = {
11436
11527
  workspaceId: id,
11437
11528
  status: view.workspace.status,