mirror of
https://github.com/getpaseo/paseo.git
synced 2026-10-28 20:22:45 +00:00
* feat(worktrees): copy included local files before setup and agents Keep local-file materialization in one built-in lifecycle hook so workspace setup and the first agent see the completed copy. Reuse the community plugin algorithm with the source checkout supplied by the worktree hook. Co-authored-by: Carlos Precioso <github@precioso.design> * test(plugins): make worktree lifecycle checks portable on Windows Compare source checkout filesystem identity across Git and Windows path spellings. Exercise copy rollback with a destination file blocking a directory, since Git for Windows accepts directories as exclude files. --------- Co-authored-by: Carlos Precioso <github@precioso.design>
72 lines
2.3 KiB
TypeScript
72 lines
2.3 KiB
TypeScript
// Derived from cprecioso/paseo-worktreeinclude (MIT); see ./LICENSE in the plugin root.
|
|
// Copyright (c) 2026 Carlos Precioso.
|
|
import { execFile } from "node:child_process";
|
|
import { constants, existsSync } from "node:fs";
|
|
import { copyFile, mkdir } from "node:fs/promises";
|
|
import { dirname, join } from "node:path";
|
|
import { promisify } from "node:util";
|
|
|
|
const exec = promisify(execFile);
|
|
const INCLUDE_FILE = ".worktreeinclude";
|
|
|
|
export type CopyResult =
|
|
| { kind: "no-include-file" }
|
|
| { kind: "copied"; copied: string[]; skipped: string[] };
|
|
|
|
async function git(cwd: string, args: string[], input?: string): Promise<string> {
|
|
const child = exec("git", args, { cwd, maxBuffer: 64 * 1024 * 1024 });
|
|
if (input !== undefined) {
|
|
child.child.stdin?.end(input);
|
|
}
|
|
const { stdout } = await child;
|
|
return stdout;
|
|
}
|
|
|
|
async function gitAllowExit1(cwd: string, args: string[], input: string): Promise<string> {
|
|
try {
|
|
return await git(cwd, args, input);
|
|
} catch (error) {
|
|
// `git check-ignore` exits 1 when nothing matched.
|
|
if ((error as { code?: number }).code === 1) return "";
|
|
throw error;
|
|
}
|
|
}
|
|
|
|
const splitNul = (s: string) => s.split("\0").filter(Boolean);
|
|
|
|
// Copy files that match .worktreeinclude (gitignore syntax)
|
|
// AND are gitignored, so tracked files are never touched.
|
|
export async function copyWorktreeIncludes(worktree: string, source: string): Promise<CopyResult> {
|
|
if (!existsSync(join(source, INCLUDE_FILE))) return { kind: "no-include-file" };
|
|
|
|
const matches = splitNul(
|
|
await git(source, [
|
|
"ls-files",
|
|
"--others",
|
|
"--ignored",
|
|
`--exclude-from=${INCLUDE_FILE}`,
|
|
"-z",
|
|
]),
|
|
);
|
|
if (matches.length === 0) return { kind: "copied", copied: [], skipped: [] };
|
|
|
|
const ignored = splitNul(
|
|
await gitAllowExit1(source, ["check-ignore", "--stdin", "-z"], matches.join("\0") + "\0"),
|
|
);
|
|
|
|
const copied: string[] = [];
|
|
const skipped: string[] = [];
|
|
for (const file of ignored) {
|
|
const dest = join(worktree, file);
|
|
if (existsSync(dest)) {
|
|
skipped.push(file);
|
|
continue;
|
|
}
|
|
await mkdir(dirname(dest), { recursive: true });
|
|
// Clone (APFS/btrfs copy-on-write) when supported, else fall back to a regular copy.
|
|
await copyFile(join(source, file), dest, constants.COPYFILE_FICLONE);
|
|
copied.push(file);
|
|
}
|
|
return { kind: "copied", copied, skipped };
|
|
}
|