feat: add relay reconnect with grace period and branch suggestions
This commit is contained in:
@@ -9,6 +9,7 @@ import {
|
||||
getPullRequestStatus,
|
||||
getCheckoutStatus,
|
||||
getCheckoutStatusLite,
|
||||
listBranchSuggestions,
|
||||
mergeToBase,
|
||||
mergeFromBase,
|
||||
MergeConflictError,
|
||||
@@ -402,6 +403,51 @@ describe("checkout git utilities", () => {
|
||||
execSync(`git --git-dir ${remoteDir} show-ref --verify refs/heads/feature`);
|
||||
});
|
||||
|
||||
it("lists merged local and remote branch suggestions without duplicates", async () => {
|
||||
const remoteDir = join(tempDir, "remote.git");
|
||||
execSync(`git init --bare -b main ${remoteDir}`);
|
||||
execSync(`git remote add origin ${remoteDir}`, { cwd: repoDir });
|
||||
execSync("git push -u origin main", { cwd: repoDir });
|
||||
|
||||
execSync("git checkout -b local-only", { cwd: repoDir });
|
||||
execSync("git checkout main", { cwd: repoDir });
|
||||
|
||||
const otherClone = join(tempDir, "other-clone");
|
||||
execSync(`git clone ${remoteDir} ${otherClone}`);
|
||||
execSync("git config user.email 'test@test.com'", { cwd: otherClone });
|
||||
execSync("git config user.name 'Test'", { cwd: otherClone });
|
||||
execSync("git checkout -b remote-only", { cwd: otherClone });
|
||||
writeFileSync(join(otherClone, "remote-only.txt"), "remote-only\n");
|
||||
execSync("git add remote-only.txt", { cwd: otherClone });
|
||||
execSync("git -c commit.gpgsign=false commit -m 'remote only branch'", { cwd: otherClone });
|
||||
execSync("git push -u origin remote-only", { cwd: otherClone });
|
||||
execSync("git fetch origin", { cwd: repoDir });
|
||||
|
||||
const branches = await listBranchSuggestions(repoDir, { limit: 50 });
|
||||
expect(branches).toContain("main");
|
||||
expect(branches).toContain("local-only");
|
||||
expect(branches).toContain("remote-only");
|
||||
expect(branches.filter((name) => name === "main")).toHaveLength(1);
|
||||
expect(branches).not.toContain("HEAD");
|
||||
expect(branches.some((name) => name.startsWith("origin/"))).toBe(false);
|
||||
});
|
||||
|
||||
it("filters branch suggestions by query and enforces result limit", async () => {
|
||||
execSync("git checkout -b feature/alpha", { cwd: repoDir });
|
||||
execSync("git checkout main", { cwd: repoDir });
|
||||
execSync("git checkout -b feature/beta", { cwd: repoDir });
|
||||
execSync("git checkout main", { cwd: repoDir });
|
||||
execSync("git checkout -b chore/docs", { cwd: repoDir });
|
||||
execSync("git checkout main", { cwd: repoDir });
|
||||
|
||||
const branches = await listBranchSuggestions(repoDir, {
|
||||
query: "FEATURE/",
|
||||
limit: 1,
|
||||
});
|
||||
expect(branches).toHaveLength(1);
|
||||
expect(branches[0]?.toLowerCase()).toContain("feature/");
|
||||
});
|
||||
|
||||
it("disables GitHub features when gh is unavailable", async () => {
|
||||
execSync("git remote add origin https://github.com/getpaseo/paseo.git", { cwd: repoDir });
|
||||
|
||||
|
||||
@@ -104,6 +104,128 @@ type CheckoutFileChange = {
|
||||
isUntracked?: boolean;
|
||||
};
|
||||
|
||||
type BranchSuggestionRefOrigin = "local" | "remote";
|
||||
|
||||
function normalizeBranchSuggestionName(raw: string): string | null {
|
||||
const trimmed = raw.trim();
|
||||
if (!trimmed) {
|
||||
return null;
|
||||
}
|
||||
|
||||
let normalized = trimmed;
|
||||
if (normalized.startsWith("refs/heads/")) {
|
||||
normalized = normalized.slice("refs/heads/".length);
|
||||
} else if (normalized.startsWith("refs/remotes/")) {
|
||||
normalized = normalized.slice("refs/remotes/".length);
|
||||
}
|
||||
|
||||
if (normalized.startsWith("origin/")) {
|
||||
normalized = normalized.slice("origin/".length);
|
||||
}
|
||||
|
||||
if (!normalized || normalized === "HEAD") {
|
||||
return null;
|
||||
}
|
||||
|
||||
return normalized;
|
||||
}
|
||||
|
||||
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,
|
||||
}
|
||||
);
|
||||
return stdout
|
||||
.split("\n")
|
||||
.map((line) => line.trim())
|
||||
.filter((line) => line.length > 0);
|
||||
}
|
||||
|
||||
function sortBranchSuggestions(
|
||||
branchNames: string[],
|
||||
localBranchNames: Set<string>,
|
||||
query: string
|
||||
): string[] {
|
||||
const normalizedQuery = query.trim().toLowerCase();
|
||||
const hasQuery = normalizedQuery.length > 0;
|
||||
return branchNames.sort((a, b) => {
|
||||
const aLower = a.toLowerCase();
|
||||
const bLower = b.toLowerCase();
|
||||
|
||||
if (hasQuery) {
|
||||
const aPrefix = aLower.startsWith(normalizedQuery);
|
||||
const bPrefix = bLower.startsWith(normalizedQuery);
|
||||
if (aPrefix !== bPrefix) {
|
||||
return aPrefix ? -1 : 1;
|
||||
}
|
||||
}
|
||||
|
||||
const aIsLocal = localBranchNames.has(a);
|
||||
const bIsLocal = localBranchNames.has(b);
|
||||
if (aIsLocal !== bIsLocal) {
|
||||
return aIsLocal ? -1 : 1;
|
||||
}
|
||||
|
||||
return a.localeCompare(b);
|
||||
});
|
||||
}
|
||||
|
||||
export async function listBranchSuggestions(
|
||||
cwd: string,
|
||||
options?: { query?: string; limit?: number }
|
||||
): Promise<string[]> {
|
||||
await requireGitRepo(cwd);
|
||||
|
||||
const requestedLimit = options?.limit ?? 50;
|
||||
const limit = Math.max(1, Math.min(200, requestedLimit));
|
||||
const query = options?.query?.trim().toLowerCase() ?? "";
|
||||
|
||||
const [localRefs, remoteRefs] = await Promise.all([
|
||||
listGitRefs(cwd, "refs/heads"),
|
||||
listGitRefs(cwd, "refs/remotes/origin"),
|
||||
]);
|
||||
|
||||
const merged = new Map<string, Set<BranchSuggestionRefOrigin>>();
|
||||
for (const localRef of localRefs) {
|
||||
const normalized = normalizeBranchSuggestionName(localRef);
|
||||
if (!normalized) {
|
||||
continue;
|
||||
}
|
||||
const origins = merged.get(normalized) ?? new Set<BranchSuggestionRefOrigin>();
|
||||
origins.add("local");
|
||||
merged.set(normalized, origins);
|
||||
}
|
||||
for (const remoteRef of remoteRefs) {
|
||||
const normalized = normalizeBranchSuggestionName(remoteRef);
|
||||
if (!normalized) {
|
||||
continue;
|
||||
}
|
||||
const origins = merged.get(normalized) ?? new Set<BranchSuggestionRefOrigin>();
|
||||
origins.add("remote");
|
||||
merged.set(normalized, origins);
|
||||
}
|
||||
|
||||
const filteredNames = Array.from(merged.keys()).filter((name) =>
|
||||
query ? name.toLowerCase().includes(query) : true
|
||||
);
|
||||
if (filteredNames.length === 0) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const localBranchNames = new Set<string>();
|
||||
for (const [name, origins] of merged) {
|
||||
if (origins.has("local")) {
|
||||
localBranchNames.add(name);
|
||||
}
|
||||
}
|
||||
|
||||
const ordered = sortBranchSuggestions(filteredNames, localBranchNames, query);
|
||||
return ordered.slice(0, limit);
|
||||
}
|
||||
|
||||
async function listCheckoutFileChanges(cwd: string, ref: string): Promise<CheckoutFileChange[]> {
|
||||
const changes: CheckoutFileChange[] = [];
|
||||
|
||||
|
||||
@@ -171,7 +171,8 @@ describe("createWorktree", () => {
|
||||
const paseoConfig = {
|
||||
worktree: {
|
||||
setup: [
|
||||
'echo "root=$PASEO_ROOT_PATH" > setup.log',
|
||||
'echo "source=$PASEO_SOURCE_CHECKOUT_PATH" > setup.log',
|
||||
'echo "root_alias=$PASEO_ROOT_PATH" >> setup.log',
|
||||
'echo "worktree=$PASEO_WORKTREE_PATH" >> setup.log',
|
||||
'echo "branch=$PASEO_BRANCH_NAME" >> setup.log',
|
||||
'echo "port=$PASEO_WORKTREE_PORT" >> setup.log',
|
||||
@@ -193,7 +194,8 @@ describe("createWorktree", () => {
|
||||
|
||||
// Verify setup ran and env vars were available
|
||||
const setupLog = readFileSync(join(result.worktreePath, "setup.log"), "utf8");
|
||||
expect(setupLog).toContain(`root=${repoDir}`);
|
||||
expect(setupLog).toContain(`source=${repoDir}`);
|
||||
expect(setupLog).toContain(`root_alias=${repoDir}`);
|
||||
expect(setupLog).toContain(`worktree=${result.worktreePath}`);
|
||||
expect(setupLog).toContain("branch=setup-test");
|
||||
const portLine = setupLog
|
||||
@@ -335,9 +337,10 @@ describe("paseo worktree manager", () => {
|
||||
const paseoConfig = {
|
||||
worktree: {
|
||||
destroy: [
|
||||
'echo "root=$PASEO_ROOT_PATH" > "$PASEO_ROOT_PATH/destroy.log"',
|
||||
'echo "worktree=$PASEO_WORKTREE_PATH" >> "$PASEO_ROOT_PATH/destroy.log"',
|
||||
'echo "branch=$PASEO_BRANCH_NAME" >> "$PASEO_ROOT_PATH/destroy.log"',
|
||||
'echo "source=$PASEO_SOURCE_CHECKOUT_PATH" > "$PASEO_SOURCE_CHECKOUT_PATH/destroy.log"',
|
||||
'echo "root_alias=$PASEO_ROOT_PATH" >> "$PASEO_SOURCE_CHECKOUT_PATH/destroy.log"',
|
||||
'echo "worktree=$PASEO_WORKTREE_PATH" >> "$PASEO_SOURCE_CHECKOUT_PATH/destroy.log"',
|
||||
'echo "branch=$PASEO_BRANCH_NAME" >> "$PASEO_SOURCE_CHECKOUT_PATH/destroy.log"',
|
||||
],
|
||||
},
|
||||
};
|
||||
@@ -359,7 +362,8 @@ describe("paseo worktree manager", () => {
|
||||
expect(existsSync(created.worktreePath)).toBe(false);
|
||||
|
||||
const destroyLog = readFileSync(join(repoDir, "destroy.log"), "utf8");
|
||||
expect(destroyLog).toContain(`root=${repoDir}`);
|
||||
expect(destroyLog).toContain(`source=${repoDir}`);
|
||||
expect(destroyLog).toContain(`root_alias=${repoDir}`);
|
||||
expect(destroyLog).toContain(`worktree=${created.worktreePath}`);
|
||||
expect(destroyLog).toContain("branch=destroy-branch");
|
||||
});
|
||||
@@ -368,7 +372,7 @@ describe("paseo worktree manager", () => {
|
||||
const paseoConfig = {
|
||||
worktree: {
|
||||
destroy: [
|
||||
'echo "started" > "$PASEO_ROOT_PATH/destroy-start.log"',
|
||||
'echo "started" > "$PASEO_SOURCE_CHECKOUT_PATH/destroy-start.log"',
|
||||
"echo boom 1>&2; exit 9",
|
||||
],
|
||||
},
|
||||
|
||||
@@ -204,8 +204,11 @@ export async function runWorktreeSetupCommands(options: {
|
||||
|
||||
const setupEnv = {
|
||||
...process.env,
|
||||
// Root is the original git repo root (shared across worktrees), not the worktree itself.
|
||||
// This allows setup scripts to copy uncommitted local files (e.g. .env) from the main checkout.
|
||||
// Source checkout path is the original git repo root (shared across worktrees), not the
|
||||
// worktree itself. This allows setup scripts to copy local files (e.g. .env) from the
|
||||
// source checkout.
|
||||
PASEO_SOURCE_CHECKOUT_PATH: repoRootPath,
|
||||
// Backward-compatible alias.
|
||||
PASEO_ROOT_PATH: repoRootPath,
|
||||
PASEO_WORKTREE_PATH: options.worktreePath,
|
||||
PASEO_BRANCH_NAME: options.branchName,
|
||||
@@ -275,8 +278,11 @@ export async function runWorktreeDestroyCommands(options: {
|
||||
|
||||
const destroyEnv = {
|
||||
...process.env,
|
||||
// Root is the original git repo root (shared across worktrees), not the worktree itself.
|
||||
// This allows destroy scripts to clean resources using paths from the main checkout.
|
||||
// Source checkout path is the original git repo root (shared across worktrees), not the
|
||||
// worktree itself. This allows destroy scripts to clean resources using paths from the
|
||||
// source checkout.
|
||||
PASEO_SOURCE_CHECKOUT_PATH: repoRootPath,
|
||||
// Backward-compatible alias.
|
||||
PASEO_ROOT_PATH: repoRootPath,
|
||||
PASEO_WORKTREE_PATH: options.worktreePath,
|
||||
PASEO_BRANCH_NAME: branchName,
|
||||
|
||||
Reference in New Issue
Block a user