@devrouter/cli 0.0.35 → 0.0.37

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/devrouter.js CHANGED
@@ -247,7 +247,8 @@ Run several worktrees of one repo in parallel without host/route collisions. A *
247
247
  - **Lifecycle**: after one-time \`setup\`, use \`ensure .\` for both primary and linked checkouts; never branch manually on checkout kind or use live verify as startup. \`stop .\` is non-destructive; \`stop . --delete\` is explicit exact-owner cleanup without worktree removal; and \`exec . -- <command...>\` runs one-shot commands only in the exact running DevPod. Never substitute raw \`devpod up\`, \`stop\`, or \`delete\`: they bypass devrouter's machine-global ownership lock. \`workspace up\` creates linked worktrees; destructive worktree removal and GC remain ledger-scoped. Dirty or locked full down fails before side effects.
248
248
  - **Managed process identity**: \`ensure\` executes an exact captured adapter snapshot. Default reuse includes command argv, workspace, and adapter SHA-256. Set \`DEVROUTER_PROCESS_FINGERPRINT_ENV\` only to comma-separated non-secret environment names whose values affect runtime identity; secret-like names are rejected and raw values are never persisted.
249
249
  - **Route state**: the versioned Traefik dynamic file is authoritative for both metadata and rendering. JSON is a compatibility mirror; valid headerless generations migrate automatically, while corrupt canonical metadata fails closed.
250
- - **Cleanup**: owner status is \`present\`, \`missing\`, \`locked\`, or \`conflict\`. \`workspace gc\` is a dry-run report; \`--yes\` deletes only exact ledger-owned missing/prunable resources and their records. GC never removes Git worktrees, branches, or prune state. Git has no worktree-removal hook.
250
+ - **Cleanup**: \`workspace cleanup --repo . --inactive-for 30d --json\` is a report-only, no-\`--yes\` report for managed linked workspaces. It joins ownership (\`present|missing|locked|conflict\`), DevPod registration, runtime (\`running|stopped|busy|not-found|absent|unknown\`), checkout, route, advisory activity, and integration evidence without mutating DevPod, routes, ownership, Git, Docker, applications, worktrees, or branches. Local DevPod list/status checks always run; \`--check-merged\` alone enables read-only origin and matching GitHub/GitLab checks. Treat \`not-found\` as stale runtime after Docker pruning; busy, unavailable, or conflicting evidence suppresses destructive suggestions. Explicit \`gc\`/\`down\` can remove exact stale registration only after expected-ID \`NotFound\` proof and ownership revalidation. GC never removes Git worktrees, branches, or prune state. Git has no worktree-removal hook.
251
+ - **Sizing**: \`--measure-size\` adds per-workspace storage consumption to that report and stays read-only, but it walks each worktree and runs \`docker ps\` / \`docker inspect --size\`, so leave it off when you only need the evidence states. Each row reports reclaimable \`worktree\` and \`containerWritable\` bytes, non-reclaimable \`imageShared\` bytes, and a \`reclaimable\` total of the first two. \`imageShared\` covers image layers shared with other containers and overlaps across rows, so never sum it. Attribution is the workspace's own app container; sibling compose services such as a database are excluded. Any untrustworthy figure reports \`unknown\` with a reason rather than zero, and a workspace with no container reports a measured \`0\`.
251
252
  - **Boundary**: workspace commands require Git. Normal config, app, status, and doctor flows remain usable from a \`.devrouter.yml\` folder without \`.git\`.
252
253
 
253
254
  ## Secret manager interop (Infisical/Doppler)
@@ -323,6 +324,7 @@ Run several worktrees of one repo in parallel without host/route collisions. A *
323
324
  - \`devrouter workspace up <branch> [--path <dir>] [--no-devpod] [--open]\`: create a worktree and start/prove it unless create-only mode is requested
324
325
  - \`devrouter workspace ensure [path] [--open] [--json]\`: compatibility alias of \`devrouter ensure\`
325
326
  - \`devrouter workspace ls [--json]\`: list ownership, Git, DevPod, route, path, and branch evidence
327
+ - \`devrouter workspace cleanup [--repo .] [--inactive-for 30d] [--check-merged] [--measure-size] [--json]\`: report-only cleanup evidence and exact guarded suggestions; no \`--yes\` or apply mode
326
328
  - \`devrouter workspace stop <workspace|branch>\`: stop DevPod and routes; preserve checkout, owner record, and data
327
329
  - \`devrouter workspace down <workspace|branch> [--keep-worktree]\`: delete runtime/routes and optionally remove the clean worktree and record
328
330
  - \`devrouter workspace gc [--json] [--yes]\`: report missing owners by default; apply exact eligible cleanup with \`--yes\`
@@ -1431,6 +1433,25 @@ function readHostRouteStateLocked() {
1431
1433
  }
1432
1434
  return canonical.metadata.routes;
1433
1435
  }
1436
+ function readHostRouteStateReadOnly() {
1437
+ if (!import_node_fs6.default.existsSync(TRAEFIK_HOST_ROUTES_FILE)) {
1438
+ if (!import_node_fs6.default.existsSync(HOST_ROUTES_STATE_FILE)) {
1439
+ return [];
1440
+ }
1441
+ return readCompatibilityState();
1442
+ }
1443
+ const raw = import_node_fs6.default.readFileSync(TRAEFIK_HOST_ROUTES_FILE, "utf-8");
1444
+ const canonical = parseCanonicalState(raw);
1445
+ if (canonical.kind === "legacy") {
1446
+ if (!import_node_fs6.default.existsSync(HOST_ROUTES_STATE_FILE)) {
1447
+ throw new Error(
1448
+ "Headerless host-route document requires a valid compatibility state file for inspection."
1449
+ );
1450
+ }
1451
+ return readCompatibilityState();
1452
+ }
1453
+ return canonical.metadata.routes;
1454
+ }
1434
1455
  function listHostRouteState() {
1435
1456
  return withStateLock(readHostRouteStateLocked);
1436
1457
  }
@@ -2028,7 +2049,7 @@ function loadRepoConfig(repoPath) {
2028
2049
  const config = parseConfig(parsed ?? {}, configPath);
2029
2050
  const requiredVersion = config.devrouter?.version;
2030
2051
  if (requiredVersion && !hasWarnedVersionMismatch) {
2031
- const cliVersion = true ? "0.0.35" : "0.0.0-dev";
2052
+ const cliVersion = true ? "0.0.37" : "0.0.0-dev";
2032
2053
  if (cliVersion !== "0.0.0-dev" && compareSemver(requiredVersion, cliVersion) > 0) {
2033
2054
  hasWarnedVersionMismatch = true;
2034
2055
  process.stderr.write(
@@ -2537,6 +2558,8 @@ function buildOnboardingPrompt(options = {}) {
2537
2558
  "- Lifecycle: after one-time setup, use `devrouter ensure .` for both primary and linked checkouts; never branch on checkout kind or use live verify as startup. Managed consumer images contain no devrouter package/helper: ensure delivers its matching helper at runtime and invokes an exact captured snapshot of the repository-owned post-start adapter. Keep `.devrouter.yml` as the only consumer-side version pin. Use `devrouter stop .` for a non-destructive pause, `devrouter stop . --delete` only for explicit exact-owner cleanup without removing the checkout, and `devrouter exec . -- <command...>` for container commands. Never substitute raw DevPod mutations; they bypass the machine-global ownership lock. `workspace up` creates linked worktrees; destructive worktree removal and GC remain ledger-scoped.",
2538
2559
  "- Managed process reuse fingerprints command argv, workspace identity, and the exact adapter snapshot. If a non-secret runtime value also affects reuse, set `DEVROUTER_PROCESS_FINGERPRINT_ENV` to its comma-separated environment names; secret-like names are rejected and raw values are never persisted.",
2539
2560
  "- Owner status is `present`, `missing`, `locked`, or `conflict`. Dirty or locked full down fails before side effects. `workspace gc` is a dry run; only `--yes` deletes exact eligible missing resources and records, never Git worktrees, branches, or prune state.",
2561
+ "- `devrouter workspace cleanup --repo . --inactive-for 30d --check-merged --json` is report-only and never mutates DevPod, routes, ownership, Git, Docker, applications, worktrees, or branches. It reports ownership, DevPod registration, runtime (`running|stopped|busy|not-found|absent|unknown`), checkout, advisory activity, route, and integration evidence. Local DevPod list/status checks always run; `--check-merged` alone enables read-only origin and matching GitHub/GitLab checks. Treat `not-found` as stale runtime after Docker pruning; busy, unavailable, or conflicting evidence suppresses destructive suggestions. Explicit `gc`/`down` can remove exact stale registration only after expected-ID `NotFound` proof and ownership revalidation. DevPod `lastUsed` remains advisory.",
2562
+ '- `--measure-size` adds per-workspace storage consumption and stays read-only, but walks each worktree and runs `docker ps` / `docker inspect --size`, so leave it off for a quick report. Each row reports `worktree` and `containerWritable` bytes, which are reclaimable; `imageShared` bytes, which are not, because those image layers are shared with other containers and overlap across rows; and `reclaimable`, the sum of the reclaimable fields. Never add `imageShared` across rows. Any figure that cannot be trusted reports `{"status": "unknown", "reason": ...}` rather than zero, and a workspace with no container reports a measured `0`.',
2540
2563
  "- Workspace commands require Git. Normal config, app, status, and doctor flows work from a `.devrouter.yml` folder without `.git`. Git has no worktree-removal hook; use `workspace ls`, doctor, or dry-run GC after out-of-band removal.",
2541
2564
  "- devcontainer integration: `devcontainer.json` lists the base compose file then `${localEnv:DEVCONTAINER_COMPOSE_OVERLAY:docker-compose.default.yml}`. The default overlay contains `services: {}`; `.devcontainer/docker-compose.devrouter.yml` passes `WORKSPACE` and `DEVROUTER_WORKSPACE` into the app and bind-mounts `${DEVROUTER_GIT_COMMON_DIR}` to the same absolute app-container path. Ensure proves exact DevPod ownership, overlay/Git mounts, env, aliases, health, Git, HTTP route reachability, and unique running TCP upstream ownership before success.",
2542
2565
  "",
@@ -2719,6 +2742,10 @@ var init_ai_prompt = __esm({
2719
2742
  command: "devrouter workspace ls [--json]",
2720
2743
  purpose: "List owner, Git, DevPod, route, path, and branch evidence for managed workspaces."
2721
2744
  },
2745
+ {
2746
+ command: "devrouter workspace cleanup [--repo <path>] [--inactive-for 30d] [--check-merged] [--measure-size] [--json]",
2747
+ purpose: "Report-only evidence for managed linked workspaces; activity is advisory, --check-merged alone enables read-only origin/forge checks, and no --yes/apply path exists."
2748
+ },
2722
2749
  {
2723
2750
  command: "devrouter workspace stop <workspace|branch>",
2724
2751
  purpose: "Stop the exact DevPod and remove exact routes while preserving the worktree, owner record, and data."
@@ -2802,6 +2829,90 @@ function printJSON(value) {
2802
2829
  process.stdout.write(`${JSON.stringify(value, null, 2)}
