Skip to content

Commit 7bb7819

Browse files
committed
Merge remote-tracking branch 'origin/main' into feat/switch-worktree-profile
# Conflicts: # bin/src/completions.ts # bin/src/webmux.ts # bin/src/worktree-commands.ts # frontend/src/App.svelte # frontend/src/lib/WorktreeList.svelte
2 parents 5948448 + 99cb139 commit 7bb7819

49 files changed

Lines changed: 2367 additions & 188 deletions

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

.gitignore

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,3 +16,5 @@ public/
1616
.env.local
1717
.webmux.local.yaml
1818
docs/useful-commands.txt
19+
.claude/settings.local.json
20+
.claude/worktrees/

.release-please-manifest.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,3 @@
11
{
2-
".": "0.39.0"
2+
".": "0.43.1"
33
}

CHANGELOG.md

Lines changed: 65 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,70 @@
11
# Changelog
22

3+
## [0.43.1](https://github.com/windmill-labs/webmux/compare/v0.43.0...v0.43.1) (2026-07-23)
4+
5+
6+
### Bug Fixes
7+
8+
* **pr:** stop cancelled CI runs from masking the latest review status ([#298](https://github.com/windmill-labs/webmux/issues/298)) ([9001d9f](https://github.com/windmill-labs/webmux/commit/9001d9f92d94d151add9983dc32a7d1384fd3945))
9+
10+
## [0.43.0](https://github.com/windmill-labs/webmux/compare/v0.42.0...v0.43.0) (2026-07-20)
11+
12+
13+
### Features
14+
15+
* load global config env from ~/.config/webmux/.env ([#295](https://github.com/windmill-labs/webmux/issues/295)) ([d62612e](https://github.com/windmill-labs/webmux/commit/d62612e71e12c00664d3463d91f51f0acc9bf2c9))
16+
17+
18+
### Bug Fixes
19+
20+
* **tmux:** force a UTF-8 locale for tmux control commands ([#293](https://github.com/windmill-labs/webmux/issues/293)) ([e725f3b](https://github.com/windmill-labs/webmux/commit/e725f3be7a0dd0bd7f960b7b2641a37b900e6fb8))
21+
22+
## [0.42.0](https://github.com/windmill-labs/webmux/compare/v0.41.2...v0.42.0) (2026-07-17)
23+
24+
25+
### Features
26+
27+
* order worktree list by session and pr state ([#289](https://github.com/windmill-labs/webmux/issues/289)) ([44bd900](https://github.com/windmill-labs/webmux/commit/44bd900efa85dae03cc0c3abd3c268cab2639b55))
28+
29+
30+
### Bug Fixes
31+
32+
* **auto-remove:** sweep merged worktrees headlessly ([#291](https://github.com/windmill-labs/webmux/issues/291)) ([70c51e4](https://github.com/windmill-labs/webmux/commit/70c51e43e75b7208a7a9040bbf98762d6d22aa4a))
33+
34+
## [0.41.2](https://github.com/windmill-labs/webmux/compare/v0.41.1...v0.41.2) (2026-07-16)
35+
36+
37+
### Bug Fixes
38+
39+
* restore agent status by prefixing the runtime control URL ([d168301](https://github.com/windmill-labs/webmux/commit/d16830192fdcc5dfa81bff33df544f60bb136b25))
40+
* restore agent status by prefixing the runtime control URL ([d7fba24](https://github.com/windmill-labs/webmux/commit/d7fba241c00714d9d5e3cecd9e31c8c36d696001))
41+
42+
## [0.41.1](https://github.com/windmill-labs/webmux/compare/v0.41.0...v0.41.1) (2026-07-14)
43+
44+
45+
### Bug Fixes
46+
47+
* keep launch project .env secrets out of tmux global env ([#285](https://github.com/windmill-labs/webmux/issues/285)) ([153ce85](https://github.com/windmill-labs/webmux/commit/153ce85bfe7c99edd967d5cb75ef67b03fe7a78b))
48+
* **linear:** don't treat a linked github issue as a PR ([#287](https://github.com/windmill-labs/webmux/issues/287)) ([b8600a0](https://github.com/windmill-labs/webmux/commit/b8600a0091034edab3037472487b3cedf0c493fd))
49+
50+
## [0.41.0](https://github.com/windmill-labs/webmux/compare/v0.40.0...v0.41.0) (2026-06-30)
51+
52+
53+
### Features
54+
55+
* add create sub-worktree shortcut to worktree menu ([#269](https://github.com/windmill-labs/webmux/issues/269)) ([f40807f](https://github.com/windmill-labs/webmux/commit/f40807f2445c418be22ed12e959c11b9297a9309))
56+
* let webmux service run anywhere and add non-interactive install ([#282](https://github.com/windmill-labs/webmux/issues/282)) ([4013397](https://github.com/windmill-labs/webmux/commit/40133971ea3dce9d85ceb86c35d3d6a1074a3342))
57+
58+
## [0.40.0](https://github.com/windmill-labs/webmux/compare/v0.39.0...v0.40.0) (2026-06-27)
59+
60+
61+
### Features
62+
63+
* periodically save open sessions and add webmux restore ([a96f16d](https://github.com/windmill-labs/webmux/commit/a96f16d70bbc5cb700a015a578e9a6865d0adcee))
64+
* periodically save open sessions and add webmux restore ([0b41b19](https://github.com/windmill-labs/webmux/commit/0b41b199a911cfc60245305d4b0fd8f1ee13b2c4))
65+
* prune only closed worktrees instead of all ([0fa2da6](https://github.com/windmill-labs/webmux/commit/0fa2da67d05a705820a38643cb71f3a72cad2063))
66+
* prune only closed worktrees instead of all ([164e7fc](https://github.com/windmill-labs/webmux/commit/164e7fcbc1a699a94dace10b0d095f8896c497e1))
67+
368
## [0.39.0](https://github.com/windmill-labs/webmux/compare/v0.38.0...v0.39.0) (2026-06-22)
469

570

README.md

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -52,6 +52,17 @@ Each issue is processed once while it stays in Todo + labeled. Remove the label
5252

5353
When the auto-create watcher picks up a `webmux_oneshot` issue, it posts a structured comment on the Linear issue (prefix `` **Webmux pickup — branch `<branch>`** ``) so external automation can track when the autonomous run starts. (Regular `webmux` pickups are user-driven and skip the comment.)
5454

55+
**Setup — `LINEAR_API_KEY`.** Everything above needs a [Linear API key](https://linear.app/settings/account/security) in the server's environment. webmux runs as a single machine-wide service started from your home directory, so put the key in `~/.config/webmux/.env` — a `KEY=value` file the server loads at startup regardless of which directory it runs from, so it survives `webmux update`:
56+
57+
```bash
58+
mkdir -p ~/.config/webmux
59+
echo 'LINEAR_API_KEY=lin_api_...' >> ~/.config/webmux/.env
60+
chmod 600 ~/.config/webmux/.env
61+
webmux service restart
62+
```
63+
64+
Your assigned issues appear in the dashboard once the service restarts.
65+
5566
## Quick Start
5667

5768
```bash
Lines changed: 110 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,110 @@
1+
import { describe, expect, it } from "bun:test";
2+
import type { GitGateway, GitWorktreeEntry } from "../adapters/git";
3+
import type { PrEntry } from "../domain/model";
4+
import {
5+
runAutoRemove,
6+
type AutoRemoveDependencies,
7+
} from "../services/auto-remove-service";
8+
9+
const ROOT = "/repo";
10+
11+
function worktree(branch: string): GitWorktreeEntry {
12+
return { path: `/repo__wt/${branch}`, branch, bare: false } as GitWorktreeEntry;
13+
}
14+
15+
/** Builds deps with fakes; records which branches got removed. */
16+
function makeDeps(opts: {
17+
worktrees: GitWorktreeEntry[];
18+
states: Map<string, PrEntry["state"][]> | null;
19+
dirty?: Set<string>;
20+
}): { deps: AutoRemoveDependencies; removed: string[] } {
21+
const removed: string[] = [];
22+
const removing = new Set<string>();
23+
const dirty = opts.dirty ?? new Set<string>();
24+
25+
const git = {
26+
listLiveWorktrees: () => [{ path: ROOT, branch: "main", bare: false }, ...opts.worktrees],
27+
readWorktreeStatus: (path: string) => ({
28+
dirty: dirty.has(path),
29+
aheadCount: 0,
30+
currentCommit: null,
31+
}),
32+
} as unknown as GitGateway;
33+
34+
const deps: AutoRemoveDependencies = {
35+
git,
36+
projectRoot: ROOT,
37+
lifecycleService: {
38+
removeWorktree: async (branch: string) => {
39+
removed.push(branch);
40+
},
41+
} as unknown as AutoRemoveDependencies["lifecycleService"],
42+
notifications: { notify: () => {} } as unknown as AutoRemoveDependencies["notifications"],
43+
isRemoving: (b) => removing.has(b),
44+
markRemoving: (b) => removing.add(b),
45+
unmarkRemoving: (b) => removing.delete(b),
46+
getBranchPrStates: async () => opts.states,
47+
};
48+
return { deps, removed };
49+
}
50+
51+
describe("runAutoRemove", () => {
52+
it("removes a clean worktree whose PR is merged, without any prior open-state sync", async () => {
53+
const wt = worktree("feature");
54+
const { deps, removed } = makeDeps({
55+
worktrees: [wt],
56+
states: new Map([["feature", ["merged"]]]),
57+
});
58+
await runAutoRemove(deps);
59+
expect(removed).toEqual(["feature"]);
60+
});
61+
62+
it("keeps a worktree whose PR is still open", async () => {
63+
const wt = worktree("feature");
64+
const { deps, removed } = makeDeps({
65+
worktrees: [wt],
66+
states: new Map([["feature", ["open"]]]),
67+
});
68+
await runAutoRemove(deps);
69+
expect(removed).toEqual([]);
70+
});
71+
72+
it("keeps a worktree when any of its PRs across repos is not merged", async () => {
73+
// Same branch has a merged PR in one repo and an open PR in a linked repo.
74+
const wt = worktree("feature");
75+
const { deps, removed } = makeDeps({
76+
worktrees: [wt],
77+
states: new Map([["feature", ["merged", "open"]]]),
78+
});
79+
await runAutoRemove(deps);
80+
expect(removed).toEqual([]);
81+
});
82+
83+
it("keeps a merged worktree that is dirty", async () => {
84+
const wt = worktree("feature");
85+
const { deps, removed } = makeDeps({
86+
worktrees: [wt],
87+
states: new Map([["feature", ["merged"]]]),
88+
dirty: new Set([wt.path]),
89+
});
90+
await runAutoRemove(deps);
91+
expect(removed).toEqual([]);
92+
});
93+
94+
it("keeps a worktree that has no PR", async () => {
95+
const wt = worktree("feature");
96+
const { deps, removed } = makeDeps({ worktrees: [wt], states: new Map() });
97+
await runAutoRemove(deps);
98+
expect(removed).toEqual([]);
99+
});
100+
101+
it("removes nothing when the PR state fetch is inconclusive", async () => {
102+
// A failed repo query (null) must not be read as "merged with no other PRs":
103+
// a transient gh failure on a linked repo could otherwise drop an open
104+
// cross-repo PR and remove a still-live worktree.
105+
const wt = worktree("feature");
106+
const { deps, removed } = makeDeps({ worktrees: [wt], states: null });
107+
await runAutoRemove(deps);
108+
expect(removed).toEqual([]);
109+
});
110+
});

backend/src/__tests__/domain-policies.test.ts

Lines changed: 57 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,10 @@
11
import { describe, expect, it } from "bun:test";
22
import {
33
allocateServicePorts,
4+
compareWorktreeOrder,
45
deriveProjectPrefix,
56
sanitizeProjectPrefix,
7+
type WorktreeOrderFields,
68
} from "../domain/policies";
79

810
describe("allocateServicePorts", () => {
@@ -85,3 +87,58 @@ describe("deriveProjectPrefix", () => {
8587
expect(deriveProjectPrefix("/srv/assets", [])).toBe("assets-2");
8688
});
8789
});
90+
91+
describe("compareWorktreeOrder", () => {
92+
const worktree = (branch: string, fields: Partial<WorktreeOrderFields> = {}): WorktreeOrderFields => ({
93+
branch,
94+
open: false,
95+
prStates: [],
96+
...fields,
97+
});
98+
99+
it("ranks open worktrees above every closed one", () => {
100+
const rows = [
101+
worktree("closed-open-pr", { prStates: ["open"] }),
102+
worktree("open-no-pr", { open: true }),
103+
];
104+
105+
expect([...rows].sort(compareWorktreeOrder).map((row) => row.branch)).toEqual([
106+
"open-no-pr",
107+
"closed-open-pr",
108+
]);
109+
});
110+
111+
it("ranks closed worktrees by pr state, then no pr at all", () => {
112+
const rows = [
113+
worktree("no-pr"),
114+
worktree("merged-pr", { prStates: ["merged"] }),
115+
worktree("open-pr", { prStates: ["open"] }),
116+
worktree("closed-pr", { prStates: ["closed"] }),
117+
];
118+
119+
expect([...rows].sort(compareWorktreeOrder).map((row) => row.branch)).toEqual([
120+
"open-pr",
121+
"closed-pr",
122+
"merged-pr",
123+
"no-pr",
124+
]);
125+
});
126+
127+
it("promotes a worktree whose repos disagree on pr state if any pr is open", () => {
128+
const rows = [
129+
worktree("all-merged", { prStates: ["merged", "merged"] }),
130+
worktree("one-open", { prStates: ["merged", "open"] }),
131+
];
132+
133+
expect([...rows].sort(compareWorktreeOrder).map((row) => row.branch)).toEqual([
134+
"one-open",
135+
"all-merged",
136+
]);
137+
});
138+
139+
it("falls back to branch name within the same rank", () => {
140+
const rows = [worktree("zulu", { open: true }), worktree("alpha", { open: true })];
141+
142+
expect([...rows].sort(compareWorktreeOrder).map((row) => row.branch)).toEqual(["alpha", "zulu"]);
143+
});
144+
});

backend/src/__tests__/lifecycle-service.test.ts

Lines changed: 62 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -299,6 +299,9 @@ function makeLifecycleService(
299299
onFinished?: (branch: string) => void | Promise<void>;
300300
} = {},
301301
sessionDiscovery: SessionDiscoveryGateway = { listSessionIds: async () => [] },
302+
// `null` explicitly configures no control reporting (passing `undefined` would
303+
// fall back to the default below).
304+
controlBaseUrl: string | null = "http://127.0.0.1:5111",
302305
): LifecycleService {
303306
const reconciliation = new ReconciliationService({
304307
config,
@@ -310,7 +313,7 @@ function makeLifecycleService(
310313

311314
return new LifecycleService({
312315
projectRoot: repoRoot,
313-
controlBaseUrl: "http://127.0.0.1:5111",
316+
controlBaseUrl: controlBaseUrl ?? undefined,
314317
getControlToken: async () => "secret-token",
315318
config,
316319
archiveState: new ArchiveStateService(git.resolveWorktreeGitDir(repoRoot)),
@@ -422,6 +425,38 @@ describe("LifecycleService", () => {
422425
expect(state?.session.paneCount).toBe(2);
423426
});
424427

428+
it("writes no control.env when no control base URL is configured", async () => {
429+
const repoRoot = await initRepo();
430+
const runtime = new ProjectRuntime();
431+
const tmux = new FakeTmuxGateway();
432+
// The CLI leaves controlBaseUrl undefined when it can't resolve the project
433+
// prefix (no server running). We'd rather write no control.env than one with
434+
// a wrong (unrouted) URL — the dashboard rewrites it on next open/refresh.
435+
const lifecycle = makeLifecycleService(
436+
repoRoot,
437+
tmux,
438+
runtime,
439+
new FakeDockerGateway(),
440+
new FakeHookRunner(),
441+
TEST_CONFIG,
442+
new BunGitGateway(),
443+
new FakeAutoNameService(),
444+
{},
445+
{ listSessionIds: async () => [] },
446+
null,
447+
);
448+
449+
await lifecycle.createWorktree({ branch: "feature/search" });
450+
451+
const worktreePath = join(repoRoot, "__worktrees", "feature", "search");
452+
const gitDir = new BunGitGateway().resolveWorktreeGitDir(worktreePath);
453+
const paths = getWorktreeStoragePaths(gitDir);
454+
455+
expect(await Bun.file(paths.controlEnvPath).exists()).toBe(false);
456+
// The runtime env (unrelated to control reporting) is still written.
457+
expect(await Bun.file(paths.runtimeEnvPath).exists()).toBe(true);
458+
});
459+
425460
it("creates one managed worktree per selected agent from one task branch", async () => {
426461
const repoRoot = await initRepo();
427462
const runtime = new ProjectRuntime();
@@ -1859,7 +1894,7 @@ describe("LifecycleService", () => {
18591894
expect(run(["git", "branch", "--list", "feature-ahead"], repoRoot)).toBe("");
18601895
});
18611896

1862-
it("prunes all project worktrees", async () => {
1897+
it("prunes only closed worktrees, leaving open ones untouched", async () => {
18631898
const repoRoot = await initRepo();
18641899
const runtime = new ProjectRuntime();
18651900
const tmux = new FakeTmuxGateway();
@@ -1869,24 +1904,38 @@ describe("LifecycleService", () => {
18691904

18701905
await lifecycle.createWorktree({ branch: "feature-prune-host" });
18711906
await lifecycle.createWorktree({ branch: "feature-prune-docker", profile: "sandbox" });
1907+
// feature-prune-host is closed (no tmux window); feature-prune-docker stays open.
18721908
await lifecycle.closeWorktree("feature-prune-host");
18731909

18741910
const result = await lifecycle.pruneWorktrees();
18751911

1876-
expect(result.removedBranches).toEqual([
1877-
"feature-prune-docker",
1878-
"feature-prune-host",
1879-
]);
1880-
expect(hooks.calls.filter((call) => call.name === "preRemove").map((call) => call.cwd).sort()).toEqual([
1881-
join(repoRoot, "__worktrees", "feature-prune-docker"),
1912+
expect(result.removedBranches).toEqual(["feature-prune-host"]);
1913+
expect(hooks.calls.filter((call) => call.name === "preRemove").map((call) => call.cwd)).toEqual([
18821914
join(repoRoot, "__worktrees", "feature-prune-host"),
18831915
]);
1884-
expect(docker.removed).toEqual(["feature-prune-docker"]);
1885-
expect(new BunGitGateway().listWorktrees(repoRoot).filter((entry) => entry.path !== repoRoot)).toEqual([]);
1916+
expect(docker.removed).toEqual([]);
1917+
expect(new BunGitGateway().listWorktrees(repoRoot).filter((entry) => entry.path !== repoRoot).map((entry) => entry.branch)).toEqual([
1918+
"feature-prune-docker",
1919+
]);
18861920
expect(run(["git", "branch", "--list", "feature-prune-host"], repoRoot)).toBe("");
1887-
expect(run(["git", "branch", "--list", "feature-prune-docker"], repoRoot)).toBe("");
1888-
expect(tmux.listWindows()).toEqual([]);
1889-
expect(runtime.listWorktrees()).toEqual([]);
1921+
expect(run(["git", "branch", "--list", "feature-prune-docker"], repoRoot)).not.toBe("");
1922+
expect(runtime.listWorktrees().map((entry) => entry.branch)).toEqual(["feature-prune-docker"]);
1923+
});
1924+
1925+
it("prunes nothing when all worktrees are open", async () => {
1926+
const repoRoot = await initRepo();
1927+
const runtime = new ProjectRuntime();
1928+
const tmux = new FakeTmuxGateway();
1929+
const docker = new FakeDockerGateway();
1930+
const hooks = new FakeHookRunner();
1931+
const lifecycle = makeLifecycleService(repoRoot, tmux, runtime, docker, hooks);
1932+
1933+
await lifecycle.createWorktree({ branch: "feature-open" });
1934+
1935+
const result = await lifecycle.pruneWorktrees();
1936+
1937+
expect(result.removedBranches).toEqual([]);
1938+
expect(runtime.listWorktrees().map((entry) => entry.branch)).toEqual(["feature-open"]);
18901939
});
18911940

18921941
it("removes the sandbox container before deleting a docker worktree", async () => {

0 commit comments

Comments
 (0)