Switch to double quotes and reformat codebase with Biome

This commit is contained in:
Mohamed Boudra
2026-03-21 20:22:15 +07:00
parent ab40786882
commit f6579dbeb4
805 changed files with 38132 additions and 40175 deletions
+38 -46
View File
@@ -55,9 +55,9 @@ describe("checkout git utilities", () => {
const nonGitDir = join(tempDir, "not-git");
execSync(`mkdir -p ${nonGitDir}`);
await expect(
getCheckoutDiff(nonGitDir, { mode: "uncommitted" })
).rejects.toBeInstanceOf(NotGitRepoError);
await expect(getCheckoutDiff(nonGitDir, { mode: "uncommitted" })).rejects.toBeInstanceOf(
NotGitRepoError,
);
});
it("handles status/diff/commit in a normal repo", async () => {
@@ -77,9 +77,7 @@ describe("checkout git utilities", () => {
const cleanStatus = await getCheckoutStatus(repoDir);
expect(cleanStatus.isDirty).toBe(false);
const message = execSync("git log -1 --pretty=%B", { cwd: repoDir })
.toString()
.trim();
const message = execSync("git log -1 --pretty=%B", { cwd: repoDir }).toString().trim();
expect(message).toBe("update file");
});
@@ -179,9 +177,7 @@ describe("checkout git utilities", () => {
await commitAll(repoDir, message);
const logMessage = execSync("git log -1 --pretty=%B", { cwd: repoDir })
.toString()
.trim();
const logMessage = execSync("git log -1 --pretty=%B", { cwd: repoDir }).toString().trim();
expect(logMessage).toBe(message);
});
@@ -211,7 +207,7 @@ describe("checkout git utilities", () => {
const diff = await getCheckoutDiff(repoDir, { mode: "uncommitted", includeStructured: true });
expect(diff.structured?.some((f) => f.path === "file.txt" && f.status === "too_large")).toBe(
true
true,
);
});
@@ -393,9 +389,7 @@ describe("checkout git utilities", () => {
writeFileSync(join(otherClone, "remote-only.txt"), "remote\n");
execSync("git add remote-only.txt", { cwd: otherClone });
execSync("git -c commit.gpgsign=false commit -m 'remote only'", { cwd: otherClone });
const remoteOnlyCommit = execSync("git rev-parse HEAD", { cwd: otherClone })
.toString()
.trim();
const remoteOnlyCommit = execSync("git rev-parse HEAD", { cwd: otherClone }).toString().trim();
execSync("git push", { cwd: otherClone });
execSync("git fetch origin", { cwd: repoDir });
@@ -419,9 +413,7 @@ describe("checkout git utilities", () => {
writeFileSync(join(repoDir, "local-only.txt"), "local\n");
execSync("git add local-only.txt", { cwd: repoDir });
execSync("git -c commit.gpgsign=false commit -m 'local only'", { cwd: repoDir });
const localOnlyCommit = execSync("git rev-parse HEAD", { cwd: repoDir })
.toString()
.trim();
const localOnlyCommit = execSync("git rev-parse HEAD", { cwd: repoDir }).toString().trim();
execSync(`git checkout -b feature ${localOnlyCommit}~1`, { cwd: repoDir });
writeFileSync(join(repoDir, "feature.txt"), "feature\n");
@@ -451,7 +443,7 @@ describe("checkout git utilities", () => {
execSync("git checkout feature", { cwd: repoDir });
await expect(
mergeFromBase(repoDir, { baseRef: "main", requireCleanTarget: true })
mergeFromBase(repoDir, { baseRef: "main", requireCleanTarget: true }),
).rejects.toBeInstanceOf(MergeFromBaseConflictError);
const porcelain = execSync("git status --porcelain", { cwd: repoDir }).toString().trim();
@@ -556,24 +548,24 @@ describe("checkout git utilities", () => {
[
"#!/usr/bin/env bash",
"set -euo pipefail",
"if [[ \"${1-}\" == \"--version\" ]]; then",
" echo \"gh version 2.0.0\"",
'if [[ "${1-}" == "--version" ]]; then',
' echo "gh version 2.0.0"',
" exit 0",
"fi",
"args=\"$*\"",
"if [[ \"$args\" == *\"state=open\"* ]]; then",
" echo \"[]\"",
'args="$*"',
'if [[ "$args" == *"state=open"* ]]; then',
' echo "[]"',
" exit 0",
"fi",
"if [[ \"$args\" == *\"state=closed\"* ]]; then",
" echo '[{\"html_url\":\"https://github.com/getpaseo/paseo/pull/123\",\"title\":\"Ship feature\",\"state\":\"closed\",\"merged_at\":\"2026-02-18T00:00:00Z\",\"base\":{\"ref\":\"main\"},\"head\":{\"ref\":\"feature\"}}]'",
'if [[ "$args" == *"state=closed"* ]]; then',
' echo \'[{"html_url":"https://github.com/getpaseo/paseo/pull/123","title":"Ship feature","state":"closed","merged_at":"2026-02-18T00:00:00Z","base":{"ref":"main"},"head":{"ref":"feature"}}]\'',
" exit 0",
"fi",
"echo \"unexpected gh args: $args\" >&2",
'echo "unexpected gh args: $args" >&2',
"exit 1",
"",
].join("\n"),
"utf8"
"utf8",
);
execSync(`chmod +x ${join(fakeBinDir, "gh")}`);
@@ -610,24 +602,24 @@ describe("checkout git utilities", () => {
[
"#!/usr/bin/env bash",
"set -euo pipefail",
"if [[ \"${1-}\" == \"--version\" ]]; then",
" echo \"gh version 2.0.0\"",
'if [[ "${1-}" == "--version" ]]; then',
' echo "gh version 2.0.0"',
" exit 0",
"fi",
"args=\"$*\"",
"if [[ \"$args\" == *\"state=open\"* ]]; then",
" echo \"[]\"",
'args="$*"',
'if [[ "$args" == *"state=open"* ]]; then',
' echo "[]"',
" exit 0",
"fi",
"if [[ \"$args\" == *\"state=closed\"* ]]; then",
" echo '[{\"html_url\":\"https://github.com/getpaseo/paseo/pull/999\",\"title\":\"Closed without merge\",\"state\":\"closed\",\"merged_at\":null,\"base\":{\"ref\":\"main\"},\"head\":{\"ref\":\"feature\"}}]'",
'if [[ "$args" == *"state=closed"* ]]; then',
' echo \'[{"html_url":"https://github.com/getpaseo/paseo/pull/999","title":"Closed without merge","state":"closed","merged_at":null,"base":{"ref":"main"},"head":{"ref":"feature"}}]\'',
" exit 0",
"fi",
"echo \"unexpected gh args: $args\" >&2",
'echo "unexpected gh args: $args" >&2',
"exit 1",
"",
].join("\n"),
"utf8"
"utf8",
);
execSync(`chmod +x ${join(fakeBinDir, "gh")}`);
@@ -670,9 +662,9 @@ describe("checkout git utilities", () => {
execSync("git checkout feature", { cwd: repoDir });
await expect(
mergeToBase(repoDir, { baseRef: "main" })
).rejects.toBeInstanceOf(MergeConflictError);
await expect(mergeToBase(repoDir, { baseRef: "main" })).rejects.toBeInstanceOf(
MergeConflictError,
);
});
it("uses stored baseRefName for Paseo worktrees (no heuristics)", async () => {
@@ -714,7 +706,9 @@ describe("checkout git utilities", () => {
execSync("git remote add origin https://github.com/acme/repo.git", { cwd: repoDir });
execSync("git update-ref refs/remotes/origin/main refs/heads/main", { cwd: repoDir });
execSync("git update-ref refs/remotes/origin/develop refs/heads/develop", { cwd: repoDir });
execSync("git symbolic-ref refs/remotes/origin/HEAD refs/remotes/origin/main", { cwd: repoDir });
execSync("git symbolic-ref refs/remotes/origin/HEAD refs/remotes/origin/main", {
cwd: repoDir,
});
await expect(resolveRepositoryDefaultBranch(repoDir)).resolves.toBe("main");
});
@@ -756,7 +750,7 @@ describe("checkout git utilities", () => {
execSync(`git merge-base --is-ancestor ${featureCommit} main`, {
cwd: repoDir,
stdio: "pipe",
})
}),
).toThrow();
});
@@ -772,12 +766,10 @@ describe("checkout git utilities", () => {
const metadataPath = getPaseoWorktreeMetadataPath(worktree.worktreePath);
rmSync(metadataPath, { force: true });
await expect(getCheckoutStatus(worktree.worktreePath, { paseoHome })).rejects.toThrow(
/base/i
);
await expect(getCheckoutDiff(worktree.worktreePath, { mode: "base" }, { paseoHome })).rejects.toThrow(
/base/i
);
await expect(getCheckoutStatus(worktree.worktreePath, { paseoHome })).rejects.toThrow(/base/i);
await expect(
getCheckoutDiff(worktree.worktreePath, { mode: "base" }, { paseoHome }),
).rejects.toThrow(/base/i);
await expect(mergeToBase(worktree.worktreePath, {}, { paseoHome })).rejects.toThrow(/base/i);
});
});
+80 -67
View File
@@ -17,7 +17,10 @@ const READ_ONLY_GIT_ENV: NodeJS.ProcessEnv = {
const SMALL_OUTPUT_MAX_BUFFER = 20 * 1024 * 1024; // 20MB
async function execGit(command: string, options: { cwd: string; env?: NodeJS.ProcessEnv }): Promise<{ stdout: string; stderr: string }> {
async function execGit(
command: string,
options: { cwd: string; env?: NodeJS.ProcessEnv },
): Promise<{ stdout: string; stderr: string }> {
return execAsync(command, { ...options, maxBuffer: SMALL_OUTPUT_MAX_BUFFER });
}
@@ -82,7 +85,11 @@ async function spawnLimitedText(params: {
child.on("close", (code, signal) => {
if (code !== null && !accept.has(code) && !truncated) {
rejectPromise(new Error(`Command failed: ${params.cmd} ${params.args.join(" ")} (code ${code})\n${stderrPreview}`));
rejectPromise(
new Error(
`Command failed: ${params.cmd} ${params.args.join(" ")} (code ${code})\n${stderrPreview}`,
),
);
return;
}
resolvePromise({
@@ -131,13 +138,10 @@ function normalizeBranchSuggestionName(raw: string): string | null {
}
async function listGitRefs(cwd: string, refPrefix: string): Promise<string[]> {
const { stdout } = await execGit(
`git for-each-ref --format="%(refname:short)" ${refPrefix}`,
{
cwd,
env: READ_ONLY_GIT_ENV,
}
);
const { stdout } = await execGit(`git for-each-ref --format="%(refname:short)" ${refPrefix}`, {
cwd,
env: READ_ONLY_GIT_ENV,
});
return stdout
.split("\n")
.map((line) => line.trim())
@@ -147,7 +151,7 @@ async function listGitRefs(cwd: string, refPrefix: string): Promise<string[]> {
function sortBranchSuggestions(
branchNames: string[],
localBranchNames: Set<string>,
query: string
query: string,
): string[] {
const normalizedQuery = query.trim().toLowerCase();
const hasQuery = normalizedQuery.length > 0;
@@ -175,7 +179,7 @@ function sortBranchSuggestions(
export async function listBranchSuggestions(
cwd: string,
options?: { query?: string; limit?: number }
options?: { query?: string; limit?: number },
): Promise<string[]> {
await requireGitRepo(cwd);
@@ -209,7 +213,7 @@ export async function listBranchSuggestions(
}
const filteredNames = Array.from(merged.keys()).filter((name) =>
query ? name.toLowerCase().includes(query) : true
query ? name.toLowerCase().includes(query) : true,
);
if (filteredNames.length === 0) {
return [];
@@ -233,7 +237,10 @@ async function listCheckoutFileChanges(cwd: string, ref: string): Promise<Checko
cwd,
env: READ_ONLY_GIT_ENV,
});
for (const line of nameStatusOut.split("\n").map((l) => l.trim()).filter(Boolean)) {
for (const line of nameStatusOut
.split("\n")
.map((l) => l.trim())
.filter(Boolean)) {
// `--name-status` uses TAB separators, which preserves filenames with spaces.
const tabParts = line.split("\t");
const rawStatus = (tabParts[0] ?? "").trim();
@@ -269,7 +276,10 @@ async function listCheckoutFileChanges(cwd: string, ref: string): Promise<Checko
cwd,
env: READ_ONLY_GIT_ENV,
});
for (const file of untrackedOut.split("\n").map((l) => l.trim()).filter(Boolean)) {
for (const file of untrackedOut
.split("\n")
.map((l) => l.trim())
.filter(Boolean)) {
changes.push({
path: file,
status: "U",
@@ -296,7 +306,10 @@ async function listCheckoutFileChanges(cwd: string, ref: string): Promise<Checko
async function tryResolveMergeBase(cwd: string, baseRef: string): Promise<string | null> {
try {
const { stdout } = await execGit(`git merge-base ${baseRef} HEAD`, { cwd, env: READ_ONLY_GIT_ENV });
const { stdout } = await execGit(`git merge-base ${baseRef} HEAD`, {
cwd,
env: READ_ONLY_GIT_ENV,
});
const sha = stdout.trim();
return sha.length > 0 ? sha : null;
} catch {
@@ -324,10 +337,7 @@ function normalizeNumstatPath(pathField: string): string {
const TRACKED_DIFF_NUMSTAT_MAX_BYTES = 2 * 1024 * 1024; // 2MB
const TRACKED_MAX_CHANGED_LINES = 40_000;
async function getTrackedNumstatByPath(
cwd: string,
ref: string
): Promise<Map<string, FileStat>> {
async function getTrackedNumstatByPath(cwd: string, ref: string): Promise<Map<string, FileStat>> {
const result = await spawnLimitedText({
cmd: "git",
args: ["diff", "--numstat", ref],
@@ -415,7 +425,7 @@ export class MergeFromBaseConflictError extends Error {
constructor(options: { baseRef: string; currentBranch: string; conflictFiles: string[] }) {
super(
`Merge conflict while merging ${options.baseRef} into ${options.currentBranch}. Please merge manually.`
`Merge conflict while merging ${options.baseRef} into ${options.currentBranch}. Please merge manually.`,
);
this.name = "MergeFromBaseConflictError";
this.baseRef = options.baseRef;
@@ -547,10 +557,10 @@ async function getCurrentBranch(cwd: string): Promise<string | null> {
async function getWorktreeRoot(cwd: string): Promise<string | null> {
try {
const { stdout } = await execAsync(
"git rev-parse --path-format=absolute --show-toplevel",
{ cwd, env: READ_ONLY_GIT_ENV }
);
const { stdout } = await execAsync("git rev-parse --path-format=absolute --show-toplevel", {
cwd,
env: READ_ONLY_GIT_ENV,
});
const root = stdout.trim();
return root.length > 0 ? root : null;
} catch {
@@ -561,7 +571,7 @@ async function getWorktreeRoot(cwd: string): Promise<string | null> {
async function getMainRepoRoot(cwd: string): Promise<string> {
const { stdout: commonDirOut } = await execAsync(
"git rev-parse --path-format=absolute --git-common-dir",
{ cwd, env: READ_ONLY_GIT_ENV }
{ cwd, env: READ_ONLY_GIT_ENV },
);
const commonDir = commonDirOut.trim();
const normalized = realpathSync(commonDir);
@@ -576,11 +586,9 @@ async function getMainRepoRoot(cwd: string): Promise<string> {
});
const worktrees = parseWorktreeList(worktreeOut);
const nonBareNonPaseo = worktrees.filter(
(wt) => !wt.isBare && !wt.path.includes("/.paseo/worktrees/")
);
const childrenOfBareRepo = nonBareNonPaseo.filter((wt) =>
wt.path.startsWith(normalized + "/")
(wt) => !wt.isBare && !wt.path.includes("/.paseo/worktrees/"),
);
const childrenOfBareRepo = nonBareNonPaseo.filter((wt) => wt.path.startsWith(normalized + "/"));
const mainChild = childrenOfBareRepo.find((wt) => basename(wt.path) === "main");
return mainChild?.path ?? childrenOfBareRepo[0]?.path ?? nonBareNonPaseo[0]?.path ?? normalized;
}
@@ -619,19 +627,14 @@ function parseWorktreeList(output: string): GitWorktreeEntry[] {
return entries;
}
async function getWorktreePathForBranch(
cwd: string,
branchName: string
): Promise<string | null> {
async function getWorktreePathForBranch(cwd: string, branchName: string): Promise<string | null> {
try {
const { stdout } = await execAsync("git worktree list --porcelain", {
cwd,
env: READ_ONLY_GIT_ENV,
});
const entries = parseWorktreeList(stdout);
const ref = branchName.startsWith("refs/heads/")
? branchName
: `refs/heads/${branchName}`;
const ref = branchName.startsWith("refs/heads/") ? branchName : `refs/heads/${branchName}`;
return entries.find((entry) => entry.branchRef === ref)?.path ?? null;
} catch {
return null;
@@ -640,7 +643,7 @@ async function getWorktreePathForBranch(
export async function renameCurrentBranch(
cwd: string,
newName: string
newName: string,
): Promise<{ previousBranch: string | null; currentBranch: string | null }> {
await requireGitRepo(cwd);
@@ -663,7 +666,7 @@ type ConfiguredBaseRefForCwd =
async function getConfiguredBaseRefForCwd(
cwd: string,
context?: CheckoutContext
context?: CheckoutContext,
): Promise<ConfiguredBaseRefForCwd> {
// Fast-path reject: non-worktree paths do not need expensive ownership checks.
if (!/[\\/]worktrees[\\/]/.test(cwd)) {
@@ -775,7 +778,10 @@ async function doesGitRefExist(cwd: string, fullRef: string): Promise<boolean> {
}
}
async function resolveBestComparisonBaseRef(cwd: string, normalizedBaseRef: string): Promise<string> {
async function resolveBestComparisonBaseRef(
cwd: string,
normalizedBaseRef: string,
): Promise<string> {
const [hasLocal, hasOrigin] = await Promise.all([
doesGitRefExist(cwd, `refs/heads/${normalizedBaseRef}`),
doesGitRefExist(cwd, `refs/remotes/origin/${normalizedBaseRef}`),
@@ -795,7 +801,7 @@ async function resolveBestComparisonBaseRef(cwd: string, normalizedBaseRef: stri
try {
const { stdout } = await execAsync(
`git rev-list --left-right --count ${normalizedBaseRef}...origin/${normalizedBaseRef}`,
{ cwd, env: READ_ONLY_GIT_ENV }
{ cwd, env: READ_ONLY_GIT_ENV },
);
const [localOnlyRaw, originOnlyRaw] = stdout.trim().split(/\s+/);
const localOnly = Number.parseInt(localOnlyRaw ?? "0", 10);
@@ -810,7 +816,11 @@ async function resolveBestComparisonBaseRef(cwd: string, normalizedBaseRef: stri
return normalizedBaseRef;
}
async function getAheadBehind(cwd: string, baseRef: string, currentBranch: string): Promise<AheadBehind | null> {
async function getAheadBehind(
cwd: string,
baseRef: string,
currentBranch: string,
): Promise<AheadBehind | null> {
const normalizedBaseRef = normalizeLocalBranchRefName(baseRef);
if (!normalizedBaseRef || !currentBranch || normalizedBaseRef === currentBranch) {
return null;
@@ -818,7 +828,7 @@ async function getAheadBehind(cwd: string, baseRef: string, currentBranch: strin
const comparisonBaseRef = await resolveBestComparisonBaseRef(cwd, normalizedBaseRef);
const { stdout } = await execAsync(
`git rev-list --left-right --count ${comparisonBaseRef}...${currentBranch}`,
{ cwd, env: READ_ONLY_GIT_ENV }
{ cwd, env: READ_ONLY_GIT_ENV },
);
const [behindRaw, aheadRaw] = stdout.trim().split(/\s+/);
const behind = Number.parseInt(behindRaw ?? "0", 10);
@@ -836,7 +846,7 @@ async function getAheadOfOrigin(cwd: string, currentBranch: string): Promise<num
try {
const { stdout } = await execAsync(
`git rev-list --count origin/${currentBranch}..${currentBranch}`,
{ cwd, env: READ_ONLY_GIT_ENV }
{ cwd, env: READ_ONLY_GIT_ENV },
);
const count = Number.parseInt(stdout.trim(), 10);
return Number.isNaN(count) ? null : count;
@@ -852,7 +862,7 @@ async function getBehindOfOrigin(cwd: string, currentBranch: string): Promise<nu
try {
const { stdout } = await execAsync(
`git rev-list --count ${currentBranch}..origin/${currentBranch}`,
{ cwd, env: READ_ONLY_GIT_ENV }
{ cwd, env: READ_ONLY_GIT_ENV },
);
const count = Number.parseInt(stdout.trim(), 10);
return Number.isNaN(count) ? null : count;
@@ -870,7 +880,7 @@ type CheckoutInspectionContext = {
async function inspectCheckoutContext(
cwd: string,
context?: CheckoutContext
context?: CheckoutContext,
): Promise<CheckoutInspectionContext | null> {
try {
const root = await getWorktreeRoot(cwd);
@@ -931,7 +941,7 @@ async function isLikelyBinaryFile(absolutePath: string): Promise<boolean> {
async function inspectUntrackedFile(
cwd: string,
relativePath: string
relativePath: string,
): Promise<{ stat: FileStat; truncated: boolean }> {
const absolutePath = resolve(cwd, relativePath);
const metadata = await statFile(absolutePath);
@@ -962,7 +972,7 @@ async function inspectUntrackedFile(
function buildPlaceholderParsedDiffFile(
change: CheckoutFileChange,
options: { status: "too_large" | "binary"; stat?: FileStat }
options: { status: "too_large" | "binary"; stat?: FileStat },
): ParsedDiffFile {
return {
path: change.path,
@@ -977,7 +987,7 @@ function buildPlaceholderParsedDiffFile(
async function getUntrackedDiffText(
cwd: string,
change: CheckoutFileChange
change: CheckoutFileChange,
): Promise<{ text: string; truncated: boolean; stat: FileStat }> {
try {
const inspected = await inspectUntrackedFile(cwd, change.path);
@@ -1005,7 +1015,7 @@ async function getUntrackedDiffText(
export async function getCheckoutStatus(
cwd: string,
context?: CheckoutContext
context?: CheckoutContext,
): Promise<CheckoutStatusResult> {
const inspected = await inspectCheckoutContext(cwd, context);
if (!inspected) {
@@ -1060,7 +1070,7 @@ export async function getCheckoutStatus(
export async function getCheckoutStatusLite(
cwd: string,
context?: CheckoutContext
context?: CheckoutContext,
): Promise<CheckoutStatusLiteResult> {
const inspected = await inspectCheckoutContext(cwd, context);
if (!inspected) {
@@ -1099,7 +1109,7 @@ export interface CheckoutShortstat {
export async function getCheckoutShortstat(
cwd: string,
context?: CheckoutContext
context?: CheckoutContext,
): Promise<CheckoutShortstat | null> {
try {
await requireGitRepo(cwd);
@@ -1117,7 +1127,7 @@ export async function getCheckoutShortstat(
// Feature branch: diff against the merge-base with the base branch
const comparisonBaseRef = await resolveBestComparisonBaseRef(
cwd,
normalizeLocalBranchRefName(localBaseRef)
normalizeLocalBranchRefName(localBaseRef),
);
try {
@@ -1179,7 +1189,7 @@ export async function getCheckoutShortstat(
export async function getCheckoutDiff(
cwd: string,
compare: CheckoutDiffCompare,
context?: CheckoutContext
context?: CheckoutContext,
): Promise<CheckoutDiffResult> {
await requireGitRepo(cwd);
@@ -1231,7 +1241,9 @@ export async function getCheckoutDiff(
const untrackedChanges = changes.filter((change) => change.isUntracked === true);
const trackedNumstatByPath =
trackedChanges.length > 0 ? await getTrackedNumstatByPath(cwd, refForDiff) : new Map<string, FileStat>();
trackedChanges.length > 0
? await getTrackedNumstatByPath(cwd, refForDiff)
: new Map<string, FileStat>();
const trackedDiffPaths: string[] = [];
const trackedPlaceholderByPath = new Map<
string,
@@ -1269,7 +1281,10 @@ export async function getCheckoutDiff(
}
}
const appendTrackedPlaceholderComment = (change: CheckoutFileChange, status: "binary" | "too_large") => {
const appendTrackedPlaceholderComment = (
change: CheckoutFileChange,
status: "binary" | "too_large",
) => {
if (status === "binary") {
appendDiff(`# ${change.path}: binary diff omitted\n`);
return;
@@ -1289,7 +1304,7 @@ export async function getCheckoutDiff(
buildPlaceholderParsedDiffFile(change, {
status: placeholder.status,
stat: placeholder.stat,
})
}),
);
appendTrackedPlaceholderComment(change, placeholder.status);
continue;
@@ -1386,7 +1401,7 @@ export async function getCheckoutDiff(
export async function commitChanges(
cwd: string,
options: { message: string; addAll?: boolean }
options: { message: string; addAll?: boolean },
): Promise<void> {
await requireGitRepo(cwd);
if (options.addAll ?? true) {
@@ -1404,7 +1419,7 @@ export async function commitAll(cwd: string, message: string): Promise<void> {
export async function mergeToBase(
cwd: string,
options: MergeToBaseOptions = {},
context?: CheckoutContext
context?: CheckoutContext,
): Promise<void> {
await requireGitRepo(cwd);
const currentBranch = await getCurrentBranch(cwd);
@@ -1437,11 +1452,9 @@ export async function mergeToBase(
await execAsync(`git merge --squash ${currentBranch}`, { cwd: operationCwd });
const message =
options.commitMessage ?? `Squash merge ${currentBranch} into ${normalizedBaseRef}`;
await execFileAsync(
"git",
["-c", "commit.gpgsign=false", "commit", "-m", message],
{ cwd: operationCwd }
);
await execFileAsync("git", ["-c", "commit.gpgsign=false", "commit", "-m", message], {
cwd: operationCwd,
});
} else {
await execAsync(`git merge ${currentBranch}`, { cwd: operationCwd });
}
@@ -1510,7 +1523,7 @@ export async function mergeToBase(
export async function mergeFromBase(
cwd: string,
options: MergeFromBaseOptions = {},
context?: CheckoutContext
context?: CheckoutContext,
): Promise<void> {
await requireGitRepo(cwd);
const currentBranch = await getCurrentBranch(cwd);
@@ -1722,7 +1735,7 @@ async function listPullRequestsForHead(options: {
"-F",
`state=${options.state}`,
],
{ cwd: options.cwd }
{ cwd: options.cwd },
);
const parsed = JSON.parse(stdout.trim());
return Array.isArray(parsed) ? parsed : [];
@@ -1761,7 +1774,7 @@ function buildPullRequestStatus(current: any, fallbackHead: string): PullRequest
export async function createPullRequest(
cwd: string,
options: CreatePullRequestOptions
options: CreatePullRequestOptions,
): Promise<{ url: string; number: number }> {
await requireGitRepo(cwd);
await ensureGhAvailable(cwd);
@@ -1870,7 +1883,7 @@ export async function getPullRequestStatus(cwd: string): Promise<PullRequestStat
entry &&
typeof entry === "object" &&
typeof entry.merged_at === "string" &&
entry.merged_at.trim().length > 0
entry.merged_at.trim().length > 0,
) ?? null;
const mergedStatus = buildPullRequestStatus(mergedClosedPull, head);
if (!mergedStatus) {
@@ -1,266 +1,266 @@
import { mkdtempSync, mkdirSync, realpathSync, rmSync, symlinkSync, writeFileSync } from 'node:fs'
import { tmpdir } from 'node:os'
import path from 'node:path'
import { afterEach, beforeEach, describe, expect, it } from 'vitest'
import { searchHomeDirectories, searchWorkspaceEntries } from './directory-suggestions.js'
import { mkdtempSync, mkdirSync, realpathSync, rmSync, symlinkSync, writeFileSync } from "node:fs";
import { tmpdir } from "node:os";
import path from "node:path";
import { afterEach, beforeEach, describe, expect, it } from "vitest";
import { searchHomeDirectories, searchWorkspaceEntries } from "./directory-suggestions.js";
describe('searchHomeDirectories', () => {
let tempRoot: string
let homeDir: string
let outsideDir: string
describe("searchHomeDirectories", () => {
let tempRoot: string;
let homeDir: string;
let outsideDir: string;
beforeEach(() => {
tempRoot = realpathSync(mkdtempSync(path.join(tmpdir(), 'directory-suggestions-')))
homeDir = path.join(tempRoot, 'home')
outsideDir = path.join(tempRoot, 'outside')
tempRoot = realpathSync(mkdtempSync(path.join(tmpdir(), "directory-suggestions-")));
homeDir = path.join(tempRoot, "home");
outsideDir = path.join(tempRoot, "outside");
mkdirSync(homeDir, { recursive: true })
mkdirSync(outsideDir, { recursive: true })
mkdirSync(homeDir, { recursive: true });
mkdirSync(outsideDir, { recursive: true });
mkdirSync(path.join(homeDir, 'projects', 'paseo'), { recursive: true })
mkdirSync(path.join(homeDir, 'projects', 'playground'), { recursive: true })
mkdirSync(path.join(homeDir, 'documents', 'plans'), { recursive: true })
mkdirSync(path.join(homeDir, '.hidden', 'cache'), { recursive: true })
writeFileSync(path.join(homeDir, 'projects', 'README.md'), 'not a directory\n')
mkdirSync(path.join(homeDir, "projects", "paseo"), { recursive: true });
mkdirSync(path.join(homeDir, "projects", "playground"), { recursive: true });
mkdirSync(path.join(homeDir, "documents", "plans"), { recursive: true });
mkdirSync(path.join(homeDir, ".hidden", "cache"), { recursive: true });
writeFileSync(path.join(homeDir, "projects", "README.md"), "not a directory\n");
mkdirSync(path.join(outsideDir, 'outside-match'), { recursive: true })
symlinkSync(path.join(outsideDir, 'outside-match'), path.join(homeDir, 'outside-link'))
})
mkdirSync(path.join(outsideDir, "outside-match"), { recursive: true });
symlinkSync(path.join(outsideDir, "outside-match"), path.join(homeDir, "outside-link"));
});
afterEach(() => {
rmSync(tempRoot, { recursive: true, force: true })
})
rmSync(tempRoot, { recursive: true, force: true });
});
it('returns an empty list for blank queries', async () => {
it("returns an empty list for blank queries", async () => {
await expect(
searchHomeDirectories({
homeDir,
query: ' ',
query: " ",
limit: 10,
})
).resolves.toEqual([])
})
}),
).resolves.toEqual([]);
});
it('returns only existing directories', async () => {
it("returns only existing directories", async () => {
const results = await searchHomeDirectories({
homeDir,
query: 'proj',
query: "proj",
limit: 10,
})
});
expect(results).toContain(path.join(homeDir, 'projects'))
expect(results).toContain(path.join(homeDir, 'projects', 'paseo'))
expect(results).not.toContain(path.join(homeDir, 'projects', 'README.md'))
})
expect(results).toContain(path.join(homeDir, "projects"));
expect(results).toContain(path.join(homeDir, "projects", "paseo"));
expect(results).not.toContain(path.join(homeDir, "projects", "README.md"));
});
it('supports home-relative path query syntax', async () => {
it("supports home-relative path query syntax", async () => {
const results = await searchHomeDirectories({
homeDir,
query: '~/projects/pa',
query: "~/projects/pa",
limit: 10,
})
});
expect(results).toEqual([path.join(homeDir, 'projects', 'paseo')])
})
expect(results).toEqual([path.join(homeDir, "projects", "paseo")]);
});
it('prioritizes exact segment matches before segment-prefix matches', async () => {
const exactSegmentPath = path.join(homeDir, 'something', 'faro', 'something-else')
const prefixSegmentPath = path.join(homeDir, 'something', 'somethingelse', 'faro-bla')
mkdirSync(exactSegmentPath, { recursive: true })
mkdirSync(prefixSegmentPath, { recursive: true })
it("prioritizes exact segment matches before segment-prefix matches", async () => {
const exactSegmentPath = path.join(homeDir, "something", "faro", "something-else");
const prefixSegmentPath = path.join(homeDir, "something", "somethingelse", "faro-bla");
mkdirSync(exactSegmentPath, { recursive: true });
mkdirSync(prefixSegmentPath, { recursive: true });
const results = await searchHomeDirectories({
homeDir,
query: 'faro',
query: "faro",
limit: 30,
})
});
const exactIndex = results.indexOf(exactSegmentPath)
const prefixIndex = results.indexOf(prefixSegmentPath)
expect(exactIndex).toBeGreaterThanOrEqual(0)
expect(prefixIndex).toBeGreaterThanOrEqual(0)
expect(exactIndex).toBeLessThan(prefixIndex)
})
const exactIndex = results.indexOf(exactSegmentPath);
const prefixIndex = results.indexOf(prefixSegmentPath);
expect(exactIndex).toBeGreaterThanOrEqual(0);
expect(prefixIndex).toBeGreaterThanOrEqual(0);
expect(exactIndex).toBeLessThan(prefixIndex);
});
it('prioritizes partial matches that appear earlier in the path', async () => {
const earlierPath = path.join(homeDir, 'farofoo')
const laterPath = path.join(homeDir, 'x', 'y', 'farofoo')
mkdirSync(earlierPath, { recursive: true })
mkdirSync(laterPath, { recursive: true })
it("prioritizes partial matches that appear earlier in the path", async () => {
const earlierPath = path.join(homeDir, "farofoo");
const laterPath = path.join(homeDir, "x", "y", "farofoo");
mkdirSync(earlierPath, { recursive: true });
mkdirSync(laterPath, { recursive: true });
const results = await searchHomeDirectories({
homeDir,
query: 'arofo',
query: "arofo",
limit: 30,
})
});
const earlierIndex = results.indexOf(earlierPath)
const laterIndex = results.indexOf(laterPath)
expect(earlierIndex).toBeGreaterThanOrEqual(0)
expect(laterIndex).toBeGreaterThanOrEqual(0)
expect(earlierIndex).toBeLessThan(laterIndex)
})
const earlierIndex = results.indexOf(earlierPath);
const laterIndex = results.indexOf(laterPath);
expect(earlierIndex).toBeGreaterThanOrEqual(0);
expect(laterIndex).toBeGreaterThanOrEqual(0);
expect(earlierIndex).toBeLessThan(laterIndex);
});
it("returns home-root suggestions when query is '~'", async () => {
const results = await searchHomeDirectories({
homeDir,
query: '~',
query: "~",
limit: 20,
})
});
expect(results).toContain(path.join(homeDir, 'projects'))
expect(results).toContain(path.join(homeDir, 'documents'))
expect(results).not.toContain(path.join(homeDir, '.hidden'))
})
expect(results).toContain(path.join(homeDir, "projects"));
expect(results).toContain(path.join(homeDir, "documents"));
expect(results).not.toContain(path.join(homeDir, ".hidden"));
});
it('does not return hidden directories during tree search', async () => {
it("does not return hidden directories during tree search", async () => {
const results = await searchHomeDirectories({
homeDir,
query: 'cache',
query: "cache",
limit: 20,
})
});
expect(results).not.toContain(path.join(homeDir, '.hidden', 'cache'))
})
expect(results).not.toContain(path.join(homeDir, ".hidden", "cache"));
});
it('does not return paths that escape home through symlinks', async () => {
it("does not return paths that escape home through symlinks", async () => {
const results = await searchHomeDirectories({
homeDir,
query: 'outside',
query: "outside",
limit: 20,
})
});
expect(results).not.toContain(path.join(homeDir, 'outside-link'))
expect(results).not.toContain(path.join(outsideDir, 'outside-match'))
})
expect(results).not.toContain(path.join(homeDir, "outside-link"));
expect(results).not.toContain(path.join(outsideDir, "outside-match"));
});
it('respects the result limit', async () => {
it("respects the result limit", async () => {
const results = await searchHomeDirectories({
homeDir,
query: 'p',
query: "p",
limit: 1,
})
});
expect(results).toHaveLength(1)
})
})
expect(results).toHaveLength(1);
});
});
describe('searchWorkspaceEntries', () => {
let tempRoot: string
let workspaceDir: string
let outsideDir: string
describe("searchWorkspaceEntries", () => {
let tempRoot: string;
let workspaceDir: string;
let outsideDir: string;
beforeEach(() => {
tempRoot = realpathSync(mkdtempSync(path.join(tmpdir(), 'workspace-suggestions-')))
workspaceDir = path.join(tempRoot, 'workspace')
outsideDir = path.join(tempRoot, 'outside')
tempRoot = realpathSync(mkdtempSync(path.join(tmpdir(), "workspace-suggestions-")));
workspaceDir = path.join(tempRoot, "workspace");
outsideDir = path.join(tempRoot, "outside");
mkdirSync(path.join(workspaceDir, 'src', 'components'), {
mkdirSync(path.join(workspaceDir, "src", "components"), {
recursive: true,
})
mkdirSync(path.join(workspaceDir, 'docs'), { recursive: true })
mkdirSync(path.join(outsideDir, 'escaped'), { recursive: true })
});
mkdirSync(path.join(workspaceDir, "docs"), { recursive: true });
mkdirSync(path.join(outsideDir, "escaped"), { recursive: true });
writeFileSync(path.join(workspaceDir, 'README.md'), '# paseo\n')
writeFileSync(path.join(workspaceDir, "README.md"), "# paseo\n");
writeFileSync(
path.join(workspaceDir, 'src', 'components', 'chat-input.tsx'),
'export const ChatInput = null;\n'
)
writeFileSync(path.join(workspaceDir, 'docs', 'notes.md'), 'notes\n')
path.join(workspaceDir, "src", "components", "chat-input.tsx"),
"export const ChatInput = null;\n",
);
writeFileSync(path.join(workspaceDir, "docs", "notes.md"), "notes\n");
symlinkSync(path.join(outsideDir, 'escaped'), path.join(workspaceDir, 'escaped-link'))
})
symlinkSync(path.join(outsideDir, "escaped"), path.join(workspaceDir, "escaped-link"));
});
afterEach(() => {
rmSync(tempRoot, { recursive: true, force: true })
})
rmSync(tempRoot, { recursive: true, force: true });
});
it('returns relative file and directory suggestions for workspace queries', async () => {
it("returns relative file and directory suggestions for workspace queries", async () => {
const results = await searchWorkspaceEntries({
cwd: workspaceDir,
query: 'chat',
query: "chat",
limit: 20,
includeFiles: true,
includeDirectories: true,
})
});
expect(results).toContainEqual({
path: 'src/components/chat-input.tsx',
kind: 'file',
})
expect(results.some((entry) => entry.path === path.join(workspaceDir, 'src'))).toBe(false)
})
path: "src/components/chat-input.tsx",
kind: "file",
});
expect(results.some((entry) => entry.path === path.join(workspaceDir, "src"))).toBe(false);
});
it('filters entries by kind', async () => {
it("filters entries by kind", async () => {
const dirsOnly = await searchWorkspaceEntries({
cwd: workspaceDir,
query: 'src',
query: "src",
limit: 20,
includeFiles: false,
includeDirectories: true,
})
expect(dirsOnly.some((entry) => entry.kind === 'file')).toBe(false)
expect(dirsOnly.some((entry) => entry.path === 'src')).toBe(true)
});
expect(dirsOnly.some((entry) => entry.kind === "file")).toBe(false);
expect(dirsOnly.some((entry) => entry.path === "src")).toBe(true);
const filesOnly = await searchWorkspaceEntries({
cwd: workspaceDir,
query: 'readme',
query: "readme",
limit: 20,
includeFiles: true,
includeDirectories: false,
})
expect(filesOnly).toEqual([{ path: 'README.md', kind: 'file' }])
})
});
expect(filesOnly).toEqual([{ path: "README.md", kind: "file" }]);
});
it('supports path-style queries and does not escape cwd through symlinks', async () => {
it("supports path-style queries and does not escape cwd through symlinks", async () => {
const pathResults = await searchWorkspaceEntries({
cwd: workspaceDir,
query: 'src/co',
query: "src/co",
limit: 20,
includeFiles: true,
includeDirectories: true,
})
});
expect(pathResults).toContainEqual({
path: 'src/components',
kind: 'directory',
})
path: "src/components",
kind: "directory",
});
const escapedResults = await searchWorkspaceEntries({
cwd: workspaceDir,
query: 'escaped',
query: "escaped",
limit: 20,
includeFiles: true,
includeDirectories: true,
})
expect(escapedResults.some((entry) => entry.path.includes('escaped-link'))).toBe(false)
})
});
expect(escapedResults.some((entry) => entry.path.includes("escaped-link"))).toBe(false);
});
it('ignores node_modules entries so deep workspace files still resolve under scan limits', async () => {
mkdirSync(path.join(workspaceDir, 'packages', 'app', 'src', 'app'), { recursive: true })
writeFileSync(path.join(workspaceDir, 'packages', 'app', 'src', 'app', '_layout.tsx'), '')
it("ignores node_modules entries so deep workspace files still resolve under scan limits", async () => {
mkdirSync(path.join(workspaceDir, "packages", "app", "src", "app"), { recursive: true });
writeFileSync(path.join(workspaceDir, "packages", "app", "src", "app", "_layout.tsx"), "");
for (let index = 0; index < 120; index += 1) {
mkdirSync(path.join(workspaceDir, 'node_modules', `pkg-${index}`), { recursive: true })
mkdirSync(path.join(workspaceDir, "node_modules", `pkg-${index}`), { recursive: true });
writeFileSync(
path.join(workspaceDir, 'node_modules', `pkg-${index}`, 'index.js'),
'module.exports = {};\n'
)
path.join(workspaceDir, "node_modules", `pkg-${index}`, "index.js"),
"module.exports = {};\n",
);
}
writeFileSync(path.join(workspaceDir, 'node_modules', '_layout.tsx'), '')
writeFileSync(path.join(workspaceDir, "node_modules", "_layout.tsx"), "");
const results = await searchWorkspaceEntries({
cwd: workspaceDir,
query: '_layout.tsx',
query: "_layout.tsx",
limit: 20,
includeFiles: true,
includeDirectories: true,
maxEntriesScanned: 60,
})
});
expect(results).toContainEqual({
path: 'packages/app/src/app/_layout.tsx',
kind: 'file',
})
expect(results.some((entry) => entry.path.startsWith('node_modules/'))).toBe(false)
})
})
path: "packages/app/src/app/_layout.tsx",
kind: "file",
});
expect(results.some((entry) => entry.path.startsWith("node_modules/"))).toBe(false);
});
});
File diff suppressed because it is too large Load Diff
+110 -24
View File
@@ -2,7 +2,14 @@ import { describe, it, expect, beforeEach, afterEach } from "vitest";
import { mkdtempSync, rmSync, writeFileSync, mkdirSync, realpathSync } from "fs";
import { join } from "path";
import { tmpdir } from "os";
import { findProjectIcon, getProjectIcon, ICON_PATTERNS, PRIORITY_DIRS, IGNORED_DIRS, MONOREPO_PACKAGE_DIRS } from "./project-icon.js";
import {
findProjectIcon,
getProjectIcon,
ICON_PATTERNS,
PRIORITY_DIRS,
IGNORED_DIRS,
MONOREPO_PACKAGE_DIRS,
} from "./project-icon.js";
function createTempDir(): string {
return realpathSync(mkdtempSync(join(tmpdir(), "project-icon-test-")));
@@ -262,33 +269,112 @@ describe("getProjectIcon", () => {
// Valid 1x1 PNG (square)
const squarePng = Buffer.from([
0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a, // PNG signature
0x00, 0x00, 0x00, 0x0d, // IHDR chunk length
0x49, 0x48, 0x44, 0x52, // IHDR
0x00, 0x00, 0x00, 0x01, // width: 1
0x00, 0x00, 0x00, 0x01, // height: 1
0x08, 0x02, // bit depth, color type
0x00, 0x00, 0x00, // compression, filter, interlace
0x90, 0x77, 0x53, 0xde, // CRC
0x00, 0x00, 0x00, 0x0c, // IDAT chunk length
0x49, 0x44, 0x41, 0x54, // IDAT
0x08, 0xd7, 0x63, 0xf8, 0xff, 0xff, 0xff, 0x00, 0x05, 0xfe, 0x02, 0xfe, // data
0xa3, 0x6c, 0x47, 0x9f, // CRC
0x00, 0x00, 0x00, 0x00, // IEND chunk length
0x49, 0x45, 0x4e, 0x44, // IEND
0xae, 0x42, 0x60, 0x82, // CRC
0x89,
0x50,
0x4e,
0x47,
0x0d,
0x0a,
0x1a,
0x0a, // PNG signature
0x00,
0x00,
0x00,
0x0d, // IHDR chunk length
0x49,
0x48,
0x44,
0x52, // IHDR
0x00,
0x00,
0x00,
0x01, // width: 1
0x00,
0x00,
0x00,
0x01, // height: 1
0x08,
0x02, // bit depth, color type
0x00,
0x00,
0x00, // compression, filter, interlace
0x90,
0x77,
0x53,
0xde, // CRC
0x00,
0x00,
0x00,
0x0c, // IDAT chunk length
0x49,
0x44,
0x41,
0x54, // IDAT
0x08,
0xd7,
0x63,
0xf8,
0xff,
0xff,
0xff,
0x00,
0x05,
0xfe,
0x02,
0xfe, // data
0xa3,
0x6c,
0x47,
0x9f, // CRC
0x00,
0x00,
0x00,
0x00, // IEND chunk length
0x49,
0x45,
0x4e,
0x44, // IEND
0xae,
0x42,
0x60,
0x82, // CRC
]);
// Valid 2x1 PNG (non-square)
const nonSquarePng = Buffer.from([
0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a, // PNG signature
0x00, 0x00, 0x00, 0x0d, // IHDR chunk length
0x49, 0x48, 0x44, 0x52, // IHDR
0x00, 0x00, 0x00, 0x02, // width: 2
0x00, 0x00, 0x00, 0x01, // height: 1
0x08, 0x02, // bit depth, color type
0x00, 0x00, 0x00, // compression, filter, interlace
0x00, 0x00, 0x00, 0x00, // CRC (not validated)
0x89,
0x50,
0x4e,
0x47,
0x0d,
0x0a,
0x1a,
0x0a, // PNG signature
0x00,
0x00,
0x00,
0x0d, // IHDR chunk length
0x49,
0x48,
0x44,
0x52, // IHDR
0x00,
0x00,
0x00,
0x02, // width: 2
0x00,
0x00,
0x00,
0x01, // height: 1
0x08,
0x02, // bit depth, color type
0x00,
0x00,
0x00, // compression, filter, interlace
0x00,
0x00,
0x00,
0x00, // CRC (not validated)
]);
it("returns icon data for square PNG", async () => {
+11 -29
View File
@@ -25,14 +25,7 @@ export const ICON_PATTERNS = [
/**
* Directories or directory paths to search first (in priority order).
*/
export const PRIORITY_DIRS = [
"public",
"static",
"priv/static",
"assets",
"images",
"img",
];
export const PRIORITY_DIRS = ["public", "static", "priv/static", "assets", "images", "img"];
/**
* Monorepo package directory patterns to scan (e.g., packages/app, apps/web).
@@ -194,18 +187,13 @@ function getMimeType(filename: string): string {
function matchesPattern(filename: string, pattern: string): boolean {
if (pattern.includes("*")) {
// Convert glob pattern to regex
const regexPattern = pattern
.replace(/\./g, "\\.")
.replace(/\*/g, ".*");
const regexPattern = pattern.replace(/\./g, "\\.").replace(/\*/g, ".*");
return new RegExp(`^${regexPattern}$`).test(filename);
}
return filename === pattern;
}
async function findIconInDir(
dir: string,
patterns: string[]
): Promise<string | null> {
async function findIconInDir(dir: string, patterns: string[]): Promise<string | null> {
let entries: string[];
try {
entries = await readdir(dir);
@@ -238,7 +226,7 @@ async function searchDirRecursively(
patterns: string[],
ignoredDirs: Set<string>,
maxDepth: number,
currentDepth: number = 0
currentDepth: number = 0,
): Promise<string | null> {
if (currentDepth > maxDepth) {
return null;
@@ -272,7 +260,7 @@ async function searchDirRecursively(
patterns,
ignoredDirs,
maxDepth,
currentDepth + 1
currentDepth + 1,
);
if (result) {
return result;
@@ -296,7 +284,7 @@ async function searchDirRecursively(
*/
export async function findProjectIcon(
projectDir: string,
maxDepth: number = 3
maxDepth: number = 3,
): Promise<string | null> {
const ignoredDirsSet = new Set(IGNORED_DIRS);
@@ -310,7 +298,7 @@ export async function findProjectIcon(
priorityPath,
ICON_PATTERNS,
ignoredDirsSet,
maxDepth - 1
maxDepth - 1,
);
if (result) {
return result;
@@ -350,7 +338,7 @@ export async function findProjectIcon(
priorityPath,
ICON_PATTERNS,
ignoredDirsSet,
maxDepth - 1
maxDepth - 1,
);
if (result) {
return result;
@@ -381,7 +369,7 @@ export async function findProjectIcon(
async function findDirRecursively(
dir: string,
maxDepth: number = 2,
currentDepth: number = 0
currentDepth: number = 0,
): Promise<string | null> {
const ignoredDirsSet = new Set(IGNORED_DIRS);
const priorityDirsSet = new Set(PRIORITY_DIRS);
@@ -414,11 +402,7 @@ async function findDirRecursively(
try {
const stats = await stat(fullPath);
if (stats.isDirectory()) {
const result = await findDirRecursively(
fullPath,
maxDepth,
currentDepth + 1
);
const result = await findDirRecursively(fullPath, maxDepth, currentDepth + 1);
if (result) {
return result;
}
@@ -439,9 +423,7 @@ async function findDirRecursively(
* @param projectDir - The root directory of the project to search
* @returns The icon data with mime type, or null if not found
*/
export async function getProjectIcon(
projectDir: string
): Promise<ProjectIcon | null> {
export async function getProjectIcon(projectDir: string): Promise<ProjectIcon | null> {
const iconPath = await findProjectIcon(projectDir);
if (!iconPath) {
return null;
@@ -1,10 +1,6 @@
import { describe, expect, it } from "vitest";
import {
extractTodos,
stripCwdPrefix,
stripShellWrapperPrefix,
} from "./tool-call-parsers.js";
import { extractTodos, stripCwdPrefix, stripShellWrapperPrefix } from "./tool-call-parsers.js";
describe("tool-call-parsers utilities", () => {
it("strips cwd prefixes", () => {
@@ -24,7 +20,7 @@ describe("tool-call-parsers utilities", () => {
{ content: "Task 1", status: "pending" },
{ content: "Task 2", status: "completed" },
],
})
}),
).toHaveLength(2);
expect(extractTodos({ plan: [] })).toEqual([]);
@@ -1,8 +1,7 @@
import { z } from "zod";
import { stripCwdPrefix } from "../shared/path-utils.js";
const SHELL_WRAPPER_PREFIX_PATTERN =
/^\/bin\/(?:zsh|bash|sh)\s+(?:-[a-zA-Z]+\s+)?/;
const SHELL_WRAPPER_PREFIX_PATTERN = /^\/bin\/(?:zsh|bash|sh)\s+(?:-[a-zA-Z]+\s+)?/;
const CD_AND_PATTERN = /^cd\s+(?:"[^"]+"|'[^']+'|\S+)\s+&&\s+/;
export { stripCwdPrefix };
@@ -36,7 +35,7 @@ const TodosSchema = z.object({
content: z.string(),
status: z.enum(["pending", "in_progress", "completed"]),
activeForm: z.string().optional(),
})
}),
),
});
@@ -64,7 +64,7 @@ export function normalizeBaseRefName(input: string): string {
export function writePaseoWorktreeMetadata(
worktreeRoot: string,
options: { baseRefName: string }
options: { baseRefName: string },
): void {
const baseRefName = normalizeBaseRefName(options.baseRefName);
if (baseRefName === "HEAD") {
@@ -85,7 +85,7 @@ export function writePaseoWorktreeMetadata(
export function writePaseoWorktreeRuntimeMetadata(
worktreeRoot: string,
options: { worktreePort: number }
options: { worktreePort: number },
): void {
if (!Number.isInteger(options.worktreePort) || options.worktreePort <= 0) {
throw new Error(`Invalid worktree runtime port: ${options.worktreePort}`);
+34 -50
View File
@@ -53,16 +53,12 @@ describe("createWorktree", () => {
paseoHome,
});
expect(result.worktreePath).toBe(
join(paseoHome, "worktrees", projectHash, "hello-world")
);
expect(result.worktreePath).toBe(join(paseoHome, "worktrees", projectHash, "hello-world"));
expect(existsSync(result.worktreePath)).toBe(true);
expect(existsSync(join(result.worktreePath, "file.txt"))).toBe(true);
const metadataPath = getPaseoWorktreeMetadataPath(result.worktreePath);
expect(existsSync(metadataPath)).toBe(true);
const metadata = JSON.parse(
readFileSync(metadataPath, "utf8")
);
const metadata = JSON.parse(readFileSync(metadataPath, "utf8"));
expect(metadata).toMatchObject({ version: 1, baseRefName: "main" });
});
@@ -94,11 +90,13 @@ describe("createWorktree", () => {
"paseo-home",
"worktrees",
projectHash,
"realpath-test"
"realpath-test",
);
expect(existsSync(privateWorktreePath)).toBe(true);
const ownership = await isPaseoOwnedWorktreeCwd(privateWorktreePath, { paseoHome: varPaseoHome });
const ownership = await isPaseoOwnedWorktreeCwd(privateWorktreePath, {
paseoHome: varPaseoHome,
});
expect(ownership.allowed).toBe(true);
rmSync(varTempDir, { recursive: true, force: true });
@@ -138,18 +136,14 @@ describe("createWorktree", () => {
paseoHome,
});
expect(result.worktreePath).toBe(
join(paseoHome, "worktrees", projectHash, "my-feature")
);
expect(result.worktreePath).toBe(join(paseoHome, "worktrees", projectHash, "my-feature"));
expect(existsSync(result.worktreePath)).toBe(true);
// Verify branch was created
const branches = execSync("git branch", { cwd: repoDir }).toString();
expect(branches).toContain("feature-branch");
const metadataPath = getPaseoWorktreeMetadataPath(result.worktreePath);
const metadata = JSON.parse(
readFileSync(metadataPath, "utf8")
);
const metadata = JSON.parse(readFileSync(metadataPath, "utf8"));
expect(metadata).toMatchObject({ version: 1, baseRefName: "main" });
});
@@ -160,7 +154,7 @@ describe("createWorktree", () => {
cwd: repoDir,
baseBranch: "main",
worktreeSlug: "test",
})
}),
).rejects.toThrow("Invalid branch name");
});
@@ -178,9 +172,7 @@ describe("createWorktree", () => {
});
// Should create branch "hello-1" since "hello" exists
expect(result.worktreePath).toBe(
join(paseoHome, "worktrees", projectHash, "hello")
);
expect(result.worktreePath).toBe(join(paseoHome, "worktrees", projectHash, "hello"));
expect(existsSync(result.worktreePath)).toBe(true);
const branches = execSync("git branch", { cwd: repoDir }).toString();
@@ -220,7 +212,9 @@ describe("createWorktree", () => {
},
};
writeFileSync(join(repoDir, "paseo.json"), JSON.stringify(paseoConfig));
execSync("git add paseo.json && git -c commit.gpgsign=false commit -m 'add paseo.json'", { cwd: repoDir });
execSync("git add paseo.json && git -c commit.gpgsign=false commit -m 'add paseo.json'", {
cwd: repoDir,
});
const result = await createWorktree({
branchName: "main",
@@ -238,9 +232,7 @@ describe("createWorktree", () => {
expect(setupLog).toContain(`root_alias=${repoDir}`);
expect(setupLog).toContain(`worktree=${result.worktreePath}`);
expect(setupLog).toContain("branch=setup-test");
const portLine = setupLog
.split("\n")
.find((line) => line.startsWith("port="));
const portLine = setupLog.split("\n").find((line) => line.startsWith("port="));
expect(portLine).toBeDefined();
const portValue = Number(portLine?.slice("port=".length));
expect(Number.isInteger(portValue)).toBe(true);
@@ -254,10 +246,9 @@ describe("createWorktree", () => {
},
};
writeFileSync(join(repoDir, "paseo.json"), JSON.stringify(paseoConfig));
execSync(
"git add paseo.json && git -c commit.gpgsign=false commit -m 'add paseo.json'",
{ cwd: repoDir }
);
execSync("git add paseo.json && git -c commit.gpgsign=false commit -m 'add paseo.json'", {
cwd: repoDir,
});
const result = await createWorktree({
branchName: "main",
@@ -275,16 +266,13 @@ describe("createWorktree", () => {
it("streams setup command progress events while commands are executing", async () => {
const paseoConfig = {
worktree: {
setup: [
'echo "first line"; echo "second line" 1>&2',
],
setup: ['echo "first line"; echo "second line" 1>&2'],
},
};
writeFileSync(join(repoDir, "paseo.json"), JSON.stringify(paseoConfig));
execSync(
"git add paseo.json && git -c commit.gpgsign=false commit -m 'add streaming setup'",
{ cwd: repoDir }
);
execSync("git add paseo.json && git -c commit.gpgsign=false commit -m 'add streaming setup'", {
cwd: repoDir,
});
const progressEvents: WorktreeSetupCommandProgressEvent[] = [];
const results = await runWorktreeSetupCommands({
@@ -350,7 +338,7 @@ describe("createWorktree", () => {
resolveWorktreeRuntimeEnv({
worktreePath: result.worktreePath,
branchName: result.branchName,
})
}),
).rejects.toThrow(`Persisted worktree port ${port} is already in use`);
await new Promise<void>((resolve, reject) => {
@@ -372,14 +360,11 @@ describe("createWorktree", () => {
},
};
writeFileSync(join(repoDir, "paseo.json"), JSON.stringify(paseoConfig));
execSync("git add paseo.json && git -c commit.gpgsign=false commit -m 'add paseo.json'", { cwd: repoDir });
execSync("git add paseo.json && git -c commit.gpgsign=false commit -m 'add paseo.json'", {
cwd: repoDir,
});
const expectedWorktreePath = join(
paseoHome,
"worktrees",
"test-repo",
"fail-test"
);
const expectedWorktreePath = join(paseoHome, "worktrees", "test-repo", "fail-test");
await expect(
createWorktree({
@@ -388,7 +373,7 @@ describe("createWorktree", () => {
baseBranch: "main",
worktreeSlug: "fail-test",
paseoHome,
})
}),
).rejects.toThrow("Worktree setup command failed");
// Verify worktree was cleaned up
@@ -554,10 +539,9 @@ describe("paseo worktree manager", () => {
},
};
writeFileSync(join(repoDir, "paseo.json"), JSON.stringify(paseoConfig));
execSync(
"git add paseo.json && git -c commit.gpgsign=false commit -m 'add destroy commands'",
{ cwd: repoDir }
);
execSync("git add paseo.json && git -c commit.gpgsign=false commit -m 'add destroy commands'", {
cwd: repoDir,
});
const created = await createWorktree({
branchName: "destroy-branch",
@@ -589,7 +573,7 @@ describe("paseo worktree manager", () => {
writeFileSync(join(repoDir, "paseo.json"), JSON.stringify(paseoConfig));
execSync(
"git add paseo.json && git -c commit.gpgsign=false commit -m 'add failing destroy commands'",
{ cwd: repoDir }
{ cwd: repoDir },
);
const created = await createWorktree({
@@ -601,13 +585,12 @@ describe("paseo worktree manager", () => {
});
await expect(
deletePaseoWorktree({ cwd: repoDir, worktreePath: created.worktreePath, paseoHome })
deletePaseoWorktree({ cwd: repoDir, worktreePath: created.worktreePath, paseoHome }),
).rejects.toThrow("Worktree destroy command failed");
expect(existsSync(created.worktreePath)).toBe(true);
expect(existsSync(join(repoDir, "destroy-start.log"))).toBe(true);
});
});
describe("slugify", () => {
@@ -617,7 +600,8 @@ describe("slugify", () => {
});
it("truncates long strings at word boundary", () => {
const longInput = "https-stackoverflow-com-questions-68349031-only-run-actions-on-non-draft-pull-request";
const longInput =
"https-stackoverflow-com-questions-68349031-only-run-actions-on-non-draft-pull-request";
const result = slugify(longInput);
expect(result.length).toBeLessThanOrEqual(50);
expect(result).toBe("https-stackoverflow-com-questions-68349031-only");
+33 -48
View File
@@ -182,9 +182,8 @@ export function getWorktreeTerminalSpecs(repoRoot: string): WorktreeTerminalConf
}
const rawName = terminal.name;
const name = typeof rawName === "string" && rawName.trim().length > 0
? rawName.trim()
: undefined;
const name =
typeof rawName === "string" && rawName.trim().length > 0 ? rawName.trim() : undefined;
specs.push({
...(name ? { name } : {}),
@@ -197,7 +196,7 @@ export function getWorktreeTerminalSpecs(repoRoot: string): WorktreeTerminalConf
async function execSetupCommand(
command: string,
options: { cwd: string; env: NodeJS.ProcessEnv }
options: { cwd: string; env: NodeJS.ProcessEnv },
): Promise<WorktreeSetupCommandResult> {
const startedAt = Date.now();
try {
@@ -219,9 +218,7 @@ async function execSetupCommand(
command,
cwd: options.cwd,
stdout: error?.stdout ?? "",
stderr:
error?.stderr ??
(error instanceof Error ? error.message : String(error)),
stderr: error?.stderr ?? (error instanceof Error ? error.message : String(error)),
exitCode: typeof error?.code === "number" ? error.code : null,
durationMs: Date.now() - startedAt,
};
@@ -347,11 +344,12 @@ async function assertPortAvailable(port: number): Promise<void> {
await new Promise<void>((resolve, reject) => {
const server = net.createServer();
server.once("error", (error: NodeJS.ErrnoException) => {
const message = error?.code === "EADDRINUSE"
? `Persisted worktree port ${port} is already in use`
: error instanceof Error
? error.message
: String(error);
const message =
error?.code === "EADDRINUSE"
? `Persisted worktree port ${port} is already in use`
: error instanceof Error
? error.message
: String(error);
reject(new Error(message));
});
server.listen(port, () => {
@@ -379,10 +377,10 @@ async function inferRepoRootPathFromWorktreePath(worktreePath: string): Promise<
} catch {
// Fallback: best-effort resolve toplevel (will be the worktree root in typical cases)
try {
const { stdout } = await execAsync(
"git rev-parse --path-format=absolute --show-toplevel",
{ cwd: worktreePath, env: READ_ONLY_GIT_ENV }
);
const { stdout } = await execAsync("git rev-parse --path-format=absolute --show-toplevel", {
cwd: worktreePath,
env: READ_ONLY_GIT_ENV,
});
const topLevel = stdout.trim();
if (topLevel) {
return normalizePathForOwnership(topLevel);
@@ -449,7 +447,7 @@ export async function runWorktreeSetupCommands(options: {
}
throw new WorktreeSetupError(
`Worktree setup command failed: ${cmd}\n${result.stderr}`.trim(),
results
results,
);
}
}
@@ -547,7 +545,7 @@ export async function runWorktreeDestroyCommands(options: {
if (result.exitCode !== 0) {
throw new WorktreeDestroyError(
`Worktree destroy command failed: ${cmd}\n${result.stderr}`.trim(),
results
results,
);
}
}
@@ -560,10 +558,10 @@ export async function runWorktreeDestroyCommands(options: {
* This is where refs, objects, etc. are stored.
*/
export async function getGitCommonDir(cwd: string): Promise<string> {
const { stdout } = await execAsync(
"git rev-parse --path-format=absolute --git-common-dir",
{ cwd, env: READ_ONLY_GIT_ENV }
);
const { stdout } = await execAsync("git rev-parse --path-format=absolute --git-common-dir", {
cwd,
env: READ_ONLY_GIT_ENV,
});
const commonDir = stdout.trim();
if (!commonDir) {
throw new Error("Not in a git repository");
@@ -649,19 +647,15 @@ function deriveShortAlphanumericHash(value: string): string {
for (let index = 0; index < 8; index += 1) {
hashValue = (hashValue << 8n) | BigInt(digest[index] ?? 0);
}
return hashValue
.toString(36)
.padStart(13, "0")
.slice(0, WORKTREE_PROJECT_HASH_LENGTH);
return hashValue.toString(36).padStart(13, "0").slice(0, WORKTREE_PROJECT_HASH_LENGTH);
}
export async function deriveWorktreeProjectHash(cwd: string): Promise<string> {
try {
const commonDir = await getGitCommonDir(cwd);
const normalizedCommonDir = normalizePathForOwnership(commonDir);
const repoRoot = basename(normalizedCommonDir) === ".git"
? dirname(normalizedCommonDir)
: normalizedCommonDir;
const repoRoot =
basename(normalizedCommonDir) === ".git" ? dirname(normalizedCommonDir) : normalizedCommonDir;
return deriveShortAlphanumericHash(repoRoot);
} catch {
return deriveShortAlphanumericHash(normalizePathForOwnership(cwd));
@@ -677,7 +671,7 @@ export async function getPaseoWorktreesRoot(cwd: string, paseoHome?: string): Pr
export async function computeWorktreePath(
cwd: string,
slug: string,
paseoHome?: string
paseoHome?: string,
): Promise<string> {
const worktreesRoot = await getPaseoWorktreesRoot(cwd, paseoHome);
return join(worktreesRoot, slug);
@@ -700,7 +694,7 @@ function resolveRepoRootFromGitCommonDir(commonDir: string): string {
export async function isPaseoOwnedWorktreeCwd(
cwd: string,
options?: { paseoHome?: string }
options?: { paseoHome?: string },
): Promise<PaseoWorktreeOwnership> {
let gitCommonDir: string;
try {
@@ -759,9 +753,7 @@ function parseWorktreeList(output: string): ParsedPaseoWorktreeInfo[] {
if (line.startsWith("branch ")) {
const ref = line.slice("branch ".length).trim();
current.branchName = ref.startsWith("refs/heads/")
? ref.slice("refs/heads/".length)
: ref;
current.branchName = ref.startsWith("refs/heads/") ? ref.slice("refs/heads/".length) : ref;
} else if (line.startsWith("HEAD ")) {
current.head = line.slice("HEAD ".length).trim();
} else if (line.trim().length === 0) {
@@ -816,7 +808,7 @@ export async function listPaseoWorktrees({
export async function resolvePaseoWorktreeRootForCwd(
cwd: string,
options?: { paseoHome?: string }
options?: { paseoHome?: string },
): Promise<{ repoRoot: string; worktreeRoot: string; worktreePath: string } | null> {
let gitCommonDir: string;
try {
@@ -830,10 +822,10 @@ export async function resolvePaseoWorktreeRootForCwd(
let worktreeRoot: string | null = null;
try {
const { stdout } = await execAsync(
"git rev-parse --path-format=absolute --show-toplevel",
{ cwd, env: READ_ONLY_GIT_ENV }
);
const { stdout } = await execAsync("git rev-parse --path-format=absolute --show-toplevel", {
cwd,
env: READ_ONLY_GIT_ENV,
});
const trimmed = stdout.trim();
worktreeRoot = trimmed.length > 0 ? trimmed : null;
} catch {
@@ -905,7 +897,6 @@ export async function deletePaseoWorktree({
}
}
/**
* Create a git worktree with proper naming conventions
*/
@@ -954,10 +945,7 @@ export async function createWorktree({
// Check if branch already exists
let branchExists = false;
try {
await execAsync(
`git show-ref --verify --quiet refs/heads/${branchName}`,
{ cwd }
);
await execAsync(`git show-ref --verify --quiet refs/heads/${branchName}`, { cwd });
branchExists = true;
} catch {
branchExists = false;
@@ -974,10 +962,7 @@ export async function createWorktree({
let suffix = 1;
while (true) {
try {
await execAsync(
`git show-ref --verify --quiet refs/heads/${newBranchName}`,
{ cwd }
);
await execAsync(`git show-ref --verify --quiet refs/heads/${newBranchName}`, { cwd });
// Branch exists, try with suffix
newBranchName = `${candidateBranch}-${suffix}`;
suffix++;