Switch to double quotes and reformat codebase with Biome
This commit is contained in:
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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
@@ -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 () => {
|
||||
|
||||
@@ -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}`);
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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++;
|
||||
|
||||
Reference in New Issue
Block a user