247 lines
9.2 KiB
TypeScript
247 lines
9.2 KiB
TypeScript
import { execFile } from "node:child_process";
|
|
import { realpath } from "node:fs/promises";
|
|
import { z } from "zod";
|
|
|
|
export async function command(args: string[], cwd: string, signal: AbortSignal) {
|
|
return new Promise<string>((resolve, reject) => {
|
|
execFile(
|
|
args[0]!,
|
|
args.slice(1),
|
|
{ cwd, signal, timeout: 30_000, maxBuffer: 2_000_000 },
|
|
(error, stdout, stderr) => {
|
|
if (error) reject(new Error(`${args[0]} failed: ${stderr || error.message}`));
|
|
else resolve(stdout.trim());
|
|
},
|
|
);
|
|
});
|
|
}
|
|
|
|
const inventory = z.object({
|
|
result: z.object({
|
|
source: z.object({
|
|
repo_key: z.string(),
|
|
repo_root: z.string(),
|
|
source_checkout_path: z.string(),
|
|
source_workspace_id: z.string(),
|
|
}),
|
|
worktrees: z.array(
|
|
z.object({
|
|
path: z.string(),
|
|
is_linked_worktree: z.boolean(),
|
|
is_bare: z.boolean(),
|
|
open_workspace_id: z.string().nullish(),
|
|
}),
|
|
),
|
|
}),
|
|
});
|
|
const workspaces = z.object({
|
|
result: z.object({ workspaces: z.array(z.object({ workspace_id: z.string() })) }),
|
|
});
|
|
const created = z.object({
|
|
result: z.object({
|
|
workspace: z.object({ workspace_id: z.string() }),
|
|
worktree: z.object({
|
|
path: z.string(),
|
|
branch: z.string(),
|
|
is_linked_worktree: z.literal(true),
|
|
}),
|
|
}),
|
|
});
|
|
export const rootSchema = z.object({
|
|
directory: z.string(),
|
|
workspaceID: z.string(),
|
|
repo: z.string(),
|
|
});
|
|
export type HerdrRoot = z.infer<typeof rootSchema>;
|
|
|
|
// The command seam also lets tests use an isolated CLI without touching a live server.
|
|
export class Herdr {
|
|
constructor(private readonly run = command) {}
|
|
|
|
async root(directory: string, signal: AbortSignal): Promise<HerdrRoot | undefined> {
|
|
signal = AbortSignal.any([signal, AbortSignal.timeout(5_000)]);
|
|
const canonical = await realpath(directory);
|
|
const list = workspaces.parse(
|
|
JSON.parse(await this.run(["herdr", "workspace", "list"], canonical, signal)),
|
|
);
|
|
const matches: HerdrRoot[] = [];
|
|
for (const workspace of list.result.workspaces) {
|
|
let value: z.infer<typeof inventory>;
|
|
try {
|
|
value = inventory.parse(
|
|
JSON.parse(
|
|
await this.run(
|
|
["herdr", "worktree", "list", "--workspace", workspace.workspace_id],
|
|
canonical,
|
|
signal,
|
|
),
|
|
),
|
|
);
|
|
} catch (error) {
|
|
if (signal.aborted) throw error;
|
|
// Non-repository workspaces cannot provide a worktree inventory.
|
|
continue;
|
|
}
|
|
const source = value.result.source;
|
|
if (source.source_workspace_id !== workspace.workspace_id) continue;
|
|
if (
|
|
(await realpath(source.source_checkout_path)) !== canonical ||
|
|
(await realpath(source.repo_root)) !== canonical
|
|
)
|
|
continue;
|
|
const checkout = value.result.worktrees.find(
|
|
(item) =>
|
|
item.open_workspace_id === workspace.workspace_id &&
|
|
!item.is_linked_worktree &&
|
|
!item.is_bare,
|
|
);
|
|
if (!checkout || (await realpath(checkout.path)) !== canonical) continue;
|
|
const repo = await realpath(source.repo_key);
|
|
const gitRepo = await this.run(
|
|
["git", "rev-parse", "--path-format=absolute", "--git-common-dir"],
|
|
canonical,
|
|
signal,
|
|
);
|
|
if ((await realpath(gitRepo)) !== repo) continue;
|
|
matches.push({ directory: canonical, workspaceID: workspace.workspace_id, repo });
|
|
}
|
|
// Never guess which Herdr workspace owns a duplicated checkout.
|
|
return matches.length === 1 ? matches[0] : undefined;
|
|
}
|
|
|
|
async create(
|
|
root: HerdrRoot,
|
|
base: string,
|
|
branch: string,
|
|
signal: AbortSignal,
|
|
label?: string,
|
|
) {
|
|
const current = await this.root(root.directory, signal);
|
|
if (!current || current.workspaceID !== root.workspaceID || current.repo !== root.repo)
|
|
throw new Error("Herdr root workspace is no longer eligible");
|
|
const ref = await this.run(
|
|
["git", "rev-parse", "--symbolic-full-name", "--verify", "--end-of-options", base],
|
|
root.directory,
|
|
signal,
|
|
);
|
|
if (!ref.startsWith("refs/heads/") && !ref.startsWith("refs/remotes/"))
|
|
throw new Error(
|
|
"base must identify a locally available branch or remote-tracking branch",
|
|
);
|
|
const commit = await this.run(
|
|
["git", "rev-parse", "--verify", "--end-of-options", `${ref}^{commit}`],
|
|
root.directory,
|
|
signal,
|
|
);
|
|
await this.run(["git", "check-ref-format", "--branch", branch], root.directory, signal);
|
|
if (
|
|
await this.run(
|
|
["git", "branch", "--list", "--format=%(refname)", branch],
|
|
root.directory,
|
|
signal,
|
|
)
|
|
)
|
|
throw new Error(`Destination branch already exists: ${branch}`);
|
|
const response = created.parse(
|
|
JSON.parse(
|
|
await this.run(
|
|
[
|
|
"herdr",
|
|
"worktree",
|
|
"create",
|
|
// Pin the repository even if a user repoints the workspace after discovery.
|
|
"--cwd",
|
|
root.directory,
|
|
"--branch",
|
|
branch,
|
|
"--base",
|
|
commit,
|
|
...(label === undefined ? [] : ["--label", label]),
|
|
"--no-focus",
|
|
],
|
|
root.directory,
|
|
signal,
|
|
),
|
|
),
|
|
);
|
|
const directory = await realpath(response.result.worktree.path);
|
|
const workspaceID = response.result.workspace.workspace_id;
|
|
try {
|
|
if (
|
|
response.result.worktree.branch !== branch ||
|
|
directory === root.directory ||
|
|
workspaceID === root.workspaceID
|
|
)
|
|
throw new Error("Herdr returned an unexpected checkout");
|
|
const actual = await this.run(["git", "rev-parse", "HEAD"], directory, signal);
|
|
const actualBranch = await this.run(
|
|
["git", "symbolic-ref", "--short", "HEAD"],
|
|
directory,
|
|
signal,
|
|
);
|
|
const repo = await this.run(
|
|
["git", "rev-parse", "--path-format=absolute", "--git-common-dir"],
|
|
directory,
|
|
signal,
|
|
);
|
|
if (
|
|
actual !== commit ||
|
|
actualBranch !== branch ||
|
|
(await realpath(repo)) !== root.repo
|
|
)
|
|
throw new Error(
|
|
"Created checkout does not match the requested repository and base",
|
|
);
|
|
await this.verify(directory, workspaceID, root, branch, signal);
|
|
} catch (error) {
|
|
throw new Error(
|
|
`Retained Herdr workspace ${workspaceID}, checkout ${directory}, branch ${branch}`,
|
|
{ cause: error },
|
|
);
|
|
}
|
|
return { directory, workspaceID, branch };
|
|
}
|
|
|
|
async verify(
|
|
directory: string,
|
|
workspaceID: string,
|
|
root: HerdrRoot,
|
|
branch: string,
|
|
signal: AbortSignal,
|
|
) {
|
|
const value = inventory.parse(
|
|
JSON.parse(
|
|
await this.run(
|
|
["herdr", "worktree", "list", "--workspace", workspaceID],
|
|
directory,
|
|
signal,
|
|
),
|
|
),
|
|
);
|
|
const checkout = value.result.worktrees.find(
|
|
(item) => item.open_workspace_id === workspaceID && item.is_linked_worktree,
|
|
);
|
|
const sourceCheckout = value.result.worktrees.find(
|
|
(item) =>
|
|
item.open_workspace_id === value.result.source.source_workspace_id &&
|
|
!item.is_linked_worktree &&
|
|
!item.is_bare,
|
|
);
|
|
if (
|
|
!checkout ||
|
|
!sourceCheckout ||
|
|
value.result.source.source_workspace_id !== root.workspaceID ||
|
|
(await realpath(value.result.source.source_checkout_path)) !== root.directory ||
|
|
(await realpath(sourceCheckout.path)) !== root.directory ||
|
|
(await realpath(checkout.path)) !== directory ||
|
|
(await realpath(value.result.source.repo_key)) !== root.repo
|
|
)
|
|
throw new Error("Bound Herdr checkout is no longer available");
|
|
if (
|
|
(await this.run(["git", "symbolic-ref", "--short", "HEAD"], directory, signal)) !==
|
|
branch
|
|
)
|
|
throw new Error("Bound Herdr checkout changed branches");
|
|
}
|
|
}
|