@devrouter/cli 0.0.35 → 0.0.36
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 +1089 -39
- package/package.json +3 -1
- package/upgrade-prompts/0.0.36.md +31 -0
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**:
|
|
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.
|
|
2052
|
+
const cliVersion = true ? "0.0.36" : "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"
|
|
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
|
-
|
|
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 =
|
|
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
|
-
|
|
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
|
|
4648
|
-
|
|
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.
|
|
5797
|
+
const cliVersion = true ? "0.0.36" : "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
|
-
|
|
6198
|
-
|
|
6199
|
-
|
|
6200
|
-
|
|
6201
|
-
|
|
6202
|
-
|
|
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
|
|
6208
|
-
|
|
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
|
|
|
@@ -10254,9 +10442,847 @@ var init_tls2 = __esm({
|
|
|
10254
10442
|
}
|
|
10255
10443
|
});
|
|
10256
10444
|
|
|
10445
|
+
// src/core/workspace-consumption.ts
|
|
10446
|
+
function measureWorktreeConsumption(worktreePath, options) {
|
|
10447
|
+
const deadlineMs = options?.deadlineMs ?? DEFAULT_DEADLINE_MS;
|
|
10448
|
+
const startedAt = Date.now();
|
|
10449
|
+
let rootStat;
|
|
10450
|
+
try {
|
|
10451
|
+
rootStat = import_node_fs24.default.lstatSync(worktreePath);
|
|
10452
|
+
} catch (error) {
|
|
10453
|
+
return { status: "unknown", reason: describeError(error, worktreePath) };
|
|
10454
|
+
}
|
|
10455
|
+
let rootEntries;
|
|
10456
|
+
try {
|
|
10457
|
+
rootEntries = import_node_fs24.default.readdirSync(worktreePath, { withFileTypes: true });
|
|
10458
|
+
} catch (error) {
|
|
10459
|
+
return { status: "unknown", reason: describeError(error, worktreePath) };
|
|
10460
|
+
}
|
|
10461
|
+
const seenInodes = /* @__PURE__ */ new Set();
|
|
10462
|
+
let bytes = 0;
|
|
10463
|
+
let timedOut = false;
|
|
10464
|
+
let unreadableReason = null;
|
|
10465
|
+
const accumulate = (stat) => {
|
|
10466
|
+
if (stat.nlink > 1) {
|
|
10467
|
+
const key = `${stat.dev}:${stat.ino}`;
|
|
10468
|
+
if (seenInodes.has(key)) return;
|
|
10469
|
+
seenInodes.add(key);
|
|
10470
|
+
}
|
|
10471
|
+
bytes += stat.blocks * BLOCK_SIZE_BYTES;
|
|
10472
|
+
};
|
|
10473
|
+
const deadlineExceeded = () => Date.now() - startedAt >= deadlineMs;
|
|
10474
|
+
accumulate(rootStat);
|
|
10475
|
+
const stack = [{ dirPath: worktreePath, entries: rootEntries }];
|
|
10476
|
+
while (stack.length > 0 && !timedOut && !unreadableReason) {
|
|
10477
|
+
const { dirPath, entries } = stack.pop();
|
|
10478
|
+
for (const entry of entries) {
|
|
10479
|
+
if (deadlineExceeded()) {
|
|
10480
|
+
timedOut = true;
|
|
10481
|
+
break;
|
|
10482
|
+
}
|
|
10483
|
+
const entryPath = import_node_path24.default.join(dirPath, entry.name);
|
|
10484
|
+
let entryStat;
|
|
10485
|
+
try {
|
|
10486
|
+
entryStat = import_node_fs24.default.lstatSync(entryPath);
|
|
10487
|
+
} catch (error) {
|
|
10488
|
+
if (error?.code === "ENOENT") continue;
|
|
10489
|
+
unreadableReason = describeIncompleteWalk(error);
|
|
10490
|
+
break;
|
|
10491
|
+
}
|
|
10492
|
+
accumulate(entryStat);
|
|
10493
|
+
if (!entryStat.isDirectory()) continue;
|
|
10494
|
+
let childEntries;
|
|
10495
|
+
try {
|
|
10496
|
+
childEntries = import_node_fs24.default.readdirSync(entryPath, { withFileTypes: true });
|
|
10497
|
+
} catch (error) {
|
|
10498
|
+
unreadableReason = describeIncompleteWalk(error);
|
|
10499
|
+
break;
|
|
10500
|
+
}
|
|
10501
|
+
stack.push({ dirPath: entryPath, entries: childEntries });
|
|
10502
|
+
}
|
|
10503
|
+
}
|
|
10504
|
+
if (timedOut) {
|
|
10505
|
+
return { status: "unknown", reason: `exceeded deadline of ${deadlineMs}ms` };
|
|
10506
|
+
}
|
|
10507
|
+
if (unreadableReason) {
|
|
10508
|
+
return { status: "unknown", reason: unreadableReason };
|
|
10509
|
+
}
|
|
10510
|
+
return { status: "measured", bytes };
|
|
10511
|
+
}
|
|
10512
|
+
function describeIncompleteWalk(error) {
|
|
10513
|
+
return `could not read every path inside the worktree: ${error?.message ?? String(error)}`;
|
|
10514
|
+
}
|
|
10515
|
+
function measureContainerConsumption(worktreePaths, dependencies) {
|
|
10516
|
+
const byWorktree = /* @__PURE__ */ new Map();
|
|
10517
|
+
if (worktreePaths.length === 0) return byWorktree;
|
|
10518
|
+
const inspect = dependencies?.inspect ?? inspectWorkspaceContainers;
|
|
10519
|
+
const containers = inspect();
|
|
10520
|
+
const attributedIds = /* @__PURE__ */ new Set();
|
|
10521
|
+
for (const worktreePath of worktreePaths) {
|
|
10522
|
+
for (const container of workspaceAppContainers(containers, worktreePath)) {
|
|
10523
|
+
attributedIds.add(container.id);
|
|
10524
|
+
}
|
|
10525
|
+
}
|
|
10526
|
+
const sized = attributedIds.size === 0 ? [] : inspect({ withSize: true, ids: Array.from(attributedIds) });
|
|
10527
|
+
for (const worktreePath of worktreePaths) {
|
|
10528
|
+
byWorktree.set(worktreePath, summarizeContainers(workspaceAppContainers(sized, worktreePath)));
|
|
10529
|
+
}
|
|
10530
|
+
return byWorktree;
|
|
10531
|
+
}
|
|
10532
|
+
function summarizeContainers(containers) {
|
|
10533
|
+
let writable = 0;
|
|
10534
|
+
let shared = 0;
|
|
10535
|
+
for (const container of containers) {
|
|
10536
|
+
const { sizeRw, sizeRootFs } = container;
|
|
10537
|
+
if (typeof sizeRw !== "number" || typeof sizeRootFs !== "number" || !Number.isFinite(sizeRw) || !Number.isFinite(sizeRootFs) || sizeRootFs < sizeRw) {
|
|
10538
|
+
const unknown = {
|
|
10539
|
+
status: "unknown",
|
|
10540
|
+
reason: `container ${container.id.slice(0, 12)} reported no usable size`
|
|
10541
|
+
};
|
|
10542
|
+
return { containerWritable: unknown, imageShared: unknown };
|
|
10543
|
+
}
|
|
10544
|
+
writable += sizeRw;
|
|
10545
|
+
shared += sizeRootFs - sizeRw;
|
|
10546
|
+
}
|
|
10547
|
+
return {
|
|
10548
|
+
containerWritable: { status: "measured", bytes: writable },
|
|
10549
|
+
imageShared: { status: "measured", bytes: shared }
|
|
10550
|
+
};
|
|
10551
|
+
}
|
|
10552
|
+
function describeError(error, worktreePath) {
|
|
10553
|
+
const code = error?.code;
|
|
10554
|
+
if (code === "ENOENT") return `worktree path '${worktreePath}' does not exist`;
|
|
10555
|
+
if (code === "EACCES" || code === "EPERM") {
|
|
10556
|
+
return `permission denied reading worktree path '${worktreePath}'`;
|
|
10557
|
+
}
|
|
10558
|
+
return `could not stat worktree path '${worktreePath}': ${error?.message ?? String(error)}`;
|
|
10559
|
+
}
|
|
10560
|
+
var import_node_fs24, import_node_path24, DEFAULT_DEADLINE_MS, BLOCK_SIZE_BYTES;
|
|
10561
|
+
var init_workspace_consumption = __esm({
|
|
10562
|
+
"src/core/workspace-consumption.ts"() {
|
|
10563
|
+
"use strict";
|
|
10564
|
+
import_node_fs24 = __toESM(require("fs"));
|
|
10565
|
+
import_node_path24 = __toESM(require("path"));
|
|
10566
|
+
init_devpod_environment();
|
|
10567
|
+
DEFAULT_DEADLINE_MS = 1e4;
|
|
10568
|
+
BLOCK_SIZE_BYTES = 512;
|
|
10569
|
+
}
|
|
10570
|
+
});
|
|
10571
|
+
|
|
10572
|
+
// src/core/workspace-cleanup.ts
|
|
10573
|
+
function isRecord2(value) {
|
|
10574
|
+
return value !== null && typeof value === "object" && !Array.isArray(value);
|
|
10575
|
+
}
|
|
10576
|
+
function isSha(value) {
|
|
10577
|
+
return typeof value === "string" && /^[0-9a-f]{40,64}$/i.test(value);
|
|
10578
|
+
}
|
|
10579
|
+
function parseTimestamp(value) {
|
|
10580
|
+
if (typeof value !== "string") return void 0;
|
|
10581
|
+
const timestamp = Date.parse(value);
|
|
10582
|
+
return Number.isFinite(timestamp) ? timestamp : void 0;
|
|
10583
|
+
}
|
|
10584
|
+
function validTimestamp(value) {
|
|
10585
|
+
return parseTimestamp(value) !== void 0;
|
|
10586
|
+
}
|
|
10587
|
+
function successfulOutput(result) {
|
|
10588
|
+
if (result.status !== 0 || result.error) return void 0;
|
|
10589
|
+
const output2 = result.stdout.trim();
|
|
10590
|
+
return output2.length > 0 ? output2 : void 0;
|
|
10591
|
+
}
|
|
10592
|
+
function gitOutput(repoPath, args, commandRunner) {
|
|
10593
|
+
return successfulOutput(commandRunner("git", ["-C", repoPath, ...args]));
|
|
10594
|
+
}
|
|
10595
|
+
function quoteCommandArg(value) {
|
|
10596
|
+
return /^[a-zA-Z0-9_./:@+-]+$/.test(value) ? value : `'${value.replaceAll("'", "'\\''")}'`;
|
|
10597
|
+
}
|
|
10598
|
+
function workspaceCommand(repoPath, args, beforeRepo = [], afterRepo = []) {
|
|
10599
|
+
return [
|
|
10600
|
+
"devrouter",
|
|
10601
|
+
"workspace",
|
|
10602
|
+
...args,
|
|
10603
|
+
...beforeRepo,
|
|
10604
|
+
"--repo",
|
|
10605
|
+
quoteCommandArg(repoPath),
|
|
10606
|
+
...afterRepo
|
|
10607
|
+
].join(" ");
|
|
10608
|
+
}
|
|
10609
|
+
function parseInactiveFor(value = DEFAULT_INACTIVE_FOR) {
|
|
10610
|
+
const match = /^(?<amount>[1-9]\d*)(?<unit>[smhdw])$/.exec(value);
|
|
10611
|
+
if (!match?.groups) {
|
|
10612
|
+
throw new Error(
|
|
10613
|
+
"--inactive-for must be a positive integer followed by s, m, h, d, or w (for example 30d)."
|
|
10614
|
+
);
|
|
10615
|
+
}
|
|
10616
|
+
const amount = Number(match.groups.amount);
|
|
10617
|
+
const multiplier = { s: 1, m: 60, h: 3600, d: 86400, w: 604800 }[match.groups.unit];
|
|
10618
|
+
const seconds = amount * multiplier;
|
|
10619
|
+
if (!Number.isSafeInteger(seconds)) {
|
|
10620
|
+
throw new Error("--inactive-for is too large.");
|
|
10621
|
+
}
|
|
10622
|
+
return { input: value, seconds };
|
|
10623
|
+
}
|
|
10624
|
+
function evaluateWorkspaceActivity(evidence, cutoff) {
|
|
10625
|
+
const cutoffTime = parseTimestamp(cutoff);
|
|
10626
|
+
if (cutoffTime === void 0) {
|
|
10627
|
+
throw new Error(`Invalid activity cutoff '${cutoff}'.`);
|
|
10628
|
+
}
|
|
10629
|
+
const validEvidence = evidence.filter(
|
|
10630
|
+
(entry) => entry.status === "valid" && validTimestamp(entry.timestamp)
|
|
10631
|
+
);
|
|
10632
|
+
const latestTime = validEvidence.reduce(
|
|
10633
|
+
(latest, entry) => Math.max(
|
|
10634
|
+
latest ?? Number.NEGATIVE_INFINITY,
|
|
10635
|
+
parseTimestamp(entry.timestamp) ?? Number.NEGATIVE_INFINITY
|
|
10636
|
+
),
|
|
10637
|
+
void 0
|
|
10638
|
+
);
|
|
10639
|
+
const latestTimestamp = latestTime === void 0 ? null : validEvidence.find((entry) => parseTimestamp(entry.timestamp) === latestTime)?.timestamp ?? null;
|
|
10640
|
+
const contributingEvidence = ACTIVITY_SOURCES.filter(
|
|
10641
|
+
(source) => validEvidence.some(
|
|
10642
|
+
(entry) => entry.source === source && parseTimestamp(entry.timestamp) === latestTime
|
|
10643
|
+
)
|
|
10644
|
+
);
|
|
10645
|
+
let status = "unknown";
|
|
10646
|
+
if (latestTime !== void 0) {
|
|
10647
|
+
if (latestTime >= cutoffTime) {
|
|
10648
|
+
status = "recent";
|
|
10649
|
+
} else if (validEvidence.length > 0 && evidence.every(
|
|
10650
|
+
(entry) => entry.status === "valid" || entry.status === "not-applicable" || entry.status === "missing"
|
|
10651
|
+
)) {
|
|
10652
|
+
status = "quiet";
|
|
10653
|
+
}
|
|
10654
|
+
}
|
|
10655
|
+
return {
|
|
10656
|
+
status,
|
|
10657
|
+
latestTimestamp,
|
|
10658
|
+
contributingEvidence,
|
|
10659
|
+
evidence: evidence.slice().sort(
|
|
10660
|
+
(left, right) => ACTIVITY_SOURCES.indexOf(left.source) - ACTIVITY_SOURCES.indexOf(right.source)
|
|
10661
|
+
)
|
|
10662
|
+
};
|
|
10663
|
+
}
|
|
10664
|
+
function readGitSnapshot(worktree, commandRunner) {
|
|
10665
|
+
const comparablePath = comparableWorkspacePath(worktree.path);
|
|
10666
|
+
if (worktree.prunable || !import_node_fs25.default.existsSync(comparablePath)) {
|
|
10667
|
+
return { worktree, checkout: "missing", head: null, committerDate: null };
|
|
10668
|
+
}
|
|
10669
|
+
const head = gitOutput(comparablePath, ["rev-parse", "--verify", "HEAD"], commandRunner);
|
|
10670
|
+
if (!head) {
|
|
10671
|
+
return { worktree, checkout: "unknown", head: null, committerDate: null };
|
|
10672
|
+
}
|
|
10673
|
+
const committerDate = gitOutput(comparablePath, ["show", "-s", "--format=%cI", "HEAD"], commandRunner) ?? null;
|
|
10674
|
+
const statusResult = commandRunner("git", [
|
|
10675
|
+
"-C",
|
|
10676
|
+
comparablePath,
|
|
10677
|
+
"status",
|
|
10678
|
+
"--porcelain=v1",
|
|
10679
|
+
"--untracked-files=normal"
|
|
10680
|
+
]);
|
|
10681
|
+
const checkout = worktree.branch === void 0 ? "detached" : statusResult.status !== 0 || statusResult.error ? "unknown" : statusResult.stdout.trim().length > 0 ? "dirty" : "clean";
|
|
10682
|
+
return { worktree, checkout, head, committerDate };
|
|
10683
|
+
}
|
|
10684
|
+
function parseRemoteIdentity(value) {
|
|
10685
|
+
const normalized = value.trim();
|
|
10686
|
+
let host;
|
|
10687
|
+
let project;
|
|
10688
|
+
const scp = /^git@([^:]+):(.+)$/.exec(normalized);
|
|
10689
|
+
if (scp) {
|
|
10690
|
+
host = scp[1];
|
|
10691
|
+
project = scp[2];
|
|
10692
|
+
} else {
|
|
10693
|
+
try {
|
|
10694
|
+
const url = new URL(normalized);
|
|
10695
|
+
if (url.protocol !== "https:" && url.protocol !== "ssh:") return void 0;
|
|
10696
|
+
host = url.hostname;
|
|
10697
|
+
project = url.pathname.replace(/^\/+/, "");
|
|
10698
|
+
} catch {
|
|
10699
|
+
return void 0;
|
|
10700
|
+
}
|
|
10701
|
+
}
|
|
10702
|
+
project = project.replace(/\.git$/, "").replace(/\/+$/, "");
|
|
10703
|
+
if (!host || !project || project.includes("..") || project.includes(" ")) return void 0;
|
|
10704
|
+
const lowerHost = host.toLowerCase();
|
|
10705
|
+
if (lowerHost === "github.com" && /^[^/]+\/[^/]+$/.test(project)) {
|
|
10706
|
+
return { provider: "github", host: lowerHost, project };
|
|
10707
|
+
}
|
|
10708
|
+
if (lowerHost === "gitlab.com") {
|
|
10709
|
+
return { provider: "gitlab", host: lowerHost, project };
|
|
10710
|
+
}
|
|
10711
|
+
return void 0;
|
|
10712
|
+
}
|
|
10713
|
+
function parseGitHubChanges(value, project, branch) {
|
|
10714
|
+
if (!Array.isArray(value)) return void 0;
|
|
10715
|
+
const changes = [];
|
|
10716
|
+
for (const item of value) {
|
|
10717
|
+
if (!isRecord2(item)) return void 0;
|
|
10718
|
+
const repository = isRecord2(item.repository) ? item.repository.nameWithOwner : void 0;
|
|
10719
|
+
const headRepository = isRecord2(item.headRepository) ? item.headRepository.nameWithOwner : void 0;
|
|
10720
|
+
if (repository !== project || headRepository !== project || item.headRefName !== branch || !isSha(item.headRefOid) || typeof item.baseRefName !== "string") {
|
|
10721
|
+
continue;
|
|
10722
|
+
}
|
|
10723
|
+
const merged = item.state === "MERGED" && validTimestamp(item.mergedAt);
|
|
10724
|
+
const mergeCommit = isRecord2(item.mergeCommit) && isSha(item.mergeCommit.oid) ? item.mergeCommit.oid : void 0;
|
|
10725
|
+
const baseSha = isSha(item.baseRefOid) ? item.baseRefOid : void 0;
|
|
10726
|
+
changes.push({
|
|
10727
|
+
sourceBranch: branch,
|
|
10728
|
+
sourceHeadSha: item.headRefOid,
|
|
10729
|
+
targetBranch: item.baseRefName,
|
|
10730
|
+
...baseSha ? { baseSha } : {},
|
|
10731
|
+
...mergeCommit ? { mergeCommitSha: mergeCommit } : {},
|
|
10732
|
+
merged
|
|
10733
|
+
});
|
|
10734
|
+
}
|
|
10735
|
+
return changes;
|
|
10736
|
+
}
|
|
10737
|
+
function parseGitLabChanges(value, _project, branch) {
|
|
10738
|
+
if (!Array.isArray(value)) return void 0;
|
|
10739
|
+
const changes = [];
|
|
10740
|
+
for (const item of value) {
|
|
10741
|
+
if (!isRecord2(item)) return void 0;
|
|
10742
|
+
const sourceProjectId = item.source_project_id;
|
|
10743
|
+
const targetProjectId = item.target_project_id;
|
|
10744
|
+
const sameProject = typeof sourceProjectId === "number" && typeof targetProjectId === "number" && sourceProjectId === targetProjectId;
|
|
10745
|
+
if (!sameProject || item.source_branch !== branch || !isSha(item.sha) || typeof item.target_branch !== "string") {
|
|
10746
|
+
continue;
|
|
10747
|
+
}
|
|
10748
|
+
const diffRefs = isRecord2(item.diff_refs) ? item.diff_refs : void 0;
|
|
10749
|
+
const mergeCommitSha = isSha(item.merge_commit_sha) ? item.merge_commit_sha : void 0;
|
|
10750
|
+
const baseSha = diffRefs && isSha(diffRefs.base_sha) ? diffRefs.base_sha : void 0;
|
|
10751
|
+
changes.push({
|
|
10752
|
+
sourceBranch: branch,
|
|
10753
|
+
sourceHeadSha: item.sha,
|
|
10754
|
+
targetBranch: item.target_branch,
|
|
10755
|
+
...baseSha ? { baseSha } : {},
|
|
10756
|
+
...mergeCommitSha ? { mergeCommitSha } : {},
|
|
10757
|
+
merged: item.state === "merged" && validTimestamp(item.merged_at)
|
|
10758
|
+
});
|
|
10759
|
+
}
|
|
10760
|
+
return changes;
|
|
10761
|
+
}
|
|
10762
|
+
function patchIdForRange(repoPath, baseSha, headSha, commandRunner) {
|
|
10763
|
+
const diff = commandRunner("git", [
|
|
10764
|
+
"-C",
|
|
10765
|
+
repoPath,
|
|
10766
|
+
"diff",
|
|
10767
|
+
"--no-ext-diff",
|
|
10768
|
+
"--binary",
|
|
10769
|
+
baseSha,
|
|
10770
|
+
headSha
|
|
10771
|
+
]);
|
|
10772
|
+
if (diff.status !== 0 || diff.error || diff.stdout.length === 0) return void 0;
|
|
10773
|
+
const result = commandRunner("git", ["patch-id", "--stable"], diff.stdout);
|
|
10774
|
+
if (result.status !== 0 || result.error) return void 0;
|
|
10775
|
+
const match = /^([0-9a-f]{40,64})\s+(?:-|[0-9a-f]{40,64})$/i.exec(result.stdout.trim());
|
|
10776
|
+
return match?.[1];
|
|
10777
|
+
}
|
|
10778
|
+
function hasUniqueSourceCommit(repoPath, baseSha, headSha, commandRunner) {
|
|
10779
|
+
const result = gitOutput(
|
|
10780
|
+
repoPath,
|
|
10781
|
+
["rev-list", "--no-merges", "--reverse", `${baseSha}..${headSha}`],
|
|
10782
|
+
commandRunner
|
|
10783
|
+
);
|
|
10784
|
+
if (!result) return false;
|
|
10785
|
+
const commits = result.split(/\r?\n/).map((sha) => sha.trim()).filter((sha) => sha.length > 0);
|
|
10786
|
+
return commits.length > 0 && commits.every(isSha) && new Set(commits).size > 0;
|
|
10787
|
+
}
|
|
10788
|
+
function hasVerifiedCommonBase(repoPath, baseSha, headSha, commandRunner) {
|
|
10789
|
+
const result = commandRunner("git", [
|
|
10790
|
+
"-C",
|
|
10791
|
+
repoPath,
|
|
10792
|
+
"merge-base",
|
|
10793
|
+
"--is-ancestor",
|
|
10794
|
+
baseSha,
|
|
10795
|
+
headSha
|
|
10796
|
+
]);
|
|
10797
|
+
return result.status === 0 && !result.error;
|
|
10798
|
+
}
|
|
10799
|
+
function parseTargetBranch(output2) {
|
|
10800
|
+
if (!output2) return void 0;
|
|
10801
|
+
const value = output2.trim();
|
|
10802
|
+
return value.startsWith("origin/") && value.length > "origin/".length ? value.slice("origin/".length) : void 0;
|
|
10803
|
+
}
|
|
10804
|
+
function parseRemoteDefaultTarget(output2) {
|
|
10805
|
+
if (!output2) return void 0;
|
|
10806
|
+
const match = /^ref:\s+refs\/heads\/([^\s]+)\s+HEAD$/m.exec(output2);
|
|
10807
|
+
return match?.[1];
|
|
10808
|
+
}
|
|
10809
|
+
function inspectWorkspaceIntegration(repoPath, snapshot, branch, checkMerged, commandRunner) {
|
|
10810
|
+
if (!checkMerged) return { status: "not-verified", reason: "Merged checks were not requested." };
|
|
10811
|
+
if (!snapshot.head || !branch || snapshot.checkout === "missing" || snapshot.checkout === "unknown") {
|
|
10812
|
+
return { status: "unknown", reason: "Current HEAD or branch evidence is unavailable." };
|
|
10813
|
+
}
|
|
10814
|
+
const originUrl = gitOutput(repoPath, ["config", "--get", "remote.origin.url"], commandRunner);
|
|
10815
|
+
const identity = originUrl ? parseRemoteIdentity(originUrl) : void 0;
|
|
10816
|
+
if (!identity) {
|
|
10817
|
+
return { status: "unknown", reason: "The origin is missing or uses an unsupported forge." };
|
|
10818
|
+
}
|
|
10819
|
+
const localTargetBranch = parseTargetBranch(
|
|
10820
|
+
gitOutput(
|
|
10821
|
+
repoPath,
|
|
10822
|
+
["symbolic-ref", "--quiet", "--short", "refs/remotes/origin/HEAD"],
|
|
10823
|
+
commandRunner
|
|
10824
|
+
)
|
|
10825
|
+
);
|
|
10826
|
+
const remoteTargetBranch = parseRemoteDefaultTarget(
|
|
10827
|
+
successfulOutput(
|
|
10828
|
+
commandRunner("git", ["-C", repoPath, "ls-remote", "--symref", "origin", "HEAD"])
|
|
10829
|
+
)
|
|
10830
|
+
);
|
|
10831
|
+
if (!remoteTargetBranch || localTargetBranch && localTargetBranch !== remoteTargetBranch) {
|
|
10832
|
+
return {
|
|
10833
|
+
status: "unknown",
|
|
10834
|
+
reason: "The origin default target is missing, stale, or unavailable."
|
|
10835
|
+
};
|
|
10836
|
+
}
|
|
10837
|
+
const targetBranch = remoteTargetBranch;
|
|
10838
|
+
const localTargetSha = gitOutput(
|
|
10839
|
+
repoPath,
|
|
10840
|
+
["rev-parse", "--verify", `refs/remotes/origin/${targetBranch}`],
|
|
10841
|
+
commandRunner
|
|
10842
|
+
);
|
|
10843
|
+
const remoteTargetOutput = successfulOutput(
|
|
10844
|
+
commandRunner("git", ["-C", repoPath, "ls-remote", "origin", `refs/heads/${targetBranch}`])
|
|
10845
|
+
);
|
|
10846
|
+
const remoteTargetSha = remoteTargetOutput?.split(/\s+/)[0];
|
|
10847
|
+
if (!isSha(localTargetSha) || !isSha(remoteTargetSha) || localTargetSha !== remoteTargetSha) {
|
|
10848
|
+
return { status: "unknown", reason: "The origin target is missing, stale, or unavailable." };
|
|
10849
|
+
}
|
|
10850
|
+
const sourceRemoteOutput = successfulOutput(
|
|
10851
|
+
commandRunner("git", ["-C", repoPath, "ls-remote", "origin", `refs/heads/${branch}`])
|
|
10852
|
+
);
|
|
10853
|
+
const sourceRemoteSha = sourceRemoteOutput?.split(/\s+/)[0];
|
|
10854
|
+
if (!isSha(sourceRemoteSha)) {
|
|
10855
|
+
return {
|
|
10856
|
+
status: "unknown",
|
|
10857
|
+
reason: "The workspace source branch is missing or unavailable on origin."
|
|
10858
|
+
};
|
|
10859
|
+
}
|
|
10860
|
+
const forgeChanges = [];
|
|
10861
|
+
const forgeCommand = identity.provider === "github" ? [
|
|
10862
|
+
"pr",
|
|
10863
|
+
"list",
|
|
10864
|
+
"--repo",
|
|
10865
|
+
identity.project,
|
|
10866
|
+
"--state",
|
|
10867
|
+
"all",
|
|
10868
|
+
"--head",
|
|
10869
|
+
branch,
|
|
10870
|
+
"--json",
|
|
10871
|
+
"headRefName,headRefOid,baseRefName,baseRefOid,state,mergedAt,repository,headRepository,mergeCommit"
|
|
10872
|
+
] : [
|
|
10873
|
+
"mr",
|
|
10874
|
+
"list",
|
|
10875
|
+
"--repo",
|
|
10876
|
+
identity.project,
|
|
10877
|
+
"--all",
|
|
10878
|
+
"--source-branch",
|
|
10879
|
+
branch,
|
|
10880
|
+
"--output",
|
|
10881
|
+
"json"
|
|
10882
|
+
];
|
|
10883
|
+
const forge = commandRunner(identity.provider === "github" ? "gh" : "glab", forgeCommand);
|
|
10884
|
+
if (forge.status !== 0 || forge.error) {
|
|
10885
|
+
return { status: "unknown", reason: "The forge query was unavailable or unauthenticated." };
|
|
10886
|
+
}
|
|
10887
|
+
try {
|
|
10888
|
+
const parsed = JSON.parse(forge.stdout);
|
|
10889
|
+
const changes = identity.provider === "github" ? parseGitHubChanges(parsed, identity.project, branch) : parseGitLabChanges(parsed, identity.project, branch);
|
|
10890
|
+
if (!changes) {
|
|
10891
|
+
return { status: "unknown", reason: "The forge response was malformed." };
|
|
10892
|
+
}
|
|
10893
|
+
forgeChanges.push(...changes);
|
|
10894
|
+
} catch {
|
|
10895
|
+
return { status: "unknown", reason: "The forge response was malformed." };
|
|
10896
|
+
}
|
|
10897
|
+
const mergedExact = forgeChanges.find(
|
|
10898
|
+
(change) => change.merged && change.targetBranch === targetBranch && change.sourceHeadSha.toLowerCase() === snapshot.head?.toLowerCase() && change.sourceHeadSha.toLowerCase() === sourceRemoteSha.toLowerCase()
|
|
10899
|
+
);
|
|
10900
|
+
if (mergedExact)
|
|
10901
|
+
return {
|
|
10902
|
+
status: "merged-exact",
|
|
10903
|
+
headSha: snapshot.head,
|
|
10904
|
+
reason: "Merged source head exactly matches current HEAD."
|
|
10905
|
+
};
|
|
10906
|
+
const worktreePath = snapshot.worktree.path;
|
|
10907
|
+
const ancestry = commandRunner("git", [
|
|
10908
|
+
"-C",
|
|
10909
|
+
worktreePath,
|
|
10910
|
+
"merge-base",
|
|
10911
|
+
"--is-ancestor",
|
|
10912
|
+
snapshot.head,
|
|
10913
|
+
remoteTargetSha
|
|
10914
|
+
]);
|
|
10915
|
+
if (ancestry.status === 0 && !ancestry.error) {
|
|
10916
|
+
return {
|
|
10917
|
+
status: "on-target",
|
|
10918
|
+
headSha: snapshot.head,
|
|
10919
|
+
reason: "Current HEAD is an ancestor of the verified-fresh origin target."
|
|
10920
|
+
};
|
|
10921
|
+
}
|
|
10922
|
+
if (ancestry.status !== 1 || ancestry.error) {
|
|
10923
|
+
return { status: "unknown", reason: "Target ancestry could not be verified." };
|
|
10924
|
+
}
|
|
10925
|
+
for (const change of forgeChanges.filter(
|
|
10926
|
+
(candidate) => candidate.merged && candidate.targetBranch === targetBranch && candidate.sourceHeadSha.toLowerCase() !== snapshot.head?.toLowerCase()
|
|
10927
|
+
)) {
|
|
10928
|
+
if (change.sourceHeadSha.toLowerCase() !== sourceRemoteSha?.toLowerCase()) continue;
|
|
10929
|
+
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)) {
|
|
10930
|
+
const currentPatchId = patchIdForRange(
|
|
10931
|
+
worktreePath,
|
|
10932
|
+
change.baseSha,
|
|
10933
|
+
snapshot.head,
|
|
10934
|
+
commandRunner
|
|
10935
|
+
);
|
|
10936
|
+
const mergedPatchId = patchIdForRange(
|
|
10937
|
+
worktreePath,
|
|
10938
|
+
change.baseSha,
|
|
10939
|
+
change.mergeCommitSha,
|
|
10940
|
+
commandRunner
|
|
10941
|
+
);
|
|
10942
|
+
if (!currentPatchId || !mergedPatchId || currentPatchId !== mergedPatchId) continue;
|
|
10943
|
+
return {
|
|
10944
|
+
status: "patch-equivalent",
|
|
10945
|
+
headSha: snapshot.head,
|
|
10946
|
+
reason: "All source changes have an equivalent merged patch."
|
|
10947
|
+
};
|
|
10948
|
+
}
|
|
10949
|
+
}
|
|
10950
|
+
return {
|
|
10951
|
+
status: "not-verified",
|
|
10952
|
+
reason: "Fresh target and forge evidence did not prove integration."
|
|
10953
|
+
};
|
|
10954
|
+
}
|
|
10955
|
+
function routeStatus(record, routes) {
|
|
10956
|
+
if (!routes) return "unknown";
|
|
10957
|
+
const matchingPath = routes.filter(
|
|
10958
|
+
(route) => sameWorkspacePath(route.repoPath, record.worktreePath)
|
|
10959
|
+
);
|
|
10960
|
+
if (matchingPath.length === 0) return "absent";
|
|
10961
|
+
return matchingPath.every((route) => route.workspace === record.workspace) ? "owned" : "conflict";
|
|
10962
|
+
}
|
|
10963
|
+
function buildActivityEvidence(record, providerStatus, provider, routeEntries, git) {
|
|
10964
|
+
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" };
|
|
10965
|
+
const routeTimestamps = routeEntries?.map((route) => route.updatedAt) ?? [];
|
|
10966
|
+
const malformedRoute = routeTimestamps.some((timestamp) => !validTimestamp(timestamp));
|
|
10967
|
+
const newestRoute = routeTimestamps.filter(validTimestamp).sort((left, right) => (parseTimestamp(right) ?? 0) - (parseTimestamp(left) ?? 0))[0];
|
|
10968
|
+
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" };
|
|
10969
|
+
const ownerEvidence = validTimestamp(record.updatedAt) ? { source: "ownership.updatedAt", status: "valid", timestamp: record.updatedAt } : { source: "ownership.updatedAt", status: "malformed" };
|
|
10970
|
+
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" };
|
|
10971
|
+
return [providerEvidence, routeEvidence, ownerEvidence, gitEvidence];
|
|
10972
|
+
}
|
|
10973
|
+
function buildSuggestions(repoPath, record, ownership, provider, runtime, checkout, route, activity, integration, worktree, checkMerged) {
|
|
10974
|
+
const reasons = [];
|
|
10975
|
+
const eligibleActions = [];
|
|
10976
|
+
const suggestions = [];
|
|
10977
|
+
const gcCommand = workspaceCommand(repoPath, ["gc"], [], ["--yes"]);
|
|
10978
|
+
const keepCommand = workspaceCommand(repoPath, ["down", record.workspace], ["--keep-worktree"]);
|
|
10979
|
+
const downCommand = workspaceCommand(repoPath, ["down", record.workspace]);
|
|
10980
|
+
const routeSafe = route === "owned" || route === "absent";
|
|
10981
|
+
const checkoutSafe = checkout === "clean" && !worktree?.locked;
|
|
10982
|
+
const providerSafe = provider === "owned";
|
|
10983
|
+
const runtimeSafe = runtime === "running" || runtime === "stopped" || runtime === "not-found";
|
|
10984
|
+
if (ownership === "missing") {
|
|
10985
|
+
if ((provider === "absent" || provider === "owned" && runtimeSafe) && routeSafe) {
|
|
10986
|
+
eligibleActions.push(gcCommand);
|
|
10987
|
+
suggestions.push({
|
|
10988
|
+
command: gcCommand,
|
|
10989
|
+
reason: "The exact ownership record is missing from live Git registration; GC will revalidate before deleting eligible runtime evidence."
|
|
10990
|
+
});
|
|
10991
|
+
} else {
|
|
10992
|
+
reasons.push(
|
|
10993
|
+
`GC suggestion suppressed because provider=${provider}, runtime=${runtime}, or route=${route} is not independently safe.`
|
|
10994
|
+
);
|
|
10995
|
+
}
|
|
10996
|
+
return { eligibleActions, suggestions, reasons };
|
|
10997
|
+
}
|
|
10998
|
+
if (ownership !== "present")
|
|
10999
|
+
reasons.push(`Destructive suggestions require ownership=present (found ${ownership}).`);
|
|
11000
|
+
if (!providerSafe)
|
|
11001
|
+
reasons.push(
|
|
11002
|
+
`Destructive suggestions require an exact owned DevPod (found provider=${provider}).`
|
|
11003
|
+
);
|
|
11004
|
+
if (!runtimeSafe)
|
|
11005
|
+
reasons.push(
|
|
11006
|
+
`Destructive suggestions require an actionable DevPod runtime (found runtime=${runtime}).`
|
|
11007
|
+
);
|
|
11008
|
+
if (!routeSafe)
|
|
11009
|
+
reasons.push(
|
|
11010
|
+
`Destructive suggestions require non-conflicting route evidence (found route=${route}).`
|
|
11011
|
+
);
|
|
11012
|
+
if (checkout === "dirty")
|
|
11013
|
+
reasons.push("Checkout is dirty; destructive workspace down is blocked.");
|
|
11014
|
+
if (checkout === "missing")
|
|
11015
|
+
reasons.push("Checkout is missing; the report cannot authorize full down.");
|
|
11016
|
+
if (checkout === "detached")
|
|
11017
|
+
reasons.push("Checkout is detached; branch identity is not safe for destructive advice.");
|
|
11018
|
+
if (checkout === "unknown")
|
|
11019
|
+
reasons.push("Checkout state is unknown; destructive advice is suppressed.");
|
|
11020
|
+
if (worktree?.locked)
|
|
11021
|
+
reasons.push("Git worktree is locked; destructive workspace down is blocked.");
|
|
11022
|
+
if (!providerSafe || !runtimeSafe || !routeSafe || !checkoutSafe || ownership !== "present") {
|
|
11023
|
+
return { eligibleActions, suggestions, reasons };
|
|
11024
|
+
}
|
|
11025
|
+
if (integration.status === "merged-exact" && integration.headSha) {
|
|
11026
|
+
eligibleActions.push(downCommand);
|
|
11027
|
+
suggestions.push({
|
|
11028
|
+
command: downCommand,
|
|
11029
|
+
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."
|
|
11030
|
+
});
|
|
11031
|
+
return { eligibleActions, suggestions, reasons };
|
|
11032
|
+
}
|
|
11033
|
+
if (integration.status === "patch-equivalent")
|
|
11034
|
+
reasons.push("Patch-equivalent integration is advisory and never authorizes full removal.");
|
|
11035
|
+
if (integration.status === "unknown" || integration.status === "not-verified")
|
|
11036
|
+
reasons.push(`Integration is ${integration.status}; full removal is not suggested.`);
|
|
11037
|
+
const activityCleanupAllowed = !checkMerged || integration.status === "on-target" || integration.status === "merged-exact";
|
|
11038
|
+
if (!activityCleanupAllowed) {
|
|
11039
|
+
reasons.push(
|
|
11040
|
+
"Cleanup is not suggested because the requested integration check did not provide a verified target or exact merge."
|
|
11041
|
+
);
|
|
11042
|
+
return { eligibleActions, suggestions, reasons };
|
|
11043
|
+
}
|
|
11044
|
+
if (activity === "quiet") {
|
|
11045
|
+
eligibleActions.push(keepCommand);
|
|
11046
|
+
suggestions.push({
|
|
11047
|
+
command: keepCommand,
|
|
11048
|
+
reason: "Managed workspace has no recent trustworthy activity; this deletes DevPod/runtime data and preserves the worktree and owner record."
|
|
11049
|
+
});
|
|
11050
|
+
} else if (activity === "recent") {
|
|
11051
|
+
reasons.push("Recent trustworthy activity vetoes a quiet-workspace suggestion.");
|
|
11052
|
+
} else {
|
|
11053
|
+
reasons.push("Activity is unknown; a quiet-workspace suggestion is suppressed.");
|
|
11054
|
+
}
|
|
11055
|
+
return { eligibleActions, suggestions, reasons };
|
|
11056
|
+
}
|
|
11057
|
+
function buildRow(repoPath, record, worktrees, devpods, routes, snapshot, cutoff, checkMerged, commandRunner, inspectOwnershipFn, inspectIntegrationFn, inspectRuntimeFn) {
|
|
11058
|
+
const ownershipEvidence = inspectOwnershipFn(record, worktrees, devpods);
|
|
11059
|
+
const providerOwnership = devpods?.find(
|
|
11060
|
+
(devpod) => devpod.id === record.devpodId && sameWorkspacePath(devpod.source.localFolder, record.worktreePath)
|
|
11061
|
+
);
|
|
11062
|
+
const matchingRoutes = routes?.filter(
|
|
11063
|
+
(route) => sameWorkspacePath(route.repoPath, record.worktreePath)
|
|
11064
|
+
);
|
|
11065
|
+
const activity = evaluateWorkspaceActivity(
|
|
11066
|
+
buildActivityEvidence(
|
|
11067
|
+
record,
|
|
11068
|
+
ownershipEvidence.devpodStatus,
|
|
11069
|
+
providerOwnership,
|
|
11070
|
+
matchingRoutes,
|
|
11071
|
+
snapshot
|
|
11072
|
+
),
|
|
11073
|
+
cutoff
|
|
11074
|
+
);
|
|
11075
|
+
const branch = snapshot.checkout === "detached" ? null : snapshot.worktree.branch ?? record.branch;
|
|
11076
|
+
const integration = inspectIntegrationFn?.(repoPath, snapshot, branch, checkMerged) ?? inspectWorkspaceIntegration(repoPath, snapshot, branch, checkMerged, commandRunner);
|
|
11077
|
+
const runtime = ownershipEvidence.devpodStatus === "absent" ? "absent" : ownershipEvidence.devpodStatus === "owned" ? (() => {
|
|
11078
|
+
try {
|
|
11079
|
+
return inspectRuntimeFn(record.devpodId);
|
|
11080
|
+
} catch {
|
|
11081
|
+
return "unknown";
|
|
11082
|
+
}
|
|
11083
|
+
})() : "unknown";
|
|
11084
|
+
const suggestions = buildSuggestions(
|
|
11085
|
+
repoPath,
|
|
11086
|
+
record,
|
|
11087
|
+
ownershipEvidence.ownerStatus,
|
|
11088
|
+
ownershipEvidence.devpodStatus,
|
|
11089
|
+
runtime,
|
|
11090
|
+
snapshot.checkout,
|
|
11091
|
+
routeStatus(record, routes),
|
|
11092
|
+
activity.status,
|
|
11093
|
+
integration,
|
|
11094
|
+
snapshot.worktree,
|
|
11095
|
+
checkMerged
|
|
11096
|
+
);
|
|
11097
|
+
const reasons = [
|
|
11098
|
+
`ownership=${ownershipEvidence.ownerStatus}`,
|
|
11099
|
+
`provider=${ownershipEvidence.devpodStatus}`,
|
|
11100
|
+
`runtime=${runtime}`,
|
|
11101
|
+
`checkout=${snapshot.checkout}`,
|
|
11102
|
+
`route=${routeStatus(record, routes)}`,
|
|
11103
|
+
`activity=${activity.status}`,
|
|
11104
|
+
`integration=${integration.status}`,
|
|
11105
|
+
...integration.reason ? [integration.reason] : [],
|
|
11106
|
+
...suggestions.reasons
|
|
11107
|
+
];
|
|
11108
|
+
return {
|
|
11109
|
+
schemaVersion: 2,
|
|
11110
|
+
workspace: record.workspace,
|
|
11111
|
+
branch,
|
|
11112
|
+
repo: repoPath,
|
|
11113
|
+
worktreePath: record.worktreePath,
|
|
11114
|
+
devpodId: record.devpodId,
|
|
11115
|
+
ownership: ownershipEvidence.ownerStatus,
|
|
11116
|
+
provider: ownershipEvidence.devpodStatus,
|
|
11117
|
+
runtime,
|
|
11118
|
+
checkout: snapshot.checkout,
|
|
11119
|
+
route: routeStatus(record, routes),
|
|
11120
|
+
activity: activity.status,
|
|
11121
|
+
cutoff,
|
|
11122
|
+
latestTimestamp: activity.latestTimestamp,
|
|
11123
|
+
contributingEvidence: activity.contributingEvidence,
|
|
11124
|
+
activityEvidence: activity.evidence,
|
|
11125
|
+
integration: integration.status,
|
|
11126
|
+
eligibleActions: suggestions.eligibleActions,
|
|
11127
|
+
suggestions: suggestions.suggestions,
|
|
11128
|
+
reasons: Array.from(new Set(reasons))
|
|
11129
|
+
};
|
|
11130
|
+
}
|
|
11131
|
+
function deriveReclaimable(worktree, containerWritable) {
|
|
11132
|
+
if (worktree.status === "unknown") {
|
|
11133
|
+
return worktree;
|
|
11134
|
+
}
|
|
11135
|
+
if (containerWritable.status === "unknown") {
|
|
11136
|
+
return containerWritable;
|
|
11137
|
+
}
|
|
11138
|
+
return { status: "measured", bytes: worktree.bytes + containerWritable.bytes };
|
|
11139
|
+
}
|
|
11140
|
+
function collectConsumption(worktreePath, measureWorktreeFn, containers) {
|
|
11141
|
+
let worktree;
|
|
11142
|
+
try {
|
|
11143
|
+
worktree = measureWorktreeFn(worktreePath);
|
|
11144
|
+
} catch (error) {
|
|
11145
|
+
worktree = {
|
|
11146
|
+
status: "unknown",
|
|
11147
|
+
reason: `worktree measurement failed: ${describeCause(error)}`
|
|
11148
|
+
};
|
|
11149
|
+
}
|
|
11150
|
+
const docker = containers.get(worktreePath) ?? unknownContainers("attribution not collected");
|
|
11151
|
+
return {
|
|
11152
|
+
worktree,
|
|
11153
|
+
containerWritable: docker.containerWritable,
|
|
11154
|
+
imageShared: docker.imageShared,
|
|
11155
|
+
reclaimable: deriveReclaimable(worktree, docker.containerWritable)
|
|
11156
|
+
};
|
|
11157
|
+
}
|
|
11158
|
+
function unknownContainers(reason) {
|
|
11159
|
+
return {
|
|
11160
|
+
containerWritable: { status: "unknown", reason },
|
|
11161
|
+
imageShared: { status: "unknown", reason }
|
|
11162
|
+
};
|
|
11163
|
+
}
|
|
11164
|
+
function describeCause(error) {
|
|
11165
|
+
return error instanceof Error ? error.message : String(error);
|
|
11166
|
+
}
|
|
11167
|
+
function buildWorkspaceCleanupReport(options = {}, dependencies = {}) {
|
|
11168
|
+
const repoPath = resolveRepoPath(options.repo);
|
|
11169
|
+
const commandRunner = dependencies.commandRunner ?? defaultCommandRunner;
|
|
11170
|
+
const duration = parseInactiveFor(options.inactiveFor);
|
|
11171
|
+
const now = options.now ?? /* @__PURE__ */ new Date();
|
|
11172
|
+
const cutoff = new Date(now.getTime() - duration.seconds * 1e3).toISOString();
|
|
11173
|
+
const worktrees = (dependencies.listWorktrees ?? listGitWorktrees)(repoPath);
|
|
11174
|
+
const records = (dependencies.listOwnership ?? listWorkspaceOwnership)(repoPath);
|
|
11175
|
+
let devpods;
|
|
11176
|
+
try {
|
|
11177
|
+
devpods = (dependencies.listDevpods ?? listDevpodWorkspaces)();
|
|
11178
|
+
} catch {
|
|
11179
|
+
devpods = void 0;
|
|
11180
|
+
}
|
|
11181
|
+
let routes;
|
|
11182
|
+
try {
|
|
11183
|
+
routes = (dependencies.readRoutes ?? readHostRouteStateReadOnly)();
|
|
11184
|
+
} catch {
|
|
11185
|
+
routes = void 0;
|
|
11186
|
+
}
|
|
11187
|
+
const measureSize = Boolean(options.measureSize);
|
|
11188
|
+
const measureWorktreeFn = dependencies.measureWorktree ?? measureWorktreeConsumption;
|
|
11189
|
+
const measureContainersFn = dependencies.measureContainers ?? measureContainerConsumption;
|
|
11190
|
+
let containers = /* @__PURE__ */ new Map();
|
|
11191
|
+
if (measureSize) {
|
|
11192
|
+
const worktreePaths = records.map((record) => record.worktreePath);
|
|
11193
|
+
try {
|
|
11194
|
+
containers = measureContainersFn(worktreePaths);
|
|
11195
|
+
} catch (error) {
|
|
11196
|
+
const reason = `container measurement failed: ${describeCause(error)}`;
|
|
11197
|
+
containers = new Map(worktreePaths.map((path24) => [path24, unknownContainers(reason)]));
|
|
11198
|
+
}
|
|
11199
|
+
}
|
|
11200
|
+
const rows = records.map((record) => {
|
|
11201
|
+
const worktree = worktrees.find(
|
|
11202
|
+
(candidate) => sameWorkspacePath(candidate.path, record.worktreePath)
|
|
11203
|
+
);
|
|
11204
|
+
const snapshot = (dependencies.readGitSnapshot ?? ((candidate) => readGitSnapshot(candidate, commandRunner)))(
|
|
11205
|
+
worktree ?? {
|
|
11206
|
+
path: record.worktreePath,
|
|
11207
|
+
branch: record.branch ?? void 0,
|
|
11208
|
+
locked: false,
|
|
11209
|
+
prunable: true
|
|
11210
|
+
}
|
|
11211
|
+
);
|
|
11212
|
+
const row = buildRow(
|
|
11213
|
+
repoPath,
|
|
11214
|
+
record,
|
|
11215
|
+
worktrees,
|
|
11216
|
+
devpods,
|
|
11217
|
+
routes,
|
|
11218
|
+
snapshot,
|
|
11219
|
+
cutoff,
|
|
11220
|
+
Boolean(options.checkMerged),
|
|
11221
|
+
commandRunner,
|
|
11222
|
+
dependencies.inspectOwnership ?? ((recordValue, worktreeValues, devpodValues) => {
|
|
11223
|
+
const status = inspectWorkspaceOwnership(recordValue, worktreeValues, devpodValues);
|
|
11224
|
+
return { ownerStatus: status.ownerStatus, devpodStatus: status.devpodStatus };
|
|
11225
|
+
}),
|
|
11226
|
+
dependencies.inspectIntegration,
|
|
11227
|
+
dependencies.inspectDevpodRuntime ?? inspectDevpodRuntimeStatus
|
|
11228
|
+
);
|
|
11229
|
+
return measureSize ? {
|
|
11230
|
+
...row,
|
|
11231
|
+
consumption: collectConsumption(record.worktreePath, measureWorktreeFn, containers)
|
|
11232
|
+
} : row;
|
|
11233
|
+
}).sort((left, right) => left.workspace.localeCompare(right.workspace));
|
|
11234
|
+
return {
|
|
11235
|
+
schemaVersion: 2,
|
|
11236
|
+
generatedAt: now.toISOString(),
|
|
11237
|
+
repoPath,
|
|
11238
|
+
inactiveFor: duration.input,
|
|
11239
|
+
cutoff,
|
|
11240
|
+
checkMerged: Boolean(options.checkMerged),
|
|
11241
|
+
measureSize,
|
|
11242
|
+
workspaces: rows
|
|
11243
|
+
};
|
|
11244
|
+
}
|
|
11245
|
+
var import_node_child_process21, import_node_fs25, DEFAULT_INACTIVE_FOR, READ_ONLY_GIT_ENV2, ACTIVITY_SOURCES, defaultCommandRunner;
|
|
11246
|
+
var init_workspace_cleanup = __esm({
|
|
11247
|
+
"src/core/workspace-cleanup.ts"() {
|
|
11248
|
+
"use strict";
|
|
11249
|
+
import_node_child_process21 = require("child_process");
|
|
11250
|
+
import_node_fs25 = __toESM(require("fs"));
|
|
11251
|
+
init_devpod_workspaces();
|
|
11252
|
+
init_host_routes();
|
|
11253
|
+
init_repo_config();
|
|
11254
|
+
init_workspace();
|
|
11255
|
+
init_workspace_consumption();
|
|
11256
|
+
init_workspace_ownership();
|
|
11257
|
+
DEFAULT_INACTIVE_FOR = "30d";
|
|
11258
|
+
READ_ONLY_GIT_ENV2 = { ...process.env, GIT_OPTIONAL_LOCKS: "0", LC_ALL: "C" };
|
|
11259
|
+
ACTIVITY_SOURCES = [
|
|
11260
|
+
"devpod.lastUsed",
|
|
11261
|
+
"route.updatedAt",
|
|
11262
|
+
"ownership.updatedAt",
|
|
11263
|
+
"git.headCommitterDate"
|
|
11264
|
+
];
|
|
11265
|
+
defaultCommandRunner = (command, args, input2) => {
|
|
11266
|
+
const environment = command === "git" ? READ_ONLY_GIT_ENV2 : { ...process.env, LC_ALL: "C" };
|
|
11267
|
+
const result = (0, import_node_child_process21.spawnSync)(command, args, {
|
|
11268
|
+
encoding: "utf-8",
|
|
11269
|
+
env: environment,
|
|
11270
|
+
...input2 === void 0 ? {} : { input: input2 }
|
|
11271
|
+
});
|
|
11272
|
+
return {
|
|
11273
|
+
status: result.status,
|
|
11274
|
+
stdout: result.stdout ?? "",
|
|
11275
|
+
stderr: result.stderr ?? "",
|
|
11276
|
+
...result.error ? { error: result.error } : {}
|
|
11277
|
+
};
|
|
11278
|
+
};
|
|
11279
|
+
}
|
|
11280
|
+
});
|
|
11281
|
+
|
|
10257
11282
|
// src/commands/workspace.ts
|
|
10258
11283
|
var workspace_exports = {};
|
|
10259
11284
|
__export(workspace_exports, {
|
|
11285
|
+
runWorkspaceCleanupCommand: () => runWorkspaceCleanupCommand,
|
|
10260
11286
|
runWorkspaceDownCommand: () => runWorkspaceDownCommand,
|
|
10261
11287
|
runWorkspaceGcCommand: () => runWorkspaceGcCommand,
|
|
10262
11288
|
runWorkspaceLsCommand: () => runWorkspaceLsCommand,
|
|
@@ -10304,6 +11330,15 @@ function runWorkspaceLsCommand(options) {
|
|
|
10304
11330
|
);
|
|
10305
11331
|
}
|
|
10306
11332
|
}
|
|
11333
|
+
function runWorkspaceCleanupCommand(options) {
|
|
11334
|
+
const repoPath = resolveGitWorkspaceRepo(options.repo);
|
|
11335
|
+
const report = buildWorkspaceCleanupReport({ ...options, repo: repoPath });
|
|
11336
|
+
if (options.json) {
|
|
11337
|
+
printJSON(report);
|
|
11338
|
+
return;
|
|
11339
|
+
}
|
|
11340
|
+
printWorkspaceCleanupReport(report);
|
|
11341
|
+
}
|
|
10307
11342
|
async function runWorkspaceDownCommand(target, options) {
|
|
10308
11343
|
const repoPath = resolveGitWorkspaceRepo(options.repo);
|
|
10309
11344
|
await workspaceDown(target, {
|
|
@@ -10342,7 +11377,9 @@ function runWorkspaceGcCommand(options) {
|
|
|
10342
11377
|
var init_workspace2 = __esm({
|
|
10343
11378
|
"src/commands/workspace.ts"() {
|
|
10344
11379
|
"use strict";
|
|
11380
|
+
init_output();
|
|
10345
11381
|
init_repo_config();
|
|
11382
|
+
init_workspace_cleanup();
|
|
10346
11383
|
init_workspace_gc();
|
|
10347
11384
|
init_workspace_lifecycle();
|
|
10348
11385
|
init_workspace_ownership();
|
|
@@ -10387,7 +11424,7 @@ var init_version = __esm({
|
|
|
10387
11424
|
|
|
10388
11425
|
// src/cli.ts
|
|
10389
11426
|
var import_commander = require("commander");
|
|
10390
|
-
var CLI_VERSION = true ? "0.0.
|
|
11427
|
+
var CLI_VERSION = true ? "0.0.36" : "0.0.0-dev";
|
|
10391
11428
|
var VERSION_FLAGS = /* @__PURE__ */ new Set(["-V", "--version"]);
|
|
10392
11429
|
function withErrorHandling(action2) {
|
|
10393
11430
|
return async (...args) => {
|
|
@@ -10626,8 +11663,8 @@ tlsCommand.command("install").description("Install mkcert certs and enable HTTPS
|
|
|
10626
11663
|
await runTLSInstallCommand2();
|
|
10627
11664
|
})
|
|
10628
11665
|
);
|
|
10629
|
-
var
|
|
10630
|
-
|
|
11666
|
+
var workspaceCommand2 = program.command("workspace").description("Spin up / list / tear down isolated worktree+devcontainer workspaces");
|
|
11667
|
+
workspaceCommand2.command("up").description(
|
|
10631
11668
|
"Create a worktree for <branch>, bring up its devpod, and register namespaced routes"
|
|
10632
11669
|
).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
11670
|
withErrorHandling(async (branch, _options, command) => {
|
|
@@ -10641,7 +11678,7 @@ workspaceCommand.command("up").description(
|
|
|
10641
11678
|
});
|
|
10642
11679
|
})
|
|
10643
11680
|
);
|
|
10644
|
-
|
|
11681
|
+
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
11682
|
withErrorHandling(
|
|
10646
11683
|
async (worktreePath, _options, command) => {
|
|
10647
11684
|
const options = command.opts();
|
|
@@ -10654,28 +11691,41 @@ workspaceCommand.command("ensure").description("Start and prove a primary or lin
|
|
|
10654
11691
|
}
|
|
10655
11692
|
)
|
|
10656
11693
|
);
|
|
10657
|
-
|
|
11694
|
+
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
11695
|
withErrorHandling(async (_options, command) => {
|
|
10659
11696
|
const options = command.opts();
|
|
10660
11697
|
const { runWorkspaceLsCommand: runWorkspaceLsCommand2 } = await Promise.resolve().then(() => (init_workspace2(), workspace_exports));
|
|
10661
11698
|
runWorkspaceLsCommand2(options);
|
|
10662
11699
|
})
|
|
10663
11700
|
);
|
|
10664
|
-
|
|
11701
|
+
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(
|
|
11702
|
+
withErrorHandling(async (_options, command) => {
|
|
11703
|
+
const options = command.opts();
|
|
11704
|
+
const { runWorkspaceCleanupCommand: runWorkspaceCleanupCommand2 } = await Promise.resolve().then(() => (init_workspace2(), workspace_exports));
|
|
11705
|
+
runWorkspaceCleanupCommand2({
|
|
11706
|
+
repo: options.repo,
|
|
11707
|
+
inactiveFor: options.inactiveFor,
|
|
11708
|
+
checkMerged: Boolean(options.checkMerged),
|
|
11709
|
+
measureSize: Boolean(options.measureSize),
|
|
11710
|
+
json: Boolean(options.json)
|
|
11711
|
+
});
|
|
11712
|
+
})
|
|
11713
|
+
);
|
|
11714
|
+
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
11715
|
withErrorHandling(async (_options, command) => {
|
|
10666
11716
|
const options = command.opts();
|
|
10667
11717
|
const { runWorkspaceGcCommand: runWorkspaceGcCommand2 } = await Promise.resolve().then(() => (init_workspace2(), workspace_exports));
|
|
10668
11718
|
runWorkspaceGcCommand2(options);
|
|
10669
11719
|
})
|
|
10670
11720
|
);
|
|
10671
|
-
|
|
11721
|
+
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
11722
|
withErrorHandling(async (target, _options, command) => {
|
|
10673
11723
|
const options = command.opts();
|
|
10674
11724
|
const { runWorkspaceStopCommand: runWorkspaceStopCommand2 } = await Promise.resolve().then(() => (init_workspace2(), workspace_exports));
|
|
10675
11725
|
await runWorkspaceStopCommand2(target, options);
|
|
10676
11726
|
})
|
|
10677
11727
|
);
|
|
10678
|
-
|
|
11728
|
+
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
11729
|
withErrorHandling(async (target, _options, command) => {
|
|
10680
11730
|
const options = command.opts();
|
|
10681
11731
|
const { runWorkspaceDownCommand: runWorkspaceDownCommand2 } = await Promise.resolve().then(() => (init_workspace2(), workspace_exports));
|