2803
2830
  `);
2804
2831
  }
2832
+ function printWorkspaceCleanupReport(report) {
2833
+ process.stdout.write("Workspace cleanup report (read-only)\n");
2834
+ process.stdout.write(
2835
+ `${renderTable(
2836
+ ["FIELD", "VALUE"],
2837
+ [
2838
+ ["Repo path", report.repoPath],
2839
+ ["Generated", report.generatedAt],
2840
+ ["Inactive threshold", report.inactiveFor],
2841
+ ["Cutoff", report.cutoff],
2842
+ ["Merged check", report.checkMerged ? "enabled" : "disabled"],
2843
+ ["Size measurement", report.measureSize ? "enabled" : "disabled"],
2844
+ ["Managed workspaces", String(report.workspaces.length)]
2845
+ ]
2846
+ )}
2847
+ `
2848
+ );
2849
+ if (report.workspaces.length === 0) {
2850
+ process.stdout.write("\nNo managed linked workspaces found.\n");
2851
+ return;
2852
+ }
2853
+ for (const row of report.workspaces) {
2854
+ process.stdout.write(`
2855
+ Workspace: ${row.workspace}
2856
+ `);
2857
+ process.stdout.write(
2858
+ `${renderTable(
2859
+ ["FIELD", "VALUE"],
2860
+ [
2861
+ ["Branch", row.branch ?? "-"],
2862
+ ["Repo", row.repo],
2863
+ ["Worktree", row.worktreePath],
2864
+ ["DevPod", `${row.devpodId} (${row.provider})`],
2865
+ ["Runtime", row.runtime],
2866
+ ["Ownership", row.ownership],
2867
+ ["Checkout", row.checkout],
2868
+ ["Route", row.route],
2869
+ ["Activity", row.activity],
2870
+ ["Cutoff", row.cutoff],
2871
+ ["Latest activity", row.latestTimestamp ?? "-"],
2872
+ ["Contributing evidence", row.contributingEvidence.join(", ") || "-"],
2873
+ [
2874
+ "Activity evidence",
2875
+ row.activityEvidence.map(
2876
+ (entry) => `${entry.source}=${entry.status}${entry.timestamp ? `@${entry.timestamp}` : ""}`
2877
+ ).join(", ")
2878
+ ],
2879
+ ["Integration", row.integration],
2880
+ ["Eligible actions", row.eligibleActions.join("; ") || "none"],
2881
+ [
2882
+ "Suggestions",
2883
+ row.suggestions.map((suggestion) => `${suggestion.command} (${suggestion.reason})`).join("; ") || "none"
2884
+ ],
2885
+ ["Reasons", row.reasons.join(" | ") || "none"],
2886
+ ...row.consumption ? [
2887
+ ["Reclaimable total", formatCleanupSize(row.consumption.reclaimable)],
2888
+ [" worktree files", formatCleanupSize(row.consumption.worktree)],
2889
+ [" container writable", formatCleanupSize(row.consumption.containerWritable)],
2890
+ [
2891
+ "Shared image layers",
2892
+ // The qualifier describes a size, so it is dropped when there
2893
+ // is no size to qualify: "unknown (reason) (not reclaimable)"
2894
+ // reads as two competing answers to the same question.
2895
+ row.consumption.imageShared.status === "measured" ? `${formatCleanupSize(row.consumption.imageShared)} (not reclaimable)` : formatCleanupSize(row.consumption.imageShared)
2896
+ ]
2897
+ ] : []
2898
+ ]
2899
+ )}
2900
+ `
2901
+ );
2902
+ }
2903
+ }
2904
+ function formatCleanupSize(size) {
2905
+ if (size.status === "unknown") {
2906
+ return `unknown (${size.reason})`;
2907
+ }
2908
+ let value = size.bytes;
2909
+ let unit = 0;
2910
+ while (value >= 1024 && unit < BINARY_SIZE_UNITS.length - 1) {
2911
+ value /= 1024;
2912
+ unit += 1;
2913
+ }
2914
+ return unit === 0 ? `${value} B` : `${value.toFixed(1)} ${BINARY_SIZE_UNITS[unit]}`;
2915
+ }
2805
2916
  function printStatus(status) {
2806
2917
  const rows = [
2807
2918
  ["Docker context", status.dockerContext],
@@ -3005,11 +3116,13 @@ function printSetupReport(report) {
3005
3116
  }
3006
3117
  }
3007
3118
  }
3119
+ var BINARY_SIZE_UNITS;
3008
3120
  var init_output = __esm({
3009
3121
  "src/core/output.ts"() {
3010
3122
  "use strict";
3011
3123
  init_table();
3012
3124
  init_timeago();
3125
+ BINARY_SIZE_UNITS = ["B", "KiB", "MiB", "GiB", "TiB"];
3013
3126
  }
3014
3127
  });
3015
3128
 
@@ -4554,7 +4667,9 @@ var init_tool_diagnostics = __esm({
4554
4667
 
4555
4668
  // src/core/devpod-workspaces.ts
4556
4669
  function listDevpodWorkspaces() {
4557
- const result = (0, import_node_child_process8.spawnSync)("devpod", ["list", "--output", "json"], { encoding: "utf-8" });
4670
+ const result = (0, import_node_child_process8.spawnSync)("devpod", ["list", "--output", "json", "--skip-pro"], {
4671
+ encoding: "utf-8"
4672
+ });
4558
4673
  if (result.status !== 0) {
4559
4674
  const details = [result.error?.message, result.stdout, result.stderr].filter(Boolean).join("\n").trim();
4560
4675
  throw new Error(`devpod list failed: ${details || "devpod is not installed or unavailable"}`);
@@ -4573,9 +4688,50 @@ function listDevpodWorkspaces() {
4573
4688
  if (typeof candidate.id !== "string" || !candidate.source || typeof candidate.source.localFolder !== "string") {
4574
4689
  throw new Error("devpod list returned a workspace without id/source.localFolder.");
4575
4690
  }
4576
- return candidate;
4691
+ const workspace = {
4692
+ id: candidate.id,
4693
+ source: { localFolder: candidate.source.localFolder }
4694
+ };
4695
+ if ("lastUsed" in candidate) {
4696
+ if (typeof candidate.lastUsed === "string") {
4697
+ workspace.lastUsed = candidate.lastUsed;
4698
+ } else {
4699
+ workspace.lastUsedMalformed = true;
4700
+ }
4701
+ }
4702
+ return workspace;
4577
4703
  });
4578
4704
  }
4705
+ function parseDevpodRuntimeStatus(output2, expectedId) {
4706
+ let parsed;
4707
+ try {
4708
+ parsed = JSON.parse(output2);
4709
+ } catch {
4710
+ return "unknown";
4711
+ }
4712
+ if (!parsed || typeof parsed !== "object" || Array.isArray(parsed) || parsed.id !== expectedId) {
4713
+ return "unknown";
4714
+ }
4715
+ switch (parsed.state) {
4716
+ case "Running":
4717
+ return "running";
4718
+ case "Stopped":
4719
+ return "stopped";
4720
+ case "Busy":
4721
+ return "busy";
4722
+ case "NotFound":
4723
+ return "not-found";
4724
+ default:
4725
+ return "unknown";
4726
+ }
4727
+ }
4728
+ function inspectDevpodRuntimeStatus(devpodId) {
4729
+ const result = (0, import_node_child_process8.spawnSync)("devpod", ["status", devpodId, "--output", "json", "--timeout", "5s"], {
4730
+ encoding: "utf-8"
4731
+ });
4732
+ if (result.status !== 0 || result.error) return "unknown";
4733
+ return parseDevpodRuntimeStatus(result.stdout, devpodId);
4734
+ }
4579
4735
  function inspectDevpodWorkspaceOwnership(workspaces, devpodId, worktreePath) {
4580
4736
  const idOwners = workspaces.filter((workspace) => workspace.id === devpodId);
4581
4737
  const pathOwners = workspaces.filter(
@@ -4624,28 +4780,46 @@ function withMutationLock(activity, target, operation) {
4624
4780
  function commandFailure2(result) {
4625
4781
  return [result.error?.message, result.stdout, result.stderr].filter(Boolean).join("\n").trim();
4626
4782
  }
4627
- function runDevpodAction(action2, devpodId) {
4628
- const args = action2 === "delete" ? [action2, devpodId, "--ignore-not-found"] : [action2, devpodId];
4783
+ function runDevpodAction(action2, devpodId, force = false) {
4784
+ const args = action2 === "delete" ? [action2, devpodId, ...force ? ["--force"] : [], "--ignore-not-found"] : [action2, devpodId];
4629
4785
  const result = (0, import_node_child_process9.spawnSync)("devpod", args, { encoding: "utf-8" });
4630
4786
  if (result.status !== 0) {
4631
4787
  throw new Error(
4632
- `devpod ${action2} failed for '${devpodId}': ${commandFailure2(result) || "unknown error"}`
4788
+ `devpod ${action2}${force ? " --force" : ""} failed for '${devpodId}': ${commandFailure2(result) || "unknown error"}`
4633
4789
  );
4634
4790
  }
4635
4791
  }
4792
+ function inspectExactOwnership(devpodId, worktreePath) {
4793
+ const ownership = inspectDevpodWorkspaceOwnership(listDevpodWorkspaces(), devpodId, worktreePath);
4794
+ if (ownership.status === "conflict") throw new Error(ownership.reason);
4795
+ return ownership;
4796
+ }
4636
4797
  function mutateOwnedDevpodWorkspace(action2, devpodId, worktreePath) {
4637
4798
  return withMutationLock(`DevPod ${action2}`, worktreePath, () => {
4638
- const before = inspectDevpodWorkspaceOwnership(listDevpodWorkspaces(), devpodId, worktreePath);
4639
- if (before.status === "conflict") throw new Error(before.reason);
4799
+ const before = inspectExactOwnership(devpodId, worktreePath);
4640
4800
  if (before.status === "absent") return { status: "absent" };
4641
4801
  runDevpodAction(action2, devpodId);
4642
- const after = inspectDevpodWorkspaceOwnership(listDevpodWorkspaces(), devpodId, worktreePath);
4643
- if (after.status === "conflict") throw new Error(after.reason);
4802
+ let after = inspectExactOwnership(devpodId, worktreePath);
4644
4803
  if (action2 === "stop" && after.status !== "owned") {
4645
4804
  throw new Error(`DevPod '${devpodId}' no longer owns '${worktreePath}' after provider stop.`);
4646
4805
  }
4647
- if (action2 === "delete" && after.status !== "absent") {
4648
- throw new Error(`DevPod '${devpodId}' still owns '${worktreePath}' after provider delete.`);
4806
+ if (action2 === "delete" && after.status === "owned") {
4807
+ const runtime = inspectDevpodRuntimeStatus(devpodId);
4808
+ if (runtime !== "not-found") {
4809
+ throw new Error(
4810
+ `DevPod '${devpodId}' still owns '${worktreePath}' after provider delete (runtime=${runtime}).`
4811
+ );
4812
+ }
4813
+ after = inspectExactOwnership(devpodId, worktreePath);
4814
+ if (after.status === "owned") {
4815
+ runDevpodAction("delete", devpodId, true);
4816
+ after = inspectExactOwnership(devpodId, worktreePath);
4817
+ }
4818
+ if (after.status !== "absent") {
4819
+ throw new Error(
4820
+ `DevPod '${devpodId}' still owns '${worktreePath}' after forced provider delete.`
4821
+ );
4822
+ }
4649
4823
  }
4650
4824
  return { status: "changed" };
4651
4825
  });
@@ -4746,7 +4920,8 @@ function commandError(command, repoPath, stderr) {
4746
4920
  }
4747
4921
  function resolveGitCommonDir(repoPath) {
4748
4922
  const result = (0, import_node_child_process10.spawnSync)("git", ["-C", repoPath, "rev-parse", "--git-common-dir"], {
4749
- encoding: "utf-8"
4923
+ encoding: "utf-8",
4924
+ env: READ_ONLY_GIT_ENV
4750
4925
  });
4751
4926
  const output2 = result.stdout.trim();
4752
4927
  if (result.status !== 0 || !output2) {
@@ -4756,7 +4931,8 @@ function resolveGitCommonDir(repoPath) {
4756
4931
  }
4757
4932
  function resolveGitTopLevel(repoPath) {
4758
4933
  const result = (0, import_node_child_process10.spawnSync)("git", ["-C", repoPath, "rev-parse", "--show-toplevel"], {
4759
- encoding: "utf-8"
4934
+ encoding: "utf-8",
4935
+ env: READ_ONLY_GIT_ENV
4760
4936
  });
4761
4937
  const output2 = result.stdout.trim();
4762
4938
  if (result.status !== 0 || !output2) {
@@ -4766,7 +4942,8 @@ function resolveGitTopLevel(repoPath) {
4766
4942
  }
4767
4943
  function listGitWorktrees(repoPath) {
4768
4944
  const result = (0, import_node_child_process10.spawnSync)("git", ["-C", repoPath, "worktree", "list", "--porcelain"], {
4769
- encoding: "utf-8"
4945
+ encoding: "utf-8",
4946
+ env: READ_ONLY_GIT_ENV
4770
4947
  });
4771
4948
  if (result.status !== 0) {
4772
4949
  throw commandError("git worktree list", repoPath, result.stderr);
@@ -5003,7 +5180,7 @@ function listMissingWorkspaceOwnership(repoPath) {
5003
5180
  (record) => inspectWorkspaceOwnership(record, worktrees, void 0).ownerStatus === "missing"
5004
5181
  );
5005
5182
  }
5006
- var import_node_child_process10, import_node_fs16, import_node_path14, OWNERSHIP_VERSION, OWNERSHIP_DIR;
5183
+ var import_node_child_process10, import_node_fs16, import_node_path14, READ_ONLY_GIT_ENV, OWNERSHIP_VERSION, OWNERSHIP_DIR;
5007
5184
  var init_workspace_ownership = __esm({
5008
5185
  "src/core/workspace-ownership.ts"() {
5009
5186
  "use strict";
@@ -5014,6 +5191,7 @@ var init_workspace_ownership = __esm({
5014
5191
  init_devpod_workspaces();
5015
5192
  init_file_lock();
5016
5193
  init_workspace();
5194
+ READ_ONLY_GIT_ENV = { ...process.env, GIT_OPTIONAL_LOCKS: "0" };
5017
5195
  OWNERSHIP_VERSION = 1;
5018
5196
  OWNERSHIP_DIR = import_node_path14.default.join("devrouter", "workspaces");
5019
5197
  }
@@ -5616,7 +5794,7 @@ async function buildDoctorReport(options = {}) {
5616
5794
  const config = runtimeConfig.config;
5617
5795
  loadedConfig = config;
5618
5796
  loadedWorkspace = runtimeConfig.workspace;
5619
- const cliVersion = true ? "0.0.35" : "0.0.0-dev";
5797
+ const cliVersion = true ? "0.0.37" : "0.0.0-dev";
5620
5798
  const configVersion = config.devrouter?.version;
5621
5799
  if (configVersion && cliVersion !== "0.0.0-dev" && compareSemver(configVersion, cliVersion) > 0) {
5622
5800
  addCheck(checks, {
@@ -6193,20 +6371,26 @@ var init_up = __esm({
6193
6371
  });
6194
6372
 
6195
6373
  // src/core/devpod-environment.ts
6196
- function inspectWorkspaceContainers() {
6197
- const listed = (0, import_node_child_process12.spawnSync)("docker", ["ps", "-a", "--format", "{{.ID}}"], {
6198
- encoding: "utf-8"
6199
- });
6200
- if (listed.status !== 0) {
6201
- throw new Error(
6202
- `docker ps failed: ${(listed.stderr || listed.stdout || "unknown error").trim()}`
6203
- );
6374
+ function inspectWorkspaceContainers(options) {
6375
+ let ids = options?.ids;
6376
+ if (!ids) {
6377
+ const listed = (0, import_node_child_process12.spawnSync)("docker", ["ps", "-a", "--format", "{{.ID}}"], {
6378
+ encoding: "utf-8"
6379
+ });
6380
+ if (listed.status !== 0) {
6381
+ throw new Error(
6382
+ `docker ps failed: ${(listed.stderr || listed.stdout || listed.error?.message || "unknown error").trim()}`
6383
+ );
6384
+ }
6385
+ ids = listed.stdout.split(/\r?\n/).map((id) => id.trim()).filter(Boolean);
6204
6386
  }
6205
- const ids = listed.stdout.split(/\r?\n/).map((id) => id.trim()).filter(Boolean);
6206
6387
  if (ids.length === 0) return [];
6207
- const inspected = (0, import_node_child_process12.spawnSync)("docker", ["inspect", "--format", SAFE_INSPECT_TEMPLATE, ...ids], {
6208
- encoding: "utf-8"
6209
- });
6388
+ const template = options?.withSize ? SIZE_INSPECT_TEMPLATE : SAFE_INSPECT_TEMPLATE;
6389
+ const inspected = (0, import_node_child_process12.spawnSync)(
6390
+ "docker",
6391
+ ["inspect", ...options?.withSize ? ["--size"] : [], "--format", template, ...ids],
6392
+ { encoding: "utf-8" }
6393
+ );
6210
6394
  if (inspected.status !== 0) {
6211
6395
  throw new Error(
6212
6396
  `docker inspect failed: ${(inspected.stderr || inspected.stdout || "unknown error").trim()}`
@@ -6240,7 +6424,7 @@ function resolveRunningWorkspaceContainer(repoPath) {
6240
6424
  }
6241
6425
  return { id: container.id, workspacePath: repoMount.Destination };
6242
6426
  }
6243
- var import_node_child_process12, import_node_path17, SAFE_INSPECT_TEMPLATE;
6427
+ var import_node_child_process12, import_node_path17, SAFE_INSPECT_TEMPLATE, SIZE_INSPECT_TEMPLATE;
6244
6428
  var init_devpod_environment = __esm({
6245
6429
  "src/core/devpod-environment.ts"() {
6246
6430
  "use strict";
@@ -6248,6 +6432,10 @@ var init_devpod_environment = __esm({
6248
6432
  import_node_path17 = __toESM(require("path"));
6249
6433
  init_workspace();
6250
6434
  SAFE_INSPECT_TEMPLATE = '{"id":{{json .Id}},"state":{"Running":{{json .State.Running}},"Health":{{with (index .State "Health")}}{"Status":{{json .Status}}}{{else}}null{{end}}},"labels":{"com.docker.compose.project.working_dir":{{json (index .Config.Labels "com.docker.compose.project.working_dir")}},"com.docker.compose.project.config_files":{{json (index .Config.Labels "com.docker.compose.project.config_files")}}},"mounts":{{json .Mounts}},"networks":{{json .NetworkSettings.Networks}}}';
6435
+ SIZE_INSPECT_TEMPLATE = SAFE_INSPECT_TEMPLATE.replace(
6436
+ /}$/,
6437
+ ',"sizeRw":{{json (index . "SizeRw")}},"sizeRootFs":{{json (index . "SizeRootFs")}}}'
6438
+ );
6251
6439
  }
6252
6440
  });
6253
6441
 
@@ -6269,7 +6457,8 @@ function probeHttpRoute(host, options = {}) {
6269
6457
  String(options.maxTimeSeconds ?? 5)
6270
6458
  ];
6271
6459
  if (tlsEnabled) {
6272
- args.push("--cacert", getMkcertRootCAPath({ repoPath: options.repoPath }));
6460
+ getMkcertRootCAPath({ repoPath: options.repoPath });
6461
+ args.push("--cacert", CERT_FILE);
6273
6462
  }
6274
6463
  args.push(url);
6275
6464
  const result = (0, import_node_child_process13.spawnSync)("curl", args, { encoding: "utf-8" });
@@ -10254,9 +10443,847 @@ var init_tls2 = __esm({
10254
10443
  }
10255
10444
  });
10256
10445
 
10446
+ // src/core/workspace-consumption.ts
10447
+ function measureWorktreeConsumption(worktreePath, options) {
10448
+ const deadlineMs = options?.deadlineMs ?? DEFAULT_DEADLINE_MS;
10449
+ const startedAt = Date.now();
10450
+ let rootStat;
10451
+ try {
10452
+ rootStat = import_node_fs24.default.lstatSync(worktreePath);
10453
+ } catch (error) {
10454
+ return { status: "unknown", reason: describeError(error, worktreePath) };
10455
+ }
10456
+ let rootEntries;
10457
+ try {
10458
+ rootEntries = import_node_fs24.default.readdirSync(worktreePath, { withFileTypes: true });
10459
+ } catch (error) {
10460
+ return { status: "unknown", reason: describeError(error, worktreePath) };
10461
+ }
10462
+ const seenInodes = /* @__PURE__ */ new Set();
10463
+ let bytes = 0;
10464
+ let timedOut = false;
10465
+ let unreadableReason = null;
10466
+ const accumulate = (stat) => {
10467
+ if (stat.nlink > 1) {
10468
+ const key = `${stat.dev}:${stat.ino}`;
10469
+ if (seenInodes.has(key)) return;
10470
+ seenInodes.add(key);
10471
+ }
10472
+ bytes += stat.blocks * BLOCK_SIZE_BYTES;
10473
+ };
10474
+ const deadlineExceeded = () => Date.now() - startedAt >= deadlineMs;
10475
+ accumulate(rootStat);
10476
+ const stack = [{ dirPath: worktreePath, entries: rootEntries }];
10477
+ while (stack.length > 0 && !timedOut && !unreadableReason) {
10478
+ const { dirPath, entries } = stack.pop();
10479
+ for (const entry of entries) {
10480
+ if (deadlineExceeded()) {
10481
+ timedOut = true;
10482
+ break;
10483
+ }
10484
+ const entryPath = import_node_path24.default.join(dirPath, entry.name);
10485
+ let entryStat;
10486
+ try {
10487
+ entryStat = import_node_fs24.default.lstatSync(entryPath);
10488
+ } catch (error) {
10489
+ if (error?.code === "ENOENT") continue;
10490
+ unreadableReason = describeIncompleteWalk(error);
10491
+ break;
10492
+ }
10493
+ accumulate(entryStat);
10494
+ if (!entryStat.isDirectory()) continue;
10495
+ let childEntries;
10496
+ try {
10497
+ childEntries = import_node_fs24.default.readdirSync(entryPath, { withFileTypes: true });
10498
+ } catch (error) {
10499
+ unreadableReason = describeIncompleteWalk(error);
10500
+ break;
10501
+ }
10502
+ stack.push({ dirPath: entryPath, entries: childEntries });
10503
+ }
10504
+ }
10505
+ if (timedOut) {
10506
+ return { status: "unknown", reason: `exceeded deadline of ${deadlineMs}ms` };
10507
+ }
10508
+ if (unreadableReason) {
10509
+ return { status: "unknown", reason: unreadableReason };
10510
+ }
10511
+ return { status: "measured", bytes };
10512
+ }
10513
+ function describeIncompleteWalk(error) {
10514
+ return `could not read every path inside the worktree: ${error?.message ?? String(error)}`;
10515
+ }
10516
+ function measureContainerConsumption(worktreePaths, dependencies) {
10517
+ const byWorktree = /* @__PURE__ */ new Map();
10518
+ if (worktreePaths.length === 0) return byWorktree;
10519
+ const inspect = dependencies?.inspect ?? inspectWorkspaceContainers;
10520
+ const containers = inspect();
10521
+ const attributedIds = /* @__PURE__ */ new Set();
10522
+ for (const worktreePath of worktreePaths) {
10523
+ for (const container of workspaceAppContainers(containers, worktreePath)) {
10524
+ attributedIds.add(container.id);
10525
+ }
10526
+ }
10527
+ const sized = attributedIds.size === 0 ? [] : inspect({ withSize: true, ids: Array.from(attributedIds) });
10528
+ for (const worktreePath of worktreePaths) {
10529
+ byWorktree.set(worktreePath, summarizeContainers(workspaceAppContainers(sized, worktreePath)));
10530
+ }
10531
+ return byWorktree;
10532
+ }
10533
+ function summarizeContainers(containers) {
10534
+ let writable = 0;
10535
+ let shared = 0;
10536
+ for (const container of containers) {
10537
+ const { sizeRw, sizeRootFs } = container;
10538
+ if (typeof sizeRw !== "number" || typeof sizeRootFs !== "number" || !Number.isFinite(sizeRw) || !Number.isFinite(sizeRootFs) || sizeRootFs < sizeRw) {
10539
+ const unknown = {
10540
+ status: "unknown",
10541
+ reason: `container ${container.id.slice(0, 12)} reported no usable size`
10542
+ };
10543
+ return { containerWritable: unknown, imageShared: unknown };
10544
+ }
10545
+ writable += sizeRw;
10546
+ shared += sizeRootFs - sizeRw;
10547
+ }
10548
+ return {
10549
+ containerWritable: { status: "measured", bytes: writable },
10550
+ imageShared: { status: "measured", bytes: shared }
10551
+ };
10552
+ }
10553
+ function describeError(error, worktreePath) {
10554
+ const code = error?.code;
10555
+ if (code === "ENOENT") return `worktree path '${worktreePath}' does not exist`;
10556
+ if (code === "EACCES" || code === "EPERM") {
10557
+ return `permission denied reading worktree path '${worktreePath}'`;
10558
+ }
10559
+ return `could not stat worktree path '${worktreePath}': ${error?.message ?? String(error)}`;
10560
+ }
10561
+ var import_node_fs24, import_node_path24, DEFAULT_DEADLINE_MS, BLOCK_SIZE_BYTES;
10562
+ var init_workspace_consumption = __esm({
10563
+ "src/core/workspace-consumption.ts"() {
10564
+ "use strict";
10565
+ import_node_fs24 = __toESM(require("fs"));
10566
+ import_node_path24 = __toESM(require("path"));
10567
+ init_devpod_environment();
10568
+ DEFAULT_DEADLINE_MS = 1e4;
10569
+ BLOCK_SIZE_BYTES = 512;
10570
+ }
10571
+ });
10572
+
10573
+ // src/core/workspace-cleanup.ts
10574
+ function isRecord2(value) {
10575
+ return value !== null && typeof value === "object" && !Array.isArray(value);
10576
+ }
10577
+ function isSha(value) {
10578
+ return typeof value === "string" && /^[0-9a-f]{40,64}$/i.test(value);
10579
+ }
10580
+ function parseTimestamp(value) {
10581
+ if (typeof value !== "string") return void 0;
10582
+ const timestamp = Date.parse(value);
10583
+ return Number.isFinite(timestamp) ? timestamp : void 0;
10584
+ }
10585
+ function validTimestamp(value) {
10586
+ return parseTimestamp(value) !== void 0;
10587
+ }
10588
+ function successfulOutput(result) {
10589
+ if (result.status !== 0 || result.error) return void 0;
10590
+ const output2 = result.stdout.trim();
10591
+ return output2.length > 0 ? output2 : void 0;
10592
+ }
10593
+ function gitOutput(repoPath, args, commandRunner) {
10594
+ return successfulOutput(commandRunner("git", ["-C", repoPath, ...args]));
10595
+ }
10596
+ function quoteCommandArg(value) {
10597
+ return /^[a-zA-Z0-9_./:@+-]+$/.test(value) ? value : `'${value.replaceAll("'", "'\\''")}'`;
10598
+ }
10599
+ function workspaceCommand(repoPath, args, beforeRepo = [], afterRepo = []) {
10600
+ return [
10601
+ "devrouter",
10602
+ "workspace",
10603
+ ...args,
10604
+ ...beforeRepo,
10605
+ "--repo",
10606
+ quoteCommandArg(repoPath),
10607
+ ...afterRepo
10608
+ ].join(" ");
10609
+ }
10610
+ function parseInactiveFor(value = DEFAULT_INACTIVE_FOR) {
10611
+ const match = /^(?<amount>[1-9]\d*)(?<unit>[smhdw])$/.exec(value);
10612
+ if (!match?.groups) {
10613
+ throw new Error(
10614
+ "--inactive-for must be a positive integer followed by s, m, h, d, or w (for example 30d)."
10615
+ );
10616
+ }
10617
+ const amount = Number(match.groups.amount);
10618
+ const multiplier = { s: 1, m: 60, h: 3600, d: 86400, w: 604800 }[match.groups.unit];
10619
+ const seconds = amount * multiplier;
10620
+ if (!Number.isSafeInteger(seconds)) {
10621
+ throw new Error("--inactive-for is too large.");
10622
+ }
10623
+ return { input: value, seconds };
10624
+ }
10625
+ function evaluateWorkspaceActivity(evidence, cutoff) {
10626
+ const cutoffTime = parseTimestamp(cutoff);
10627
+ if (cutoffTime === void 0) {
10628
+ throw new Error(`Invalid activity cutoff '${cutoff}'.`);
10629
+ }
10630
+ const validEvidence = evidence.filter(
10631
+ (entry) => entry.status === "valid" && validTimestamp(entry.timestamp)
10632
+ );
10633
+ const latestTime = validEvidence.reduce(
10634
+ (latest, entry) => Math.max(
10635
+ latest ?? Number.NEGATIVE_INFINITY,
10636
+ parseTimestamp(entry.timestamp) ?? Number.NEGATIVE_INFINITY
10637
+ ),
10638
+ void 0
10639
+ );
10640
+ const latestTimestamp = latestTime === void 0 ? null : validEvidence.find((entry) => parseTimestamp(entry.timestamp) === latestTime)?.timestamp ?? null;
10641
+ const contributingEvidence = ACTIVITY_SOURCES.filter(
10642
+ (source) => validEvidence.some(
10643
+ (entry) => entry.source === source && parseTimestamp(entry.timestamp) === latestTime
10644
+ )
10645
+ );
10646
+ let status = "unknown";
10647
+ if (latestTime !== void 0) {
10648
+ if (latestTime >= cutoffTime) {
10649
+ status = "recent";
10650
+ } else if (validEvidence.length > 0 && evidence.every(
10651
+ (entry) => entry.status === "valid" || entry.status === "not-applicable" || entry.status === "missing"
10652
+ )) {
10653
+ status = "quiet";
10654
+ }
10655
+ }
10656
+ return {
10657
+ status,
10658
+ latestTimestamp,
10659
+ contributingEvidence,
10660
+ evidence: evidence.slice().sort(
10661
+ (left, right) => ACTIVITY_SOURCES.indexOf(left.source) - ACTIVITY_SOURCES.indexOf(right.source)
10662
+ )
10663
+ };
10664
+ }
10665
+ function readGitSnapshot(worktree, commandRunner) {
10666
+ const comparablePath = comparableWorkspacePath(worktree.path);
10667
+ if (worktree.prunable || !import_node_fs25.default.existsSync(comparablePath)) {
10668
+ return { worktree, checkout: "missing", head: null, committerDate: null };
10669
+ }
10670
+ const head = gitOutput(comparablePath, ["rev-parse", "--verify", "HEAD"], commandRunner);
10671
+ if (!head) {
10672
+ return { worktree, checkout: "unknown", head: null, committerDate: null };
10673
+ }
10674
+ const committerDate = gitOutput(comparablePath, ["show", "-s", "--format=%cI", "HEAD"], commandRunner) ?? null;
10675
+ const statusResult = commandRunner("git", [
10676
+ "-C",
10677
+ comparablePath,
10678
+ "status",
10679
+ "--porcelain=v1",
10680
+ "--untracked-files=normal"
10681
+ ]);
10682
+ const checkout = worktree.branch === void 0 ? "detached" : statusResult.status !== 0 || statusResult.error ? "unknown" : statusResult.stdout.trim().length > 0 ? "dirty" : "clean";
10683
+ return { worktree, checkout, head, committerDate };
10684
+ }
10685
+ function parseRemoteIdentity(value) {
10686
+ const normalized = value.trim();
10687
+ let host;
10688
+ let project;
10689
+ const scp = /^git@([^:]+):(.+)$/.exec(normalized);
10690
+ if (scp) {
10691
+ host = scp[1];
10692
+ project = scp[2];
10693
+ } else {
10694
+ try {
10695
+ const url = new URL(normalized);
10696
+ if (url.protocol !== "https:" && url.protocol !== "ssh:") return void 0;
10697
+ host = url.hostname;
10698
+ project = url.pathname.replace(/^\/+/, "");
10699
+ } catch {
10700
+ return void 0;
10701
+ }
10702
+ }
10703
+ project = project.replace(/\.git$/, "").replace(/\/+$/, "");
10704
+ if (!host || !project || project.includes("..") || project.includes(" ")) return void 0;
10705
+ const lowerHost = host.toLowerCase();
10706
+ if (lowerHost === "github.com" && /^[^/]+\/[^/]+$/.test(project)) {
10707
+ return { provider: "github", host: lowerHost, project };
10708
+ }
10709
+ if (lowerHost === "gitlab.com") {
10710
+ return { provider: "gitlab", host: lowerHost, project };
10711
+ }
10712
+ return void 0;
10713
+ }
10714
+ function parseGitHubChanges(value, project, branch) {
10715
+ if (!Array.isArray(value)) return void 0;
10716
+ const changes = [];
10717
+ for (const item of value) {
10718
+ if (!isRecord2(item)) return void 0;
10719
+ const repository = isRecord2(item.repository) ? item.repository.nameWithOwner : void 0;
10720
+ const headRepository = isRecord2(item.headRepository) ? item.headRepository.nameWithOwner : void 0;
10721
+ if (repository !== project || headRepository !== project || item.headRefName !== branch || !isSha(item.headRefOid) || typeof item.baseRefName !== "string") {
10722
+ continue;
10723
+ }
10724
+ const merged = item.state === "MERGED" && validTimestamp(item.mergedAt);
10725
+ const mergeCommit = isRecord2(item.mergeCommit) && isSha(item.mergeCommit.oid) ? item.mergeCommit.oid : void 0;
10726
+ const baseSha = isSha(item.baseRefOid) ? item.baseRefOid : void 0;
10727
+ changes.push({
10728
+ sourceBranch: branch,
10729
+ sourceHeadSha: item.headRefOid,
10730
+ targetBranch: item.baseRefName,
10731
+ ...baseSha ? { baseSha } : {},
10732
+ ...mergeCommit ? { mergeCommitSha: mergeCommit } : {},
10733
+ merged
10734
+ });
10735
+ }
10736
+ return changes;
10737
+ }
10738
+ function parseGitLabChanges(value, _project, branch) {
10739
+ if (!Array.isArray(value)) return void 0;
10740
+ const changes = [];
10741
+ for (const item of value) {
10742
+ if (!isRecord2(item)) return void 0;
10743
+ const sourceProjectId = item.source_project_id;
10744
+ const targetProjectId = item.target_project_id;
10745
+ const sameProject = typeof sourceProjectId === "number" && typeof targetProjectId === "number" && sourceProjectId === targetProjectId;
10746
+ if (!sameProject || item.source_branch !== branch || !isSha(item.sha) || typeof item.target_branch !== "string") {
10747
+ continue;
10748
+ }
10749
+ const diffRefs = isRecord2(item.diff_refs) ? item.diff_refs : void 0;
10750
+ const mergeCommitSha = isSha(item.merge_commit_sha) ? item.merge_commit_sha : void 0;
10751
+ const baseSha = diffRefs && isSha(diffRefs.base_sha) ? diffRefs.base_sha : void 0;
10752
+ changes.push({
10753
+ sourceBranch: branch,
10754
+ sourceHeadSha: item.sha,
10755
+ targetBranch: item.target_branch,
10756
+ ...baseSha ? { baseSha } : {},
10757
+ ...mergeCommitSha ? { mergeCommitSha } : {},
10758
+ merged: item.state === "merged" && validTimestamp(item.merged_at)
10759
+ });
10760
+ }
10761
+ return changes;
10762
+ }
10763
+ function patchIdForRange(repoPath, baseSha, headSha, commandRunner) {
10764
+ const diff = commandRunner("git", [
10765
+ "-C",
10766
+ repoPath,
10767
+ "diff",
10768
+ "--no-ext-diff",
10769
+ "--binary",
10770
+ baseSha,
10771
+ headSha
10772
+ ]);
10773
+ if (diff.status !== 0 || diff.error || diff.stdout.length === 0) return void 0;
10774
+ const result = commandRunner("git", ["patch-id", "--stable"], diff.stdout);
10775
+ if (result.status !== 0 || result.error) return void 0;
10776
+ const match = /^([0-9a-f]{40,64})\s+(?:-|[0-9a-f]{40,64})$/i.exec(result.stdout.trim());
10777
+ return match?.[1];
10778
+ }
10779
+ function hasUniqueSourceCommit(repoPath, baseSha, headSha, commandRunner) {
10780
+ const result = gitOutput(
10781
+ repoPath,
10782
+ ["rev-list", "--no-merges", "--reverse", `${baseSha}..${headSha}`],
10783
+ commandRunner
10784
+ );
10785
+ if (!result) return false;
10786
+ const commits = result.split(/\r?\n/).map((sha) => sha.trim()).filter((sha) => sha.length > 0);
10787
+ return commits.length > 0 && commits.every(isSha) && new Set(commits).size > 0;
10788
+ }
10789
+ function hasVerifiedCommonBase(repoPath, baseSha, headSha, commandRunner) {
10790
+ const result = commandRunner("git", [
10791
+ "-C",
10792
+ repoPath,
10793
+ "merge-base",
10794
+ "--is-ancestor",
10795
+ baseSha,
10796
+ headSha
10797
+ ]);
10798
+ return result.status === 0 && !result.error;
10799
+ }
10800
+ function parseTargetBranch(output2) {
10801
+ if (!output2) return void 0;
10802
+ const value = output2.trim();
10803
+ return value.startsWith("origin/") && value.length > "origin/".length ? value.slice("origin/".length) : void 0;
10804
+ }
10805
+ function parseRemoteDefaultTarget(output2) {
10806
+ if (!output2) return void 0;
10807
+ const match = /^ref:\s+refs\/heads\/([^\s]+)\s+HEAD$/m.exec(output2);
10808
+ return match?.[1];
10809
+ }
10810
+ function inspectWorkspaceIntegration(repoPath, snapshot, branch, checkMerged, commandRunner) {
10811
+ if (!checkMerged) return { status: "not-verified", reason: "Merged checks were not requested." };
10812
+ if (!snapshot.head || !branch || snapshot.checkout === "missing" || snapshot.checkout === "unknown") {
10813
+ return { status: "unknown", reason: "Current HEAD or branch evidence is unavailable." };
10814
+ }
10815
+ const originUrl = gitOutput(repoPath, ["config", "--get", "remote.origin.url"], commandRunner);
10816
+ const identity = originUrl ? parseRemoteIdentity(originUrl) : void 0;
10817
+ if (!identity) {
10818
+ return { status: "unknown", reason: "The origin is missing or uses an unsupported forge." };
10819
+ }
10820
+ const localTargetBranch = parseTargetBranch(
10821
+ gitOutput(
10822
+ repoPath,
10823
+ ["symbolic-ref", "--quiet", "--short", "refs/remotes/origin/HEAD"],
10824
+ commandRunner
10825
+ )
10826
+ );
10827
+ const remoteTargetBranch = parseRemoteDefaultTarget(
10828
+ successfulOutput(
10829
+ commandRunner("git", ["-C", repoPath, "ls-remote", "--symref", "origin", "HEAD"])
10830
+ )
10831
+ );
10832
+ if (!remoteTargetBranch || localTargetBranch && localTargetBranch !== remoteTargetBranch) {
10833
+ return {
10834
+ status: "unknown",
10835
+ reason: "The origin default target is missing, stale, or unavailable."
10836
+ };
10837
+ }
10838
+ const targetBranch = remoteTargetBranch;
10839
+ const localTargetSha = gitOutput(
10840
+ repoPath,
10841
+ ["rev-parse", "--verify", `refs/remotes/origin/${targetBranch}`],
10842
+ commandRunner
10843
+ );
10844
+ const remoteTargetOutput = successfulOutput(
10845
+ commandRunner("git", ["-C", repoPath, "ls-remote", "origin", `refs/heads/${targetBranch}`])
10846
+ );
10847
+ const remoteTargetSha = remoteTargetOutput?.split(/\s+/)[0];
10848
+ if (!isSha(localTargetSha) || !isSha(remoteTargetSha) || localTargetSha !== remoteTargetSha) {
10849
+ return { status: "unknown", reason: "The origin target is missing, stale, or unavailable." };
10850
+ }
10851
+ const sourceRemoteOutput = successfulOutput(
10852
+ commandRunner("git", ["-C", repoPath, "ls-remote", "origin", `refs/heads/${branch}`])
10853
+ );
10854
+ const sourceRemoteSha = sourceRemoteOutput?.split(/\s+/)[0];
10855
+ if (!isSha(sourceRemoteSha)) {
10856
+ return {
10857
+ status: "unknown",
10858
+ reason: "The workspace source branch is missing or unavailable on origin."
10859
+ };
10860
+ }
10861
+ const forgeChanges = [];
10862
+ const forgeCommand = identity.provider === "github" ? [
10863
+ "pr",
10864
+ "list",
10865
+ "--repo",
10866
+ identity.project,
10867
+ "--state",
10868
+ "all",
10869
+ "--head",
10870
+ branch,
10871
+ "--json",
10872
+ "headRefName,headRefOid,baseRefName,baseRefOid,state,mergedAt,repository,headRepository,mergeCommit"
10873
+ ] : [
10874
+ "mr",
10875
+ "list",
10876
+ "--repo",
10877
+ identity.project,
10878
+ "--all",
10879
+ "--source-branch",
10880
+ branch,
10881
+ "--output",
10882
+ "json"
10883
+ ];
10884
+ const forge = commandRunner(identity.provider === "github" ? "gh" : "glab", forgeCommand);
10885
+ if (forge.status !== 0 || forge.error) {
10886
+ return { status: "unknown", reason: "The forge query was unavailable or unauthenticated." };
10887
+ }
10888
+ try {
10889
+ const parsed = JSON.parse(forge.stdout);
10890
+ const changes = identity.provider === "github" ? parseGitHubChanges(parsed, identity.project, branch) : parseGitLabChanges(parsed, identity.project, branch);
10891
+ if (!changes) {
10892
+ return { status: "unknown", reason: "The forge response was malformed." };
10893
+ }
10894
+ forgeChanges.push(...changes);
10895
+ } catch {
10896
+ return { status: "unknown", reason: "The forge response was malformed." };
10897
+ }
10898
+ const mergedExact = forgeChanges.find(
10899
+ (change) => change.merged && change.targetBranch === targetBranch && change.sourceHeadSha.toLowerCase() === snapshot.head?.toLowerCase() && change.sourceHeadSha.toLowerCase() === sourceRemoteSha.toLowerCase()
10900
+ );
10901
+ if (mergedExact)
10902
+ return {
10903
+ status: "merged-exact",
10904
+ headSha: snapshot.head,
10905
+ reason: "Merged source head exactly matches current HEAD."
10906
+ };
10907
+ const worktreePath = snapshot.worktree.path;
10908
+ const ancestry = commandRunner("git", [
10909
+ "-C",
10910
+ worktreePath,
10911
+ "merge-base",
10912
+ "--is-ancestor",
10913
+ snapshot.head,
10914
+ remoteTargetSha
10915
+ ]);
10916
+ if (ancestry.status === 0 && !ancestry.error) {
10917
+ return {
10918
+ status: "on-target",
10919
+ headSha: snapshot.head,
10920
+ reason: "Current HEAD is an ancestor of the verified-fresh origin target."
10921
+ };
10922
+ }
10923
+ if (ancestry.status !== 1 || ancestry.error) {
10924
+ return { status: "unknown", reason: "Target ancestry could not be verified." };
10925
+ }
10926
+ for (const change of forgeChanges.filter(
10927
+ (candidate) => candidate.merged && candidate.targetBranch === targetBranch && candidate.sourceHeadSha.toLowerCase() !== snapshot.head?.toLowerCase()
10928
+ )) {
10929
+ if (change.sourceHeadSha.toLowerCase() !== sourceRemoteSha?.toLowerCase()) continue;
10930
+ if (change.baseSha && change.mergeCommitSha && hasVerifiedCommonBase(worktreePath, change.baseSha, snapshot.head, commandRunner) && hasVerifiedCommonBase(worktreePath, change.baseSha, change.mergeCommitSha, commandRunner) && hasUniqueSourceCommit(worktreePath, change.baseSha, snapshot.head, commandRunner)) {
10931
+ const currentPatchId = patchIdForRange(
10932
+ worktreePath,
10933
+ change.baseSha,
10934
+ snapshot.head,
10935
+ commandRunner
10936
+ );
10937
+ const mergedPatchId = patchIdForRange(
10938
+ worktreePath,
10939
+ change.baseSha,
10940
+ change.mergeCommitSha,
10941
+ commandRunner
10942
+ );
10943
+ if (!currentPatchId || !mergedPatchId || currentPatchId !== mergedPatchId) continue;
10944
+ return {
10945
+ status: "patch-equivalent",
10946
+ headSha: snapshot.head,
10947
+ reason: "All source changes have an equivalent merged patch."
10948
+ };
10949
+ }
10950
+ }
10951
+ return {
10952
+ status: "not-verified",
10953
+ reason: "Fresh target and forge evidence did not prove integration."
10954
+ };
10955
+ }
10956
+ function routeStatus(record, routes) {
10957
+ if (!routes) return "unknown";
10958
+ const matchingPath = routes.filter(
10959
+ (route) => sameWorkspacePath(route.repoPath, record.worktreePath)
10960
+ );
10961
+ if (matchingPath.length === 0) return "absent";
10962
+ return matchingPath.every((route) => route.workspace === record.workspace) ? "owned" : "conflict";
10963
+ }
10964
+ function buildActivityEvidence(record, providerStatus, provider, routeEntries, git) {
10965
+ const providerEvidence = providerStatus === "unknown" || providerStatus === "conflict" ? { source: "devpod.lastUsed", status: "unavailable" } : providerStatus === "absent" ? { source: "devpod.lastUsed", status: "not-applicable" } : provider?.lastUsedMalformed ? { source: "devpod.lastUsed", status: "malformed" } : provider?.lastUsed ? validTimestamp(provider.lastUsed) ? { source: "devpod.lastUsed", status: "valid", timestamp: provider.lastUsed } : { source: "devpod.lastUsed", status: "malformed" } : { source: "devpod.lastUsed", status: "missing" };
10966
+ const routeTimestamps = routeEntries?.map((route) => route.updatedAt) ?? [];
10967
+ const malformedRoute = routeTimestamps.some((timestamp) => !validTimestamp(timestamp));
10968
+ const newestRoute = routeTimestamps.filter(validTimestamp).sort((left, right) => (parseTimestamp(right) ?? 0) - (parseTimestamp(left) ?? 0))[0];
10969
+ const routeEvidence = !routeEntries ? { source: "route.updatedAt", status: "unavailable" } : routeEntries.length === 0 ? { source: "route.updatedAt", status: "not-applicable" } : malformedRoute ? { source: "route.updatedAt", status: "malformed" } : newestRoute ? { source: "route.updatedAt", status: "valid", timestamp: newestRoute } : { source: "route.updatedAt", status: "malformed" };
10970
+ const ownerEvidence = validTimestamp(record.updatedAt) ? { source: "ownership.updatedAt", status: "valid", timestamp: record.updatedAt } : { source: "ownership.updatedAt", status: "malformed" };
10971
+ const gitEvidence = git.checkout === "missing" ? { source: "git.headCommitterDate", status: "not-applicable" } : git.committerDate && validTimestamp(git.committerDate) ? { source: "git.headCommitterDate", status: "valid", timestamp: git.committerDate } : git.checkout === "unknown" ? { source: "git.headCommitterDate", status: "unavailable" } : { source: "git.headCommitterDate", status: "malformed" };
10972
+ return [providerEvidence, routeEvidence, ownerEvidence, gitEvidence];
10973
+ }
10974
+ function buildSuggestions(repoPath, record, ownership, provider, runtime, checkout, route, activity, integration, worktree, checkMerged) {
10975
+ const reasons = [];
10976
+ const eligibleActions = [];
10977
+ const suggestions = [];
10978
+ const gcCommand = workspaceCommand(repoPath, ["gc"], [], ["--yes"]);
10979
+ const keepCommand = workspaceCommand(repoPath, ["down", record.workspace], ["--keep-worktree"]);
10980
+ const downCommand = workspaceCommand(repoPath, ["down", record.workspace]);
10981
+ const routeSafe = route === "owned" || route === "absent";
10982
+ const checkoutSafe = checkout === "clean" && !worktree?.locked;
10983
+ const providerSafe = provider === "owned";
10984
+ const runtimeSafe = runtime === "running" || runtime === "stopped" || runtime === "not-found";
10985
+ if (ownership === "missing") {
10986
+ if ((provider === "absent" || provider === "owned" && runtimeSafe) && routeSafe) {
10987
+ eligibleActions.push(gcCommand);
10988
+ suggestions.push({
10989
+ command: gcCommand,
10990
+ reason: "The exact ownership record is missing from live Git registration; GC will revalidate before deleting eligible runtime evidence."
10991
+ });
10992
+ } else {
10993
+ reasons.push(
10994
+ `GC suggestion suppressed because provider=${provider}, runtime=${runtime}, or route=${route} is not independently safe.`
10995
+ );
10996
+ }
10997
+ return { eligibleActions, suggestions, reasons };
10998
+ }
10999
+ if (ownership !== "present")
11000
+ reasons.push(`Destructive suggestions require ownership=present (found ${ownership}).`);
11001
+ if (!providerSafe)
11002
+ reasons.push(
11003
+ `Destructive suggestions require an exact owned DevPod (found provider=${provider}).`
11004
+ );
11005
+ if (!runtimeSafe)
11006
+ reasons.push(
11007
+ `Destructive suggestions require an actionable DevPod runtime (found runtime=${runtime}).`
11008
+ );
11009
+ if (!routeSafe)
11010
+ reasons.push(
11011
+ `Destructive suggestions require non-conflicting route evidence (found route=${route}).`
11012
+ );
11013
+ if (checkout === "dirty")
11014
+ reasons.push("Checkout is dirty; destructive workspace down is blocked.");
11015
+ if (checkout === "missing")
11016
+ reasons.push("Checkout is missing; the report cannot authorize full down.");
11017
+ if (checkout === "detached")
11018
+ reasons.push("Checkout is detached; branch identity is not safe for destructive advice.");
11019
+ if (checkout === "unknown")
11020
+ reasons.push("Checkout state is unknown; destructive advice is suppressed.");
11021
+ if (worktree?.locked)
11022
+ reasons.push("Git worktree is locked; destructive workspace down is blocked.");
11023
+ if (!providerSafe || !runtimeSafe || !routeSafe || !checkoutSafe || ownership !== "present") {
11024
+ return { eligibleActions, suggestions, reasons };
11025
+ }
11026
+ if (integration.status === "merged-exact" && integration.headSha) {
11027
+ eligibleActions.push(downCommand);
11028
+ suggestions.push({
11029
+ command: downCommand,
11030
+ reason: "Current HEAD is the exact source head of a same-repository merged change; workspace down still revalidates cleanliness, locks, and ownership before deleting runtime and the linked worktree."
11031
+ });
11032
+ return { eligibleActions, suggestions, reasons };
11033
+ }
11034
+ if (integration.status === "patch-equivalent")
11035
+ reasons.push("Patch-equivalent integration is advisory and never authorizes full removal.");
11036
+ if (integration.status === "unknown" || integration.status === "not-verified")
11037
+ reasons.push(`Integration is ${integration.status}; full removal is not suggested.`);
11038
+ const activityCleanupAllowed = !checkMerged || integration.status === "on-target" || integration.status === "merged-exact";
11039
+ if (!activityCleanupAllowed) {
11040
+ reasons.push(
11041
+ "Cleanup is not suggested because the requested integration check did not provide a verified target or exact merge."
11042
+ );
11043
+ return { eligibleActions, suggestions, reasons };
11044
+ }
11045
+ if (activity === "quiet") {
11046
+ eligibleActions.push(keepCommand);
11047
+ suggestions.push({
11048
+ command: keepCommand,
11049
+ reason: "Managed workspace has no recent trustworthy activity; this deletes DevPod/runtime data and preserves the worktree and owner record."
11050
+ });
11051
+ } else if (activity === "recent") {
11052
+ reasons.push("Recent trustworthy activity vetoes a quiet-workspace suggestion.");
11053
+ } else {
11054
+ reasons.push("Activity is unknown; a quiet-workspace suggestion is suppressed.");
11055
+ }
11056
+ return { eligibleActions, suggestions, reasons };
11057
+ }
11058
+ function buildRow(repoPath, record, worktrees, devpods, routes, snapshot, cutoff, checkMerged, commandRunner, inspectOwnershipFn, inspectIntegrationFn, inspectRuntimeFn) {
11059
+ const ownershipEvidence = inspectOwnershipFn(record, worktrees, devpods);
11060
+ const providerOwnership = devpods?.find(
11061
+ (devpod) => devpod.id === record.devpodId && sameWorkspacePath(devpod.source.localFolder, record.worktreePath)
11062
+ );
11063
+ const matchingRoutes = routes?.filter(
11064
+ (route) => sameWorkspacePath(route.repoPath, record.worktreePath)
11065
+ );
11066
+ const activity = evaluateWorkspaceActivity(
11067
+ buildActivityEvidence(
11068
+ record,
11069
+ ownershipEvidence.devpodStatus,
11070
+ providerOwnership,
11071
+ matchingRoutes,
11072
+ snapshot
11073
+ ),
11074
+ cutoff
11075
+ );
11076
+ const branch = snapshot.checkout === "detached" ? null : snapshot.worktree.branch ?? record.branch;
11077
+ const integration = inspectIntegrationFn?.(repoPath, snapshot, branch, checkMerged) ?? inspectWorkspaceIntegration(repoPath, snapshot, branch, checkMerged, commandRunner);
11078
+ const runtime = ownershipEvidence.devpodStatus === "absent" ? "absent" : ownershipEvidence.devpodStatus === "owned" ? (() => {
11079
+ try {
11080
+ return inspectRuntimeFn(record.devpodId);
11081
+ } catch {
11082
+ return "unknown";
11083
+ }
11084
+ })() : "unknown";
11085
+ const suggestions = buildSuggestions(
11086
+ repoPath,
11087
+ record,
11088
+ ownershipEvidence.ownerStatus,
11089
+ ownershipEvidence.devpodStatus,
11090
+ runtime,
11091
+ snapshot.checkout,
11092
+ routeStatus(record, routes),
11093
+ activity.status,
11094
+ integration,
11095
+ snapshot.worktree,
11096
+ checkMerged
11097
+ );
11098
+ const reasons = [
11099
+ `ownership=${ownershipEvidence.ownerStatus}`,
11100
+ `provider=${ownershipEvidence.devpodStatus}`,
11101
+ `runtime=${runtime}`,
11102
+ `checkout=${snapshot.checkout}`,
11103
+ `route=${routeStatus(record, routes)}`,
11104
+ `activity=${activity.status}`,
11105
+ `integration=${integration.status}`,
11106
+ ...integration.reason ? [integration.reason] : [],
11107
+ ...suggestions.reasons
11108
+ ];
11109
+ return {
11110
+ schemaVersion: 2,
11111
+ workspace: record.workspace,
11112
+ branch,
11113
+ repo: repoPath,
11114
+ worktreePath: record.worktreePath,
11115
+ devpodId: record.devpodId,
11116
+ ownership: ownershipEvidence.ownerStatus,
11117
+ provider: ownershipEvidence.devpodStatus,
11118
+ runtime,
11119
+ checkout: snapshot.checkout,
11120
+ route: routeStatus(record, routes),
11121
+ activity: activity.status,
11122
+ cutoff,
11123
+ latestTimestamp: activity.latestTimestamp,
11124
+ contributingEvidence: activity.contributingEvidence,
11125
+ activityEvidence: activity.evidence,
11126
+ integration: integration.status,
11127
+ eligibleActions: suggestions.eligibleActions,
11128
+ suggestions: suggestions.suggestions,
11129
+ reasons: Array.from(new Set(reasons))
11130
+ };
11131
+ }
11132
+ function deriveReclaimable(worktree, containerWritable) {
11133
+ if (worktree.status === "unknown") {
11134
+ return worktree;
11135
+ }
11136
+ if (containerWritable.status === "unknown") {
11137
+ return containerWritable;
11138
+ }
11139
+ return { status: "measured", bytes: worktree.bytes + containerWritable.bytes };
11140
+ }
11141
+ function collectConsumption(worktreePath, measureWorktreeFn, containers) {
11142
+ let worktree;
11143
+ try {
11144
+ worktree = measureWorktreeFn(worktreePath);
11145
+ } catch (error) {
11146
+ worktree = {
11147
+ status: "unknown",
11148
+ reason: `worktree measurement failed: ${describeCause(error)}`
11149
+ };
11150
+ }
11151
+ const docker = containers.get(worktreePath) ?? unknownContainers("attribution not collected");
11152
+ return {
11153
+ worktree,
11154
+ containerWritable: docker.containerWritable,
11155
+ imageShared: docker.imageShared,
11156
+ reclaimable: deriveReclaimable(worktree, docker.containerWritable)
11157
+ };
11158
+ }
11159
+ function unknownContainers(reason) {
11160
+ return {
11161
+ containerWritable: { status: "unknown", reason },
11162
+ imageShared: { status: "unknown", reason }
11163
+ };
11164
+ }
11165
+ function describeCause(error) {
11166
+ return error instanceof Error ? error.message : String(error);
11167
+ }
11168
+ function buildWorkspaceCleanupReport(options = {}, dependencies = {}) {
11169
+ const repoPath = resolveRepoPath(options.repo);
11170
+ const commandRunner = dependencies.commandRunner ?? defaultCommandRunner;
11171
+ const duration = parseInactiveFor(options.inactiveFor);
11172
+ const now = options.now ?? /* @__PURE__ */ new Date();
11173
+ const cutoff = new Date(now.getTime() - duration.seconds * 1e3).toISOString();
11174
+ const worktrees = (dependencies.listWorktrees ?? listGitWorktrees)(repoPath);
11175
+ const records = (dependencies.listOwnership ?? listWorkspaceOwnership)(repoPath);
11176
+ let devpods;
11177
+ try {
11178
+ devpods = (dependencies.listDevpods ?? listDevpodWorkspaces)();
11179
+ } catch {
11180
+ devpods = void 0;
11181
+ }
11182
+ let routes;
11183
+ try {
11184
+ routes = (dependencies.readRoutes ?? readHostRouteStateReadOnly)();
11185
+ } catch {
11186
+ routes = void 0;
11187
+ }
11188
+ const measureSize = Boolean(options.measureSize);
11189
+ const measureWorktreeFn = dependencies.measureWorktree ?? measureWorktreeConsumption;
11190
+ const measureContainersFn = dependencies.measureContainers ?? measureContainerConsumption;
11191
+ let containers = /* @__PURE__ */ new Map();
11192
+ if (measureSize) {
11193
+ const worktreePaths = records.map((record) => record.worktreePath);
11194
+ try {
11195
+ containers = measureContainersFn(worktreePaths);
11196
+ } catch (error) {
11197
+ const reason = `container measurement failed: ${describeCause(error)}`;
11198
+ containers = new Map(worktreePaths.map((path24) => [path24, unknownContainers(reason)]));
11199
+ }
11200
+ }
11201
+ const rows = records.map((record) => {
11202
+ const worktree = worktrees.find(
11203
+ (candidate) => sameWorkspacePath(candidate.path, record.worktreePath)
11204
+ );
11205
+ const snapshot = (dependencies.readGitSnapshot ?? ((candidate) => readGitSnapshot(candidate, commandRunner)))(
11206
+ worktree ?? {
11207
+ path: record.worktreePath,
11208
+ branch: record.branch ?? void 0,
11209
+ locked: false,
11210
+ prunable: true
11211
+ }
11212
+ );
11213
+ const row = buildRow(
11214
+ repoPath,
11215
+ record,
11216
+ worktrees,
11217
+ devpods,
11218
+ routes,
11219
+ snapshot,
11220
+ cutoff,
11221
+ Boolean(options.checkMerged),
11222
+ commandRunner,
11223
+ dependencies.inspectOwnership ?? ((recordValue, worktreeValues, devpodValues) => {
11224
+ const status = inspectWorkspaceOwnership(recordValue, worktreeValues, devpodValues);
11225
+ return { ownerStatus: status.ownerStatus, devpodStatus: status.devpodStatus };
11226
+ }),
11227
+ dependencies.inspectIntegration,
11228
+ dependencies.inspectDevpodRuntime ?? inspectDevpodRuntimeStatus
11229
+ );
11230
+ return measureSize ? {
11231
+ ...row,
11232
+ consumption: collectConsumption(record.worktreePath, measureWorktreeFn, containers)
11233
+ } : row;
11234
+ }).sort((left, right) => left.workspace.localeCompare(right.workspace));
11235
+ return {
11236
+ schemaVersion: 2,
11237
+ generatedAt: now.toISOString(),
11238
+ repoPath,
11239
+ inactiveFor: duration.input,
11240
+ cutoff,
11241
+ checkMerged: Boolean(options.checkMerged),
11242
+ measureSize,
11243
+ workspaces: rows
11244
+ };
11245
+ }
11246
+ var import_node_child_process21, import_node_fs25, DEFAULT_INACTIVE_FOR, READ_ONLY_GIT_ENV2, ACTIVITY_SOURCES, defaultCommandRunner;
11247
+ var init_workspace_cleanup = __esm({
11248
+ "src/core/workspace-cleanup.ts"() {
11249
+ "use strict";
11250
+ import_node_child_process21 = require("child_process");
11251
+ import_node_fs25 = __toESM(require("fs"));
11252
+ init_devpod_workspaces();
11253
+ init_host_routes();
11254
+ init_repo_config();
11255
+ init_workspace();
11256
+ init_workspace_consumption();
11257
+ init_workspace_ownership();
11258
+ DEFAULT_INACTIVE_FOR = "30d";
11259
+ READ_ONLY_GIT_ENV2 = { ...process.env, GIT_OPTIONAL_LOCKS: "0", LC_ALL: "C" };
11260
+ ACTIVITY_SOURCES = [
11261
+ "devpod.lastUsed",
11262
+ "route.updatedAt",
11263
+ "ownership.updatedAt",
11264
+ "git.headCommitterDate"
11265
+ ];
11266
+ defaultCommandRunner = (command, args, input2) => {
11267
+ const environment = command === "git" ? READ_ONLY_GIT_ENV2 : { ...process.env, LC_ALL: "C" };
11268
+ const result = (0, import_node_child_process21.spawnSync)(command, args, {
11269
+ encoding: "utf-8",
11270
+ env: environment,
11271
+ ...input2 === void 0 ? {} : { input: input2 }
11272
+ });
11273
+ return {
11274
+ status: result.status,
11275
+ stdout: result.stdout ?? "",
11276
+ stderr: result.stderr ?? "",
11277
+ ...result.error ? { error: result.error } : {}
11278
+ };
11279
+ };
11280
+ }
11281
+ });
11282
+
10257
11283
  // src/commands/workspace.ts
10258
11284
  var workspace_exports = {};
10259
11285
  __export(workspace_exports, {
11286
+ runWorkspaceCleanupCommand: () => runWorkspaceCleanupCommand,
10260
11287
  runWorkspaceDownCommand: () => runWorkspaceDownCommand,
10261
11288
  runWorkspaceGcCommand: () => runWorkspaceGcCommand,
10262
11289
  runWorkspaceLsCommand: () => runWorkspaceLsCommand,
@@ -10304,6 +11331,15 @@ function runWorkspaceLsCommand(options) {
10304
11331
  );
10305
11332
  }
10306
11333
  }
11334
+ function runWorkspaceCleanupCommand(options) {
11335
+ const repoPath = resolveGitWorkspaceRepo(options.repo);
11336
+ const report = buildWorkspaceCleanupReport({ ...options, repo: repoPath });
11337
+ if (options.json) {
11338
+ printJSON(report);
11339
+ return;
11340
+ }
11341
+ printWorkspaceCleanupReport(report);
11342
+ }
10307
11343
  async function runWorkspaceDownCommand(target, options) {
10308
11344
  const repoPath = resolveGitWorkspaceRepo(options.repo);
10309
11345
  await workspaceDown(target, {
@@ -10342,7 +11378,9 @@ function runWorkspaceGcCommand(options) {
10342
11378
  var init_workspace2 = __esm({
10343
11379
  "src/commands/workspace.ts"() {
10344
11380
  "use strict";
11381
+ init_output();
10345
11382
  init_repo_config();
11383
+ init_workspace_cleanup();
10346
11384
  init_workspace_gc();
10347
11385
  init_workspace_lifecycle();
10348
11386
  init_workspace_ownership();
@@ -10387,7 +11425,7 @@ var init_version = __esm({
10387
11425
 
10388
11426
  // src/cli.ts
10389
11427
  var import_commander = require("commander");
10390
- var CLI_VERSION = true ? "0.0.35" : "0.0.0-dev";
11428
+ var CLI_VERSION = true ? "0.0.37" : "0.0.0-dev";
10391
11429
  var VERSION_FLAGS = /* @__PURE__ */ new Set(["-V", "--version"]);
10392
11430
  function withErrorHandling(action2) {
10393
11431
  return async (...args) => {
@@ -10626,8 +11664,8 @@ tlsCommand.command("install").description("Install mkcert certs and enable HTTPS
10626
11664
  await runTLSInstallCommand2();
10627
11665
  })
10628
11666
  );
10629
- var workspaceCommand = program.command("workspace").description("Spin up / list / tear down isolated worktree+devcontainer workspaces");
10630
- workspaceCommand.command("up").description(
11667
+ var workspaceCommand2 = program.command("workspace").description("Spin up / list / tear down isolated worktree+devcontainer workspaces");
11668
+ workspaceCommand2.command("up").description(
10631
11669
  "Create a worktree for <branch>, bring up its devpod, and register namespaced routes"
10632
11670
  ).argument("<branch>", "Git branch to base the workspace on").option("--path <dir>", "Worktree directory (default: <repo>/trees/<workspace>)").option("--no-devpod", "Only create the worktree; do not start the environment or change routes").option("--open", "Open the namespaced routes after registering").option("--repo <path>", "Main repository path (defaults to current directory)").action(
10633
11671
  withErrorHandling(async (branch, _options, command) => {
@@ -10641,7 +11679,7 @@ workspaceCommand.command("up").description(
10641
11679
  });
10642
11680
  })
10643
11681
  );
10644
- workspaceCommand.command("ensure").description("Start and prove a primary or linked checkout's DevPod, upstreams, and routes").argument("[path]", "Git checkout path (defaults to current directory)").option("--open", "Open HTTP routes after readiness succeeds").option("--json", "Output JSON").action(
11682
+ workspaceCommand2.command("ensure").description("Start and prove a primary or linked checkout's DevPod, upstreams, and routes").argument("[path]", "Git checkout path (defaults to current directory)").option("--open", "Open HTTP routes after readiness succeeds").option("--json", "Output JSON").action(
10645
11683
  withErrorHandling(
10646
11684
  async (worktreePath, _options, command) => {
10647
11685
  const options = command.opts();
@@ -10654,28 +11692,41 @@ workspaceCommand.command("ensure").description("Start and prove a primary or lin
10654
11692
  }
10655
11693
  )
10656
11694
  );
10657
- workspaceCommand.command("ls").description("List git worktrees with their workspace token and active route count").option("--repo <path>", "Main repository path (defaults to current directory)").option("--json", "Output JSON").action(
11695
+ workspaceCommand2.command("ls").description("List git worktrees with their workspace token and active route count").option("--repo <path>", "Main repository path (defaults to current directory)").option("--json", "Output JSON").action(
10658
11696
  withErrorHandling(async (_options, command) => {
10659
11697
  const options = command.opts();
10660
11698
  const { runWorkspaceLsCommand: runWorkspaceLsCommand2 } = await Promise.resolve().then(() => (init_workspace2(), workspace_exports));
10661
11699
  runWorkspaceLsCommand2(options);
10662
11700
  })
10663
11701
  );
10664
- workspaceCommand.command("gc").description("Report or clean missing ledger-owned workspace resources").option("--repo <path>", "Main repository path (defaults to current directory)").option("--json", "Output JSON").option("--yes", "Delete eligible DevPod, route, and ownership resources").action(
11702
+ workspaceCommand2.command("cleanup").description("Report managed workspace activity, identity, checkout, and integration evidence").option("--repo <path>", "Main repository path (defaults to current directory)").option("--inactive-for <duration>", "Inactive threshold using Ns, Nm, Nh, Nd, or Nw", "30d").option("--check-merged", "Enable read-only origin and GitHub/GitLab integration checks").option("--measure-size", "Measure per-workspace storage consumption (slower, still read-only)").option("--json", "Output the stable cleanup report as JSON").action(
11703
+ withErrorHandling(async (_options, command) => {
11704
+ const options = command.opts();
11705
+ const { runWorkspaceCleanupCommand: runWorkspaceCleanupCommand2 } = await Promise.resolve().then(() => (init_workspace2(), workspace_exports));
11706
+ runWorkspaceCleanupCommand2({
11707
+ repo: options.repo,
11708
+ inactiveFor: options.inactiveFor,
11709
+ checkMerged: Boolean(options.checkMerged),
11710
+ measureSize: Boolean(options.measureSize),
11711
+ json: Boolean(options.json)
11712
+ });
11713
+ })
11714
+ );
11715
+ workspaceCommand2.command("gc").description("Report or clean missing ledger-owned workspace resources").option("--repo <path>", "Main repository path (defaults to current directory)").option("--json", "Output JSON").option("--yes", "Delete eligible DevPod, route, and ownership resources").action(
10665
11716
  withErrorHandling(async (_options, command) => {
10666
11717
  const options = command.opts();
10667
11718
  const { runWorkspaceGcCommand: runWorkspaceGcCommand2 } = await Promise.resolve().then(() => (init_workspace2(), workspace_exports));
10668
11719
  runWorkspaceGcCommand2(options);
10669
11720
  })
10670
11721
  );
10671
- workspaceCommand.command("stop").description("Stop a workspace's DevPod and free routes while preserving its worktree and data").argument("<workspace>", "Workspace token or live branch name").option("--repo <path>", "Main repository path (defaults to current directory)").action(
11722
+ workspaceCommand2.command("stop").description("Stop a workspace's DevPod and free routes while preserving its worktree and data").argument("<workspace>", "Workspace token or live branch name").option("--repo <path>", "Main repository path (defaults to current directory)").action(
10672
11723
  withErrorHandling(async (target, _options, command) => {
10673
11724
  const options = command.opts();
10674
11725
  const { runWorkspaceStopCommand: runWorkspaceStopCommand2 } = await Promise.resolve().then(() => (init_workspace2(), workspace_exports));
10675
11726
  await runWorkspaceStopCommand2(target, options);
10676
11727
  })
10677
11728
  );
10678
- workspaceCommand.command("down").description("Delete a workspace's DevPod and routes, then remove its clean Git worktree").argument("<workspace>", "Workspace token or live branch name").option("--keep-worktree", "Delete runtime resources but preserve the Git worktree and record").option("--repo <path>", "Main repository path (defaults to current directory)").action(
11729
+ workspaceCommand2.command("down").description("Delete a workspace's DevPod and routes, then remove its clean Git worktree").argument("<workspace>", "Workspace token or live branch name").option("--keep-worktree", "Delete runtime resources but preserve the Git worktree and record").option("--repo <path>", "Main repository path (defaults to current directory)").action(
10679
11730
  withErrorHandling(async (target, _options, command) => {
10680
11731
  const options = command.opts();
10681
11732
  const { runWorkspaceDownCommand: runWorkspaceDownCommand2 } = await Promise.resolve().then(() => (init_workspace2(), workspace_exports));