Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,7 @@ jobs:
- run: pnpm test
- run: pnpm build
- run: node bin/worktrellis.mjs --version
- run: node bin/worktrellis.mjs info --config examples/basic/worktrellis.config.ts --json
- run: npm pack --dry-run

compose-integration:
Expand Down
15 changes: 15 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,21 @@ All notable changes to WorkTrellis are documented here. The project follows

## Unreleased

## 0.4.3 - 2026-08-04

### Fixed

- Windows Git Bash shutdown now keeps the real WorkTrellis supervisor in the
foreground, while supervised application wrappers run in a separate hidden
console so one Ctrl+C cannot terminate every layer independently.
- Portless-wrapped Node commands no longer pass a `C:\Program Files\...`
executable through Portless 0.15.x's unquoted `cmd.exe` command string, and
other child arguments that need Windows command quoting are protected.
- Windows lost-state port recovery now discovers listener PIDs with
`Get-NetTCPConnection`, falling back to the system `netstat.exe`, and other
native diagnostic commands resolve explicitly from the Windows system
directory.

## 0.4.2 - 2026-08-04

### Added
Expand Down
23 changes: 8 additions & 15 deletions bin/worktrellis.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -4,31 +4,24 @@
// The CLI itself is compiled, but it runs through tsx so projects can keep a
// typed `worktrellis.config.ts` without installing a loader of their own.

import { spawn } from "node:child_process";
import { createRequire } from "node:module";
import path from "node:path";
import { fileURLToPath } from "node:url";
import { fileURLToPath, pathToFileURL } from "node:url";

const here = path.dirname(fileURLToPath(import.meta.url));
const cli = path.join(here, "..", "dist", "cli.js");

let tsxCli;
let register;
try {
const require = createRequire(import.meta.url);
tsxCli = require.resolve("tsx/cli");
({ register } = await import("tsx/esm/api"));
} catch {
console.error(
"WorkTrellis could not load its TypeScript runtime. Reinstall the worktrellis package.",
);
process.exit(3);
}

const child = spawn(process.execPath, [tsxCli, cli, ...process.argv.slice(2)], {
stdio: "inherit",
windowsHide: true,
});

child.on("exit", (code, signal) => {
if (signal) process.kill(process.pid, signal);
else process.exit(code ?? 0);
});
// Run the compiled CLI in this process. An npm-bin relay process exits on the
// first Windows Ctrl+C before the real supervisor finishes its async cleanup,
// returning Git Bash to a prompt while descendants are still shutting down.
register();
await import(pathToFileURL(cli).href);
6 changes: 6 additions & 0 deletions docs/cli.md
Original file line number Diff line number Diff line change
Expand Up @@ -56,6 +56,12 @@ listener, walks to the highest live ancestor whose command line belongs to the
current worktree, and refuses to signal the tree when ownership cannot be
verified.

On Windows, supervised application wrappers use a separate hidden console so
Git Bash's console-wide Ctrl+C is handled by WorkTrellis first. The npm command
remains attached until port verification and any required descendant cleanup
finish. After a normal single-Ctrl+C shutdown, returning to the shell prompt
therefore means cleanup has completed.

### `worktrellis status`

Reports the worktree URL, supervised-process state, Compose health, and
Expand Down
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "worktrellis",
"version": "0.4.2",
"version": "0.4.3",
"type": "module",
"description": "Coordinate project-owned Compose stacks, isolated resources, ports, and environments across Git worktrees.",
"license": "MIT",
Expand Down
22 changes: 15 additions & 7 deletions src/platform/port-owner.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,10 @@
import { spawnSync } from "node:child_process";

import { IS_WINDOWS, run } from "../util/proc";
import {
IS_WINDOWS,
resolveWindowsSystemExecutable,
run,
} from "../util/proc";
import type { ContainerEngine } from "./engine";
import { isPortAvailable } from "./ports";

