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
6 changes: 6 additions & 0 deletions FEATURES.md
Original file line number Diff line number Diff line change
Expand Up @@ -349,6 +349,12 @@ Open **Worktree Lifecycle** from a workspace menu or the command menu to:
worktrees. The default branch is queried from the remote, not cached `origin/HEAD`;
an unavailable remote or unresolved default branch stops creation.
- Inspect paths, open/closed state, branch status, and uncommitted counts.
- See GitHub PR / GitLab MR merge badges in **Worktree Lifecycle** and **Open
Worktree**, including checkouts without an open workspace. Expand a badge for
details and its last check time; use the list's Refresh button to query again.
No match, ambiguous matches, unsupported hosts, and unavailable CLI/auth or
requests are explicit states. Status uses host-side `gh` / `glab` authentication
and refreshes when the connection, repository, or checkout branch changes.
- Focus, pull, configure branch updates, or remove with confirmation, hooks,
process cleanup, and preservation of residual files when safe removal fails.

Expand Down
65 changes: 65 additions & 0 deletions server/src/workspace/files.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,71 @@ async function withTempDir<T>(fn: (dir: string) => Promise<T>) {
}

describe("workspace file handlers", () => {
test("PR/MR reads accept only available worktrees listed for the source repository, locally and over SSH", async () => {
for (const host of [undefined, "user@server"]) {
const processes: string[][] = [];
const target = "/repo with 'quote/topic";
const handlers = createFileHandlers({
herdr: {
call: async (method: string, params: Record<string, unknown>) => {
expect(params.workspace_id).toBe("w1");
if (method === "workspace.get")
return { workspace: { worktree: { checkout_path: "/repo" } } };
if (method === "worktree.list")
return {
worktrees: [
{ path: target },
{ path: "/repo/bare", is_bare: true },
{ path: "/repo/gone", is_prunable: true },
],
};
throw new Error(`unexpected call ${method}`);
},
} as any,
sshHost: () => host,
shQuote,
runProcessWithCodeTimeout: async (argv, _timeout, options) => {
processes.push(argv);
if (argv.join(" ").includes("rev-parse"))
return { code: 0, stdout: "/repo\n", stderr: "" };
if (host) {
expect(argv[0]).toBe("ssh");
expect(argv.at(-1)).toContain(`cd ${shQuote(target)} &&`);
} else {
expect(options?.cwd).toBe(target);
}
return { code: 1, stdout: "", stderr: "" };
},
});
const status = await handlers.readWorkspacePullRequest({
workspace_id: "w1",
worktree_path: target,
summary: true,
});
expect(status.root).toBe(target);
expect(status.state).toBe("detached");
for (const worktree_path of ["/other/repo", 42]) {
processes.length = 0;
await expect(
handlers.readWorkspacePullRequest({
workspace_id: "w1",
worktree_path,
}),
).rejects.toThrow("Worktree is unavailable");
expect(processes).toHaveLength(1);
}
for (const worktree_path of ["/repo/bare", "/repo/gone"]) {
processes.length = 0;
const result = await handlers.readWorkspacePullRequest({
workspace_id: "w1",
worktree_path,
});
expect(result.state).toBe("unsupported");
expect(result.message).toContain("Bare or prunable");
expect(processes).toHaveLength(1);
}
}
});
test("falls back to focused pane cwd when workspace has no checkout path", async () => {
await withTempDir(async (root) => {
await mkdir(join(root, "src"));
Expand Down
28 changes: 27 additions & 1 deletion server/src/workspace/files.ts
Original file line number Diff line number Diff line change
Expand Up @@ -601,7 +601,33 @@ export function createFileHandlers({
}

async function readWorkspacePullRequest(params: Record<string, unknown>) {
const { root } = await workspaceAndGitRoot(params, "git.pull_request");
let { root } = await workspaceAndGitRoot(params, "git.pull_request");
if (params.worktree_path !== undefined) {
const listed = (await herdr.call("worktree.list", {
workspace_id: params.workspace_id,
})) as {
worktrees?: {
path: string;
is_bare?: boolean;
is_prunable?: boolean;
}[];
};
const worktree = listed.worktrees?.find(
(entry) => entry.path === params.worktree_path,
);
if (!worktree)
throw new Error("Worktree is unavailable in this repository.");
if (worktree.is_bare || worktree.is_prunable)
return {
state: "unsupported" as const,
root: worktree.path,
branch: "",
remotes: [],
refreshedAt: new Date().toISOString(),
message: "Bare or prunable worktrees cannot resolve PR/MR status.",
};
root = worktree.path;
}
return readPullRequestStatus(
{ root, host: sshHost(), shQuote, runProcessWithCodeTimeout },
params,
Expand Down
61 changes: 61 additions & 0 deletions server/src/workspace/pull-request.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -180,6 +180,67 @@ describe("PR/MR status", () => {
});

for (const provider of ["github", "gitlab"] as const) {
test(`${provider} summarizes open, closed and merged requests without CI/review calls`, async () => {
for (const state of ["open", "closed", "merged"]) {
const f = fixture(provider, {
override: (command) => {
if (command.includes("'repos/team/project/pulls?"))
return ok([
{
...ghPR,
state: state === "merged" ? "closed" : state,
merged_at: state === "merged" ? "2026-10-01T00:00:00Z" : null,
},
]);
if (command.includes("'projects/2/merge_requests?"))
return ok([
{ ...glMR, state: state === "open" ? "opened" : state },
]);
},
});
const result = await f.read({ summary: true });
expect(result.state).toBe("ready");
expect(result.request?.state).toBe(
provider === "gitlab" && state === "open" ? "opened" : state,
);
expect(result.ci).toBeUndefined();
expect(result.review).toBeUndefined();
expect(
f.commands
.flat()
.some((arg) => arg === "view" || arg.includes("/merge_requests/7")),
).toBe(false);
}
});
test(`${provider} summaries reject branch changes and ambiguous merged matches`, async () => {
let reads = 0;
const changed = await fixture(provider, {
override: (command) =>
command.includes("'symbolic-ref'") && ++reads > 1
? ok("other")
: undefined,
}).read({ summary: true });
expect(changed.state).toBe("error");
expect(changed.request).toBeUndefined();
const ambiguous = await fixture(provider, {
override: (command) => {
if (command.includes("'repos/team/project/pulls?"))
return ok([
ghPR,
{
...ghPR,
number: 13,
state: "closed",
merged_at: "2026-10-01T00:00:00Z",
},
]);
if (command.includes("'projects/2/merge_requests?"))
return ok([glMR, { ...glMR, iid: 8, state: "merged" }]);
},
}).read({ summary: true });
expect(ambiguous.state).toBe("select_match");
expect(ambiguous.request).toBeUndefined();
});
test(`${provider} resolves a renamed local branch through its upstream over local and SSH`, async () => {
for (const ssh of [false, true]) {
const f = fixture(provider, {
Expand Down
4 changes: 2 additions & 2 deletions server/src/workspace/pull-request.ts
Original file line number Diff line number Diff line change
Expand Up @@ -367,7 +367,7 @@ export async function readPullRequestStatus(
);
result.request = request;
const [repository, id] = request.id.split("#");
if (provider === "github") {
if (params.summary !== true && provider === "github") {
const readField = (field: string) =>
json([
"gh",
Expand Down Expand Up @@ -395,7 +395,7 @@ export async function readPullRequestStatus(
review.status === "fulfilled"
? review.value
: "Unavailable (review request failed)";
} else {
} else if (params.summary !== true) {
const path = `projects/${repository}/merge_requests/${id}`;
const [ci, review] = await Promise.allSettled([
api(path).then((data) => {
Expand Down
5 changes: 5 additions & 0 deletions shared/pullRequest.ts
Original file line number Diff line number Diff line change
Expand Up @@ -41,3 +41,8 @@ export type PullRequestStatus = {
ci?: string;
review?: string;
};

export type PullRequestSummary = Pick<
PullRequestStatus,
"state" | "message" | "refreshedAt" | "provider" | "request"
>;
Loading
Loading