Persist base branch for Paseo worktrees and gate merge action
This commit is contained in:
@@ -12,6 +12,7 @@ import {
|
||||
NotGitRepoError,
|
||||
} from "./checkout-git.js";
|
||||
import { createWorktree } from "./worktree.js";
|
||||
import { getPaseoWorktreeMetadataPath } from "./worktree-metadata.js";
|
||||
|
||||
function initRepo(): { tempDir: string; repoDir: string } {
|
||||
const tempDir = realpathSync(mkdtempSync(join(tmpdir(), "checkout-git-test-")));
|
||||
@@ -87,6 +88,7 @@ describe("checkout git utilities", () => {
|
||||
const result = await createWorktree({
|
||||
branchName: "main",
|
||||
cwd: repoDir,
|
||||
baseBranch: "main",
|
||||
worktreeSlug: "alpha",
|
||||
});
|
||||
|
||||
@@ -117,6 +119,7 @@ describe("checkout git utilities", () => {
|
||||
const worktree = await createWorktree({
|
||||
branchName: "main",
|
||||
cwd: repoDir,
|
||||
baseBranch: "main",
|
||||
worktreeSlug: "merge",
|
||||
});
|
||||
|
||||
@@ -138,6 +141,12 @@ describe("checkout git utilities", () => {
|
||||
});
|
||||
expect(baseContainsFeature).toBeDefined();
|
||||
|
||||
const statusAfterMerge = await getCheckoutStatus(worktree.worktreePath);
|
||||
expect(statusAfterMerge.isGit).toBe(true);
|
||||
if (statusAfterMerge.isGit) {
|
||||
expect(statusAfterMerge.aheadBehind?.ahead ?? 0).toBe(0);
|
||||
}
|
||||
|
||||
const currentBranch = execSync("git rev-parse --abbrev-ref HEAD", {
|
||||
cwd: worktree.worktreePath,
|
||||
})
|
||||
@@ -174,4 +183,96 @@ describe("checkout git utilities", () => {
|
||||
mergeToBase(repoDir, { baseRef: "main" })
|
||||
).rejects.toBeInstanceOf(MergeConflictError);
|
||||
});
|
||||
|
||||
it("uses stored baseRefName for Paseo worktrees (no heuristics)", async () => {
|
||||
// Create a non-default base branch with a unique commit.
|
||||
execSync("git checkout -b develop", { cwd: repoDir });
|
||||
writeFileSync(join(repoDir, "file.txt"), "develop\n");
|
||||
execSync("git add file.txt", { cwd: repoDir });
|
||||
execSync("git -c commit.gpgsign=false commit -m 'develop change'", { cwd: repoDir });
|
||||
execSync("git checkout main", { cwd: repoDir });
|
||||
|
||||
// Create a worktree/branch based on develop, but keep main as the repo default.
|
||||
const worktree = await createWorktree({
|
||||
branchName: "feature",
|
||||
cwd: repoDir,
|
||||
baseBranch: "develop",
|
||||
worktreeSlug: "feature",
|
||||
});
|
||||
|
||||
writeFileSync(join(worktree.worktreePath, "feature.txt"), "feature\n");
|
||||
execSync("git add feature.txt", { cwd: worktree.worktreePath });
|
||||
execSync("git -c commit.gpgsign=false commit -m 'feature commit'", {
|
||||
cwd: worktree.worktreePath,
|
||||
});
|
||||
|
||||
const status = await getCheckoutStatus(worktree.worktreePath);
|
||||
expect(status.isGit).toBe(true);
|
||||
expect(status.baseRef).toBe("develop");
|
||||
expect(status.aheadBehind?.ahead).toBe(1);
|
||||
|
||||
const baseDiff = await getCheckoutDiff(worktree.worktreePath, { mode: "base" });
|
||||
expect(baseDiff.diff).toContain("feature.txt");
|
||||
expect(baseDiff.diff).not.toContain("file.txt");
|
||||
});
|
||||
|
||||
it("merges to stored baseRefName when baseRef is not provided", async () => {
|
||||
// Create a non-default base branch with a unique commit.
|
||||
execSync("git checkout -b develop", { cwd: repoDir });
|
||||
writeFileSync(join(repoDir, "file.txt"), "develop\n");
|
||||
execSync("git add file.txt", { cwd: repoDir });
|
||||
execSync("git -c commit.gpgsign=false commit -m 'develop change'", { cwd: repoDir });
|
||||
execSync("git checkout main", { cwd: repoDir });
|
||||
|
||||
// Create a Paseo worktree configured to use develop as base.
|
||||
const worktree = await createWorktree({
|
||||
branchName: "feature",
|
||||
cwd: repoDir,
|
||||
baseBranch: "develop",
|
||||
worktreeSlug: "merge-to-develop",
|
||||
});
|
||||
|
||||
writeFileSync(join(worktree.worktreePath, "feature.txt"), "feature\n");
|
||||
execSync("git add feature.txt", { cwd: worktree.worktreePath });
|
||||
execSync("git -c commit.gpgsign=false commit -m 'feature commit'", {
|
||||
cwd: worktree.worktreePath,
|
||||
});
|
||||
const featureCommit = execSync("git rev-parse HEAD", { cwd: worktree.worktreePath })
|
||||
.toString()
|
||||
.trim();
|
||||
|
||||
// No baseRef passed: should merge into the configured base (develop), not default/main.
|
||||
await mergeToBase(worktree.worktreePath);
|
||||
|
||||
execSync(`git merge-base --is-ancestor ${featureCommit} develop`, {
|
||||
cwd: repoDir,
|
||||
stdio: "pipe",
|
||||
});
|
||||
expect(() =>
|
||||
execSync(`git merge-base --is-ancestor ${featureCommit} main`, {
|
||||
cwd: repoDir,
|
||||
stdio: "pipe",
|
||||
})
|
||||
).toThrow();
|
||||
});
|
||||
|
||||
it("throws if Paseo worktree base metadata is missing", async () => {
|
||||
const worktree = await createWorktree({
|
||||
branchName: "main",
|
||||
cwd: repoDir,
|
||||
baseBranch: "main",
|
||||
worktreeSlug: "missing-metadata",
|
||||
});
|
||||
|
||||
const metadataPath = getPaseoWorktreeMetadataPath(worktree.worktreePath);
|
||||
rmSync(metadataPath, { force: true });
|
||||
|
||||
await expect(getCheckoutStatus(worktree.worktreePath)).rejects.toThrow(
|
||||
/base/i
|
||||
);
|
||||
await expect(getCheckoutDiff(worktree.worktreePath, { mode: "base" })).rejects.toThrow(
|
||||
/base/i
|
||||
);
|
||||
await expect(mergeToBase(worktree.worktreePath)).rejects.toThrow(/base/i);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -3,7 +3,8 @@ import { promisify } from "util";
|
||||
import { resolve } from "path";
|
||||
import type { ParsedDiffFile } from "../server/utils/diff-highlighter.js";
|
||||
import { parseAndHighlightDiff } from "../server/utils/diff-highlighter.js";
|
||||
import { detectRepoInfo } from "./worktree.js";
|
||||
import { detectRepoInfo, isPaseoOwnedWorktreeCwd } from "./worktree.js";
|
||||
import { requirePaseoWorktreeBaseRefName } from "./worktree-metadata.js";
|
||||
|
||||
const execAsync = promisify(exec);
|
||||
const execFileAsync = promisify(execFile);
|
||||
@@ -43,14 +44,33 @@ export interface AheadBehind {
|
||||
}
|
||||
|
||||
export interface CheckoutStatus {
|
||||
isGit: boolean;
|
||||
repoRoot?: string;
|
||||
currentBranch?: string | null;
|
||||
isDirty?: boolean;
|
||||
baseRef?: string | null;
|
||||
aheadBehind?: AheadBehind | null;
|
||||
isGit: false;
|
||||
}
|
||||
|
||||
export type CheckoutStatusGitNonPaseo = {
|
||||
isGit: true;
|
||||
repoRoot: string;
|
||||
currentBranch: string | null;
|
||||
isDirty: boolean;
|
||||
baseRef: string | null;
|
||||
aheadBehind: AheadBehind | null;
|
||||
isPaseoOwnedWorktree: false;
|
||||
};
|
||||
|
||||
export type CheckoutStatusGitPaseo = {
|
||||
isGit: true;
|
||||
repoRoot: string;
|
||||
currentBranch: string | null;
|
||||
isDirty: boolean;
|
||||
baseRef: string;
|
||||
aheadBehind: AheadBehind | null;
|
||||
isPaseoOwnedWorktree: true;
|
||||
};
|
||||
|
||||
export type CheckoutStatusGit = CheckoutStatusGitNonPaseo | CheckoutStatusGitPaseo;
|
||||
|
||||
export type CheckoutStatusResult = CheckoutStatus | CheckoutStatusGit;
|
||||
|
||||
export interface CheckoutDiffResult {
|
||||
diff: string;
|
||||
structured?: ParsedDiffFile[];
|
||||
@@ -179,6 +199,25 @@ export async function renameCurrentBranch(
|
||||
return { previousBranch, currentBranch };
|
||||
}
|
||||
|
||||
type ConfiguredBaseRefForCwd =
|
||||
| { baseRef: null; isPaseoOwnedWorktree: false }
|
||||
| { baseRef: string; isPaseoOwnedWorktree: true };
|
||||
|
||||
async function getConfiguredBaseRefForCwd(
|
||||
cwd: string
|
||||
): Promise<ConfiguredBaseRefForCwd> {
|
||||
const ownership = await isPaseoOwnedWorktreeCwd(cwd);
|
||||
if (!ownership.allowed) {
|
||||
return { baseRef: null, isPaseoOwnedWorktree: false };
|
||||
}
|
||||
|
||||
const worktreeRoot = (await getWorktreeRoot(cwd)) ?? cwd;
|
||||
return {
|
||||
baseRef: requirePaseoWorktreeBaseRefName(worktreeRoot),
|
||||
isPaseoOwnedWorktree: true,
|
||||
};
|
||||
}
|
||||
|
||||
async function isWorkingTreeDirty(cwd: string, repoType: "bare" | "normal"): Promise<boolean> {
|
||||
if (repoType === "bare") {
|
||||
return false;
|
||||
@@ -198,7 +237,21 @@ async function resolveBaseRef(repoRoot: string): Promise<string | null> {
|
||||
});
|
||||
const ref = stdout.trim();
|
||||
if (ref) {
|
||||
return ref.replace(/^refs\/remotes\//, "");
|
||||
// Prefer a local branch name (e.g. "main") over the remote-tracking ref (e.g. "origin/main")
|
||||
// so that status/diff/merge all operate against the same base ref.
|
||||
const remoteShort = ref.replace(/^refs\/remotes\//, "");
|
||||
const localName = remoteShort.startsWith("origin/")
|
||||
? remoteShort.slice("origin/".length)
|
||||
: remoteShort;
|
||||
try {
|
||||
await execAsync(`git show-ref --verify --quiet refs/heads/${localName}`, {
|
||||
cwd: repoRoot,
|
||||
env: READ_ONLY_GIT_ENV,
|
||||
});
|
||||
return localName;
|
||||
} catch {
|
||||
return remoteShort;
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// ignore
|
||||
@@ -223,12 +276,17 @@ async function resolveBaseRef(repoRoot: string): Promise<string | null> {
|
||||
return null;
|
||||
}
|
||||
|
||||
function normalizeLocalBranchRefName(input: string): string {
|
||||
return input.startsWith("origin/") ? input.slice("origin/".length) : input;
|
||||
}
|
||||
|
||||
async function getAheadBehind(cwd: string, baseRef: string, currentBranch: string): Promise<AheadBehind | null> {
|
||||
if (!baseRef || !currentBranch || baseRef === currentBranch) {
|
||||
const normalizedBaseRef = normalizeLocalBranchRefName(baseRef);
|
||||
if (!normalizedBaseRef || !currentBranch || normalizedBaseRef === currentBranch) {
|
||||
return null;
|
||||
}
|
||||
const { stdout } = await execAsync(
|
||||
`git rev-list --left-right --count ${baseRef}...${currentBranch}`,
|
||||
`git rev-list --left-right --count ${normalizedBaseRef}...${currentBranch}`,
|
||||
{ cwd, env: READ_ONLY_GIT_ENV }
|
||||
);
|
||||
const [behindRaw, aheadRaw] = stdout.trim().split(/\s+/);
|
||||
@@ -268,7 +326,7 @@ async function getUntrackedDiff(cwd: string): Promise<string> {
|
||||
return untrackedDiff;
|
||||
}
|
||||
|
||||
export async function getCheckoutStatus(cwd: string): Promise<CheckoutStatus> {
|
||||
export async function getCheckoutStatus(cwd: string): Promise<CheckoutStatusResult> {
|
||||
let repoInfo: Awaited<ReturnType<typeof detectRepoInfo>>;
|
||||
try {
|
||||
repoInfo = await detectRepoInfo(cwd);
|
||||
@@ -281,10 +339,22 @@ export async function getCheckoutStatus(cwd: string): Promise<CheckoutStatus> {
|
||||
|
||||
const currentBranch = await getCurrentBranch(cwd);
|
||||
const isDirty = await isWorkingTreeDirty(cwd, repoInfo.type);
|
||||
const baseRef = await resolveBaseRef(repoInfo.path);
|
||||
const aheadBehind = baseRef && currentBranch
|
||||
? await getAheadBehind(cwd, baseRef, currentBranch)
|
||||
: null;
|
||||
const configured = await getConfiguredBaseRefForCwd(cwd);
|
||||
const baseRef = configured.baseRef ?? (await resolveBaseRef(repoInfo.path));
|
||||
const aheadBehind =
|
||||
baseRef && currentBranch ? await getAheadBehind(cwd, baseRef, currentBranch) : null;
|
||||
|
||||
if (configured.isPaseoOwnedWorktree) {
|
||||
return {
|
||||
isGit: true,
|
||||
repoRoot: repoInfo.path,
|
||||
currentBranch,
|
||||
isDirty,
|
||||
baseRef: configured.baseRef,
|
||||
aheadBehind,
|
||||
isPaseoOwnedWorktree: true,
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
isGit: true,
|
||||
@@ -293,6 +363,7 @@ export async function getCheckoutStatus(cwd: string): Promise<CheckoutStatus> {
|
||||
isDirty,
|
||||
baseRef,
|
||||
aheadBehind,
|
||||
isPaseoOwnedWorktree: false,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -311,12 +382,16 @@ export async function getCheckoutDiff(
|
||||
const untrackedDiff = await getUntrackedDiff(cwd);
|
||||
diff = trackedDiff + untrackedDiff;
|
||||
} else {
|
||||
const configured = await getConfiguredBaseRefForCwd(cwd);
|
||||
const repoInfo = await detectRepoInfo(cwd);
|
||||
const baseRef = compare.baseRef ?? (await resolveBaseRef(repoInfo.path));
|
||||
const baseRef = configured.baseRef ?? compare.baseRef ?? (await resolveBaseRef(repoInfo.path));
|
||||
if (!baseRef) {
|
||||
diff = "";
|
||||
} else if (configured.isPaseoOwnedWorktree && compare.baseRef && compare.baseRef !== baseRef) {
|
||||
throw new Error(`Base ref mismatch: expected ${baseRef}, got ${compare.baseRef}`);
|
||||
} else {
|
||||
const { stdout } = await execAsync(`git diff ${baseRef}...HEAD`, {
|
||||
const normalizedBaseRef = normalizeLocalBranchRefName(baseRef);
|
||||
const { stdout } = await execAsync(`git diff ${normalizedBaseRef}...HEAD`, {
|
||||
cwd,
|
||||
env: READ_ONLY_GIT_ENV,
|
||||
});
|
||||
@@ -350,17 +425,19 @@ export async function commitAll(cwd: string, message: string): Promise<void> {
|
||||
export async function mergeToBase(cwd: string, options: MergeToBaseOptions = {}): Promise<void> {
|
||||
const repoInfo = await requireRepoInfo(cwd);
|
||||
const currentBranch = await getCurrentBranch(cwd);
|
||||
const baseRef = options.baseRef ?? (await resolveBaseRef(repoInfo.path));
|
||||
const configured = await getConfiguredBaseRefForCwd(cwd);
|
||||
const baseRef = configured.baseRef ?? options.baseRef ?? (await resolveBaseRef(repoInfo.path));
|
||||
if (!baseRef) {
|
||||
throw new Error("Unable to determine base branch for merge");
|
||||
}
|
||||
if (configured.isPaseoOwnedWorktree && options.baseRef && options.baseRef !== baseRef) {
|
||||
throw new Error(`Base ref mismatch: expected ${baseRef}, got ${options.baseRef}`);
|
||||
}
|
||||
if (!currentBranch) {
|
||||
throw new Error("Unable to determine current branch for merge");
|
||||
}
|
||||
let normalizedBaseRef = baseRef;
|
||||
if (normalizedBaseRef.startsWith("origin/")) {
|
||||
normalizedBaseRef = normalizedBaseRef.slice("origin/".length);
|
||||
}
|
||||
normalizedBaseRef = normalizeLocalBranchRefName(normalizedBaseRef);
|
||||
if (normalizedBaseRef === currentBranch) {
|
||||
return;
|
||||
}
|
||||
@@ -523,19 +600,24 @@ export async function createPullRequest(
|
||||
|
||||
const repoInfo = await detectRepoInfo(cwd);
|
||||
const head = options.head ?? (await getCurrentBranch(cwd));
|
||||
const base = options.base ?? (await resolveBaseRef(repoInfo.path));
|
||||
const configured = await getConfiguredBaseRefForCwd(cwd);
|
||||
const base = configured.baseRef ?? options.base ?? (await resolveBaseRef(repoInfo.path));
|
||||
if (!head) {
|
||||
throw new Error("Unable to determine head branch for PR");
|
||||
}
|
||||
if (!base) {
|
||||
throw new Error("Unable to determine base branch for PR");
|
||||
}
|
||||
const normalizedBase = normalizeLocalBranchRefName(base);
|
||||
if (configured.isPaseoOwnedWorktree && options.base && options.base !== base) {
|
||||
throw new Error(`Base ref mismatch: expected ${base}, got ${options.base}`);
|
||||
}
|
||||
|
||||
await execAsync(`git push -u origin ${head}`, { cwd });
|
||||
|
||||
const args = ["api", "-X", "POST", `repos/${repo}/pulls`, "-f", `title=${options.title}`];
|
||||
args.push("-f", `head=${head}`);
|
||||
args.push("-f", `base=${base}`);
|
||||
args.push("-f", `base=${normalizedBase}`);
|
||||
if (options.body) {
|
||||
args.push("-f", `body=${options.body}`);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,87 @@
|
||||
import { existsSync, mkdirSync, readFileSync, writeFileSync } from "fs";
|
||||
import { isAbsolute, join, resolve } from "path";
|
||||
import { z } from "zod";
|
||||
|
||||
const PaseoWorktreeMetadataSchema = z.object({
|
||||
version: z.literal(1),
|
||||
baseRefName: z.string().min(1),
|
||||
});
|
||||
|
||||
export type PaseoWorktreeMetadata = z.infer<typeof PaseoWorktreeMetadataSchema>;
|
||||
|
||||
function getGitDirForWorktreeRoot(worktreeRoot: string): string {
|
||||
const gitPath = join(worktreeRoot, ".git");
|
||||
if (!existsSync(gitPath)) {
|
||||
throw new Error(`Not a git repository: ${worktreeRoot}`);
|
||||
}
|
||||
|
||||
// In a worktree checkout, `.git` is a file containing `gitdir: <path>`.
|
||||
// In a normal checkout, `.git` is a directory.
|
||||
try {
|
||||
const gitFileContent = readFileSync(gitPath, "utf8");
|
||||
const match = gitFileContent.match(/gitdir:\s*(.+)/);
|
||||
if (match?.[1]) {
|
||||
const raw = match[1].trim();
|
||||
return isAbsolute(raw) ? raw : resolve(worktreeRoot, raw);
|
||||
}
|
||||
} catch {
|
||||
// If `.git` is a directory, readFileSync will throw; fall through.
|
||||
}
|
||||
|
||||
return gitPath;
|
||||
}
|
||||
|
||||
export function getPaseoWorktreeMetadataPath(worktreeRoot: string): string {
|
||||
const gitDir = getGitDirForWorktreeRoot(worktreeRoot);
|
||||
return join(gitDir, "paseo", "worktree.json");
|
||||
}
|
||||
|
||||
export function normalizeBaseRefName(input: string): string {
|
||||
const trimmed = input.trim();
|
||||
if (!trimmed) {
|
||||
throw new Error("Base branch is required");
|
||||
}
|
||||
if (trimmed.startsWith("origin/")) {
|
||||
return trimmed.slice("origin/".length);
|
||||
}
|
||||
return trimmed;
|
||||
}
|
||||
|
||||
export function writePaseoWorktreeMetadata(
|
||||
worktreeRoot: string,
|
||||
options: { baseRefName: string }
|
||||
): void {
|
||||
const baseRefName = normalizeBaseRefName(options.baseRefName);
|
||||
if (baseRefName === "HEAD") {
|
||||
throw new Error("Base branch cannot be HEAD");
|
||||
}
|
||||
if (baseRefName.includes("..") || baseRefName.includes("@{")) {
|
||||
throw new Error(`Invalid base branch: ${baseRefName}`);
|
||||
}
|
||||
if (!/^[0-9A-Za-z._/-]+$/.test(baseRefName)) {
|
||||
throw new Error(`Invalid base branch: ${baseRefName}`);
|
||||
}
|
||||
|
||||
const metadataPath = getPaseoWorktreeMetadataPath(worktreeRoot);
|
||||
mkdirSync(join(getGitDirForWorktreeRoot(worktreeRoot), "paseo"), { recursive: true });
|
||||
const metadata: PaseoWorktreeMetadata = { version: 1, baseRefName };
|
||||
writeFileSync(metadataPath, `${JSON.stringify(metadata, null, 2)}\n`, "utf8");
|
||||
}
|
||||
|
||||
export function readPaseoWorktreeMetadata(worktreeRoot: string): PaseoWorktreeMetadata | null {
|
||||
const metadataPath = getPaseoWorktreeMetadataPath(worktreeRoot);
|
||||
if (!existsSync(metadataPath)) {
|
||||
return null;
|
||||
}
|
||||
const parsed = JSON.parse(readFileSync(metadataPath, "utf8"));
|
||||
return PaseoWorktreeMetadataSchema.parse(parsed);
|
||||
}
|
||||
|
||||
export function requirePaseoWorktreeBaseRefName(worktreeRoot: string): string {
|
||||
const metadataPath = getPaseoWorktreeMetadataPath(worktreeRoot);
|
||||
const metadata = readPaseoWorktreeMetadata(worktreeRoot);
|
||||
if (!metadata) {
|
||||
throw new Error(`Missing Paseo worktree base metadata: ${metadataPath}`);
|
||||
}
|
||||
return metadata.baseRefName;
|
||||
}
|
||||
@@ -3,9 +3,11 @@ import {
|
||||
createWorktree,
|
||||
deletePaseoWorktree,
|
||||
ensurePaseoIgnored,
|
||||
isPaseoOwnedWorktreeCwd,
|
||||
listPaseoWorktrees,
|
||||
slugify,
|
||||
} from "./worktree";
|
||||
import { getPaseoWorktreeMetadataPath } from "./worktree-metadata.js";
|
||||
import { execSync } from "child_process";
|
||||
import { mkdtempSync, rmSync, existsSync, realpathSync, writeFileSync, readFileSync } from "fs";
|
||||
import { join } from "path";
|
||||
@@ -38,6 +40,7 @@ describe("createWorktree", () => {
|
||||
const result = await createWorktree({
|
||||
branchName: "main",
|
||||
cwd: repoDir,
|
||||
baseBranch: "main",
|
||||
worktreeSlug: "hello-world",
|
||||
});
|
||||
|
||||
@@ -46,6 +49,41 @@ describe("createWorktree", () => {
|
||||
);
|
||||
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")
|
||||
);
|
||||
expect(metadata).toMatchObject({ version: 1, baseRefName: "main" });
|
||||
});
|
||||
|
||||
it("detects paseo-owned worktrees across realpath differences (macOS /var vs /private/var)", async () => {
|
||||
// Intentionally create repo using the non-realpath tmpdir() variant (often /var/... on macOS).
|
||||
const varTempDir = mkdtempSync(join(tmpdir(), "worktree-realpath-test-"));
|
||||
const privateTempDir = realpathSync(varTempDir);
|
||||
const varRepoDir = join(varTempDir, "test-repo");
|
||||
execSync(`mkdir -p ${varRepoDir}`);
|
||||
execSync("git init -b main", { cwd: varRepoDir });
|
||||
execSync("git config user.email 'test@test.com'", { cwd: varRepoDir });
|
||||
execSync("git config user.name 'Test'", { cwd: varRepoDir });
|
||||
execSync("echo 'hello' > file.txt", { cwd: varRepoDir });
|
||||
execSync("git add .", { cwd: varRepoDir });
|
||||
execSync("git -c commit.gpgsign=false commit -m 'initial'", { cwd: varRepoDir });
|
||||
|
||||
const result = await createWorktree({
|
||||
branchName: "main",
|
||||
cwd: varRepoDir,
|
||||
baseBranch: "main",
|
||||
worktreeSlug: "realpath-test",
|
||||
});
|
||||
|
||||
const privateWorktreePath = join(privateTempDir, "test-repo", ".paseo", "worktrees", "realpath-test");
|
||||
expect(existsSync(privateWorktreePath)).toBe(true);
|
||||
|
||||
const ownership = await isPaseoOwnedWorktreeCwd(privateWorktreePath);
|
||||
expect(ownership.allowed).toBe(true);
|
||||
|
||||
rmSync(varTempDir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
it("creates a worktree with a new branch", async () => {
|
||||
@@ -64,6 +102,11 @@ describe("createWorktree", () => {
|
||||
// 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")
|
||||
);
|
||||
expect(metadata).toMatchObject({ version: 1, baseRefName: "main" });
|
||||
});
|
||||
|
||||
it("fails with invalid branch name", async () => {
|
||||
@@ -71,6 +114,7 @@ describe("createWorktree", () => {
|
||||
createWorktree({
|
||||
branchName: "INVALID_UPPERCASE",
|
||||
cwd: repoDir,
|
||||
baseBranch: "main",
|
||||
worktreeSlug: "test",
|
||||
})
|
||||
).rejects.toThrow("Invalid branch name");
|
||||
@@ -83,6 +127,7 @@ describe("createWorktree", () => {
|
||||
const result = await createWorktree({
|
||||
branchName: "main",
|
||||
cwd: repoDir,
|
||||
baseBranch: "main",
|
||||
worktreeSlug: "hello",
|
||||
});
|
||||
|
||||
@@ -104,6 +149,7 @@ describe("createWorktree", () => {
|
||||
const result = await createWorktree({
|
||||
branchName: "main",
|
||||
cwd: repoDir,
|
||||
baseBranch: "main",
|
||||
worktreeSlug: "hello",
|
||||
});
|
||||
|
||||
@@ -130,6 +176,7 @@ describe("createWorktree", () => {
|
||||
const result = await createWorktree({
|
||||
branchName: "main",
|
||||
cwd: repoDir,
|
||||
baseBranch: "main",
|
||||
worktreeSlug: "setup-test",
|
||||
});
|
||||
|
||||
@@ -157,6 +204,7 @@ describe("createWorktree", () => {
|
||||
const result = await createWorktree({
|
||||
branchName: "main",
|
||||
cwd: repoDir,
|
||||
baseBranch: "main",
|
||||
worktreeSlug: "no-setup-test",
|
||||
runSetup: false,
|
||||
});
|
||||
@@ -186,6 +234,7 @@ describe("createWorktree", () => {
|
||||
createWorktree({
|
||||
branchName: "main",
|
||||
cwd: repoDir,
|
||||
baseBranch: "main",
|
||||
worktreeSlug: "fail-test",
|
||||
})
|
||||
).rejects.toThrow("Worktree setup command failed");
|
||||
@@ -220,11 +269,13 @@ describe("paseo worktree manager", () => {
|
||||
const first = await createWorktree({
|
||||
branchName: "main",
|
||||
cwd: repoDir,
|
||||
baseBranch: "main",
|
||||
worktreeSlug: "alpha",
|
||||
});
|
||||
const second = await createWorktree({
|
||||
branchName: "main",
|
||||
cwd: repoDir,
|
||||
baseBranch: "main",
|
||||
worktreeSlug: "beta",
|
||||
});
|
||||
|
||||
|
||||
@@ -3,6 +3,7 @@ import { promisify } from "util";
|
||||
import { existsSync, mkdirSync, readFileSync, rmSync, writeFileSync } from "fs";
|
||||
import { join, basename, dirname, resolve, sep } from "path";
|
||||
import { createNameId } from "mnemonic-id";
|
||||
import { normalizeBaseRefName, writePaseoWorktreeMetadata } from "./worktree-metadata.js";
|
||||
|
||||
interface PaseoConfig {
|
||||
worktree?: {
|
||||
@@ -63,7 +64,7 @@ export type PaseoWorktreeOwnership = {
|
||||
interface CreateWorktreeOptions {
|
||||
branchName: string;
|
||||
cwd: string;
|
||||
baseBranch?: string;
|
||||
baseBranch: string;
|
||||
worktreeSlug?: string;
|
||||
runSetup?: boolean;
|
||||
}
|
||||
@@ -494,6 +495,19 @@ export async function createWorktree({
|
||||
// Ensure .paseo exists and is ignored
|
||||
ensurePaseoIgnoredForRepo(repoInfo);
|
||||
|
||||
const normalizedBaseBranch = baseBranch ? normalizeBaseRefName(baseBranch) : "";
|
||||
if (!normalizedBaseBranch) {
|
||||
throw new Error("Base branch is required when creating a Paseo worktree");
|
||||
}
|
||||
if (normalizedBaseBranch === "HEAD") {
|
||||
throw new Error("Base branch cannot be HEAD when creating a Paseo worktree");
|
||||
}
|
||||
try {
|
||||
await execAsync(`git rev-parse --verify ${normalizedBaseBranch}`, { cwd: repoInfo.path });
|
||||
} catch {
|
||||
throw new Error(`Base branch not found: ${normalizedBaseBranch}`);
|
||||
}
|
||||
|
||||
// Determine worktree directory based on repo type
|
||||
let worktreePath: string;
|
||||
const desiredSlug = worktreeSlug || generateWorktreeSlug();
|
||||
@@ -516,7 +530,7 @@ export async function createWorktree({
|
||||
// Always create a new branch for the worktree
|
||||
// If branchName already exists, use it as base and create worktree-slug as branch name
|
||||
// If branchName doesn't exist, create it from baseBranch
|
||||
const base = branchExists ? branchName : (baseBranch ?? "HEAD");
|
||||
const base = branchExists ? branchName : normalizedBaseBranch;
|
||||
const candidateBranch = branchExists ? desiredSlug : branchName;
|
||||
|
||||
// Find unique branch name if collision
|
||||
@@ -548,6 +562,8 @@ export async function createWorktree({
|
||||
await execAsync(command, { cwd: repoInfo.path });
|
||||
worktreePath = finalWorktreePath;
|
||||
|
||||
writePaseoWorktreeMetadata(worktreePath, { baseRefName: normalizedBaseBranch });
|
||||
|
||||
if (runSetup) {
|
||||
await runWorktreeSetupCommands({
|
||||
repoRoot: repoInfo.path,
|
||||
|
||||
Reference in New Issue
Block a user