Expand Down Expand Up @@ -68,11 +72,15 @@ function foreignHolder(
protocol: "tcp" | "udp",
): PortOwner {
if (IS_WINDOWS) {
const netstat = spawnSync("netstat", ["-ano", "-p", protocol], {
encoding: "utf8",
windowsHide: true,
timeout: 15_000,
});
const netstat = spawnSync(
resolveWindowsSystemExecutable("netstat.exe"),
["-ano", "-p", protocol],
{
encoding: "utf8",
windowsHide: true,
timeout: 15_000,
},
);
if (netstat.status !== 0 || !netstat.stdout) return { kind: "unknown" };

for (const line of netstat.stdout.split(/\r?\n/)) {
Expand All @@ -86,7 +94,7 @@ function foreignHolder(

// tasklist is far cheaper than starting PowerShell just for an image name.
const tasklist = spawnSync(
"tasklist",
resolveWindowsSystemExecutable("tasklist.exe"),
["/FI", `PID eq ${pid}`, "/FO", "CSV", "/NH"],
{ encoding: "utf8", windowsHide: true, timeout: 15_000 },
);
Expand Down
13 changes: 9 additions & 4 deletions src/supervise/reaper.ts
Original file line number Diff line number Diff line change
Expand Up @@ -110,8 +110,8 @@ export async function reapOrphans(
continue;
}

const normalized = info.commandLine.replace(/\\/g, "/").toLowerCase();
const expected = child.cmdMustContain.replace(/\\/g, "/").toLowerCase();
const normalized = normalizeProcessCommandLine(info.commandLine);
const expected = normalizeProcessCommandLine(child.cmdMustContain);

if (!normalized.includes(expected)) {
result.blocked.push({
Expand Down Expand Up @@ -211,7 +211,7 @@ export async function reapApplicationPort(
}

const described = describeWithLiveAncestors(listeners);
const expected = worktreeRoot.replace(/\\/g, "/").toLowerCase();
const expected = normalizeProcessCommandLine(worktreeRoot);
const earliestRecordedStart = options.record?.children
.map((child) => child.startedAtMs)
.filter(Number.isFinite)
Expand Down Expand Up @@ -283,7 +283,7 @@ function belongsToWorktree(
expectedRoot: string,
earliestRecordedStart: number | undefined,
): boolean {
const command = processInfo.commandLine.replace(/\\/g, "/").toLowerCase();
const command = normalizeProcessCommandLine(processInfo.commandLine);
if (!command.includes(expectedRoot)) return false;
if (
earliestRecordedStart !== undefined &&
Expand All @@ -295,6 +295,11 @@ function belongsToWorktree(
return true;
}

/** Normalize native and command-line-escaped Windows separators identically. */
export function normalizeProcessCommandLine(value: string): string {
return value.replace(/\\+/g, "/").replace(/\/+/g, "/").toLowerCase();
}

function describeWithLiveAncestors(
pids: number[],
): Map<number, ProcessDescription> {
Expand Down
23 changes: 13 additions & 10 deletions src/supervise/supervisor.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,11 +9,7 @@ import { redactDiagnosticText } from "../core/env-resolve";
import { ensureDirectory } from "../util/fs";
import { c, info, NAMED_COLORS, PROCESS_COLORS, type Colorize } from "../util/log";
import { canConnect } from "../platform/ports";
import {
IS_WINDOWS,
killTree,
requestCooperativeTreeShutdown,
} from "../util/proc";
import { killTree, requestCooperativeTreeShutdown } from "../util/proc";
import { writeRunRecord, type RunChild, type RunRecord } from "./reaper";
import {
clearShutdownRequest,
Expand Down Expand Up @@ -74,6 +70,14 @@ export function sanitizeMultiplexedOutput(value: string): string {
.replace(/\u001b\[(?:[0-9;?]*[HJf]|[0-9;]*[JK])/g, "");
}

/** Keep Windows wrappers out of Git Bash's console-wide Ctrl+C broadcast. */
export function supervisedProcessIsolation(): {
detached: true;
windowsHide: true;
} {
return { detached: true, windowsHide: true };
}

function resolveCommand(
command: Command,
projectRoot: string,
Expand Down Expand Up @@ -267,11 +271,10 @@ export class Supervisor {
cwd: this.options.projectRoot,
env,
stdio: ["ignore", "pipe", "pipe"],
// On POSIX this makes the child a process-group leader so the whole tree
// can be signalled at once. On Windows it would open a new console
// window, and taskkill /T walks the tree anyway.
detached: !IS_WINDOWS,
windowsHide: true,
// POSIX uses the new process group for tree signals. Windows uses a
// hidden, separate console so Git Bash's console-wide Ctrl+C reaches the
// WorkTrellis supervisor but not every nested wrapper independently.
...supervisedProcessIsolation(),
});

entry.child = child;
Expand Down
21 changes: 19 additions & 2 deletions src/url/portless.ts
Original file line number Diff line number Diff line change
Expand Up @@ -146,6 +146,8 @@ export interface PortlessAppRunner {
aliasName: string;
listenPort: number;
tailscale: boolean;
/** Portless 0.15.x joins Windows child argv into one cmd.exe string. */
windowsCmdShell?: boolean;
/** Allow Portless's own Tailscale CLI cleanup to finish before force-kill. */
cooperativeShutdownGraceMs?: number;
}
Expand Down Expand Up @@ -187,11 +189,14 @@ export function wrapCommandForPortless(
const child =
"node" in command
? [
process.execPath,
path.basename(process.execPath),
path.resolve(projectRoot, command.node[0]!),
...command.node.slice(1),
]
: [command.bin, ...command.args];
const compatibleChild = runner.windowsCmdShell
? child.map(quoteWindowsCmdToken)
: child;

return {
node: [
Expand All @@ -202,11 +207,22 @@ export function wrapCommandForPortless(
"--app-port",
String(runner.listenPort),
"--",
...child,
...compatibleChild,
],
};
}

/**
* Portless 0.15.x invokes `cmd.exe /c` with `commandArgs.join(" ")` on
* Windows. Quote only tokens that cmd would otherwise split or interpret.
* The executable is normally the PATH-resolved `node.exe`, so a standard
* `C:\Program Files\nodejs` installation never appears in this command.
*/
export function quoteWindowsCmdToken(value: string): string {
if (!/[\s&|<>^()]/.test(value)) return value;
return `"${value.replace(/"/g, '\\"')}"`;
}

/** The hostname this workspace uses, without contacting the proxy. */
export function portlessAliasName(
identity: WorkspaceIdentity,
Expand Down Expand Up @@ -331,6 +347,7 @@ export async function resolvePortlessUrl(
aliasName,
listenPort,
tailscale: options.tailscale ?? false,
windowsCmdShell: process.platform === "win32",
...(options.tailscale
? {
cooperativeShutdownGraceMs:
Expand Down
36 changes: 29 additions & 7 deletions src/util/proc.ts
Original file line number Diff line number Diff line change
Expand Up @@ -132,10 +132,14 @@ export function killTree(
const force = signal === "SIGKILL";
const args = ["/pid", String(pid), "/T"];
if (force) args.push("/F");
const result = spawnSync(windowsSystemExecutable("taskkill.exe"), args, {
stdio: "ignore",
windowsHide: true,
});
const result = spawnSync(
resolveWindowsSystemExecutable("taskkill.exe"),
args,
{
stdio: "ignore",
windowsHide: true,
},
);
return result.status === 0;
}

Expand Down Expand Up @@ -192,7 +196,7 @@ function signalProcessOrGroup(pid: number, signal: NodeJS.Signals): boolean {
}
}

function windowsSystemExecutable(name: string): string {
export function resolveWindowsSystemExecutable(name: string): string {
const systemRoot = process.env.SystemRoot?.trim();
if (!systemRoot) return name;
const candidate = path.join(systemRoot, "System32", name);
Expand Down Expand Up @@ -365,8 +369,22 @@ export function listeningProcessIds(port: number): number[] {
if (!Number.isInteger(port) || port <= 0 || port > 65_535) return [];

if (IS_WINDOWS) {
const powershell = spawnSync(
windowsPowerShellExecutable(),
[
"-NoProfile",
"-NonInteractive",
"-Command",
`Get-NetTCPConnection -LocalPort ${port} -State Listen -ErrorAction SilentlyContinue | Select-Object -ExpandProperty OwningProcess -Unique`,
],
{ encoding: "utf8", windowsHide: true, timeout: 15_000 },
);
if (powershell.status === 0) {
return parseProcessIds(powershell.stdout ?? "");
}

const result = spawnSync(
windowsSystemExecutable("netstat.exe"),
resolveWindowsSystemExecutable("netstat.exe"),
["-ano", "-p", "tcp"],
{ encoding: "utf8", windowsHide: true, timeout: 15_000 },
);
Expand All @@ -380,9 +398,13 @@ export function listeningProcessIds(port: number): number[] {
{ encoding: "utf8", timeout: 15_000 },
);
if (result.status !== 0 || !result.stdout) return [];
return parseProcessIds(result.stdout);
}

export function parseProcessIds(output: string): number[] {
return [
...new Set(
result.stdout
output
.split(/\r?\n/)
.map((value) => Number.parseInt(value.trim(), 10))
.filter((pid) => Number.isInteger(pid) && pid > 0),
Expand Down
Loading
Loading