feat(checkout): add merge-from-base and push operations for worktree branches
This commit is contained in:
@@ -8,8 +8,11 @@ import {
|
||||
getCheckoutDiff,
|
||||
getCheckoutStatus,
|
||||
mergeToBase,
|
||||
mergeFromBase,
|
||||
MergeConflictError,
|
||||
MergeFromBaseConflictError,
|
||||
NotGitRepoError,
|
||||
pushCurrentBranch,
|
||||
} from "./checkout-git.js";
|
||||
import { createWorktree } from "./worktree.js";
|
||||
import { getPaseoWorktreeMetadataPath } from "./worktree-metadata.js";
|
||||
@@ -30,11 +33,13 @@ function initRepo(): { tempDir: string; repoDir: string } {
|
||||
describe("checkout git utilities", () => {
|
||||
let tempDir: string;
|
||||
let repoDir: string;
|
||||
let paseoHome: string;
|
||||
|
||||
beforeEach(() => {
|
||||
const setup = initRepo();
|
||||
tempDir = setup.tempDir;
|
||||
repoDir = setup.repoDir;
|
||||
paseoHome = join(tempDir, "paseo-home");
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
@@ -57,6 +62,7 @@ describe("checkout git utilities", () => {
|
||||
expect(status.isGit).toBe(true);
|
||||
expect(status.currentBranch).toBe("main");
|
||||
expect(status.isDirty).toBe(true);
|
||||
expect(status.hasRemote).toBe(false);
|
||||
|
||||
const diff = await getCheckoutDiff(repoDir, { mode: "uncommitted" });
|
||||
expect(diff.diff).toContain("-hello");
|
||||
@@ -72,6 +78,18 @@ describe("checkout git utilities", () => {
|
||||
expect(message).toBe("update file");
|
||||
});
|
||||
|
||||
it("exposes hasRemote when origin is configured", async () => {
|
||||
const remoteDir = join(tempDir, "remote.git");
|
||||
execSync(`git init --bare ${remoteDir}`);
|
||||
execSync(`git remote add origin ${remoteDir}`, { cwd: repoDir });
|
||||
|
||||
const status = await getCheckoutStatus(repoDir);
|
||||
expect(status.isGit).toBe(true);
|
||||
if (status.isGit) {
|
||||
expect(status.hasRemote).toBe(true);
|
||||
}
|
||||
});
|
||||
|
||||
it("commits messages with quotes safely", async () => {
|
||||
const message = `He said "hello" and it's fine`;
|
||||
writeFileSync(join(repoDir, "file.txt"), "quoted\n");
|
||||
@@ -90,22 +108,23 @@ describe("checkout git utilities", () => {
|
||||
cwd: repoDir,
|
||||
baseBranch: "main",
|
||||
worktreeSlug: "alpha",
|
||||
paseoHome,
|
||||
});
|
||||
|
||||
writeFileSync(join(result.worktreePath, "file.txt"), "worktree change\n");
|
||||
|
||||
const status = await getCheckoutStatus(result.worktreePath);
|
||||
const status = await getCheckoutStatus(result.worktreePath, { paseoHome });
|
||||
expect(status.isGit).toBe(true);
|
||||
expect(status.repoRoot).toBe(repoDir);
|
||||
expect(status.isDirty).toBe(true);
|
||||
|
||||
const diff = await getCheckoutDiff(result.worktreePath, { mode: "uncommitted" });
|
||||
const diff = await getCheckoutDiff(result.worktreePath, { mode: "uncommitted" }, { paseoHome });
|
||||
expect(diff.diff).toContain("-hello");
|
||||
expect(diff.diff).toContain("+worktree change");
|
||||
|
||||
await commitAll(result.worktreePath, "worktree update");
|
||||
|
||||
const cleanStatus = await getCheckoutStatus(result.worktreePath);
|
||||
const cleanStatus = await getCheckoutStatus(result.worktreePath, { paseoHome });
|
||||
expect(cleanStatus.isDirty).toBe(false);
|
||||
const message = execSync("git log -1 --pretty=%B", {
|
||||
cwd: result.worktreePath,
|
||||
@@ -121,6 +140,7 @@ describe("checkout git utilities", () => {
|
||||
cwd: repoDir,
|
||||
baseBranch: "main",
|
||||
worktreeSlug: "merge",
|
||||
paseoHome,
|
||||
});
|
||||
|
||||
writeFileSync(join(worktree.worktreePath, "merge.txt"), "feature\n");
|
||||
@@ -133,7 +153,7 @@ describe("checkout git utilities", () => {
|
||||
.toString()
|
||||
.trim();
|
||||
|
||||
await mergeToBase(worktree.worktreePath, { baseRef: "main" });
|
||||
await mergeToBase(worktree.worktreePath, { baseRef: "main" }, { paseoHome });
|
||||
|
||||
const baseContainsFeature = execSync(`git merge-base --is-ancestor ${featureCommit} main`, {
|
||||
cwd: repoDir,
|
||||
@@ -141,7 +161,7 @@ describe("checkout git utilities", () => {
|
||||
});
|
||||
expect(baseContainsFeature).toBeDefined();
|
||||
|
||||
const statusAfterMerge = await getCheckoutStatus(worktree.worktreePath);
|
||||
const statusAfterMerge = await getCheckoutStatus(worktree.worktreePath, { paseoHome });
|
||||
expect(statusAfterMerge.isGit).toBe(true);
|
||||
if (statusAfterMerge.isGit) {
|
||||
expect(statusAfterMerge.aheadBehind?.ahead ?? 0).toBe(0);
|
||||
@@ -155,6 +175,102 @@ describe("checkout git utilities", () => {
|
||||
expect(currentBranch).toBe("feature");
|
||||
});
|
||||
|
||||
it("merges from the most-ahead base ref (origin/main when it is ahead)", async () => {
|
||||
const remoteDir = join(tempDir, "remote.git");
|
||||
execSync(`git init --bare ${remoteDir}`);
|
||||
execSync(`git remote add origin ${remoteDir}`, { cwd: repoDir });
|
||||
execSync("git push -u origin main", { cwd: repoDir });
|
||||
|
||||
// Advance origin/main without advancing local main.
|
||||
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 });
|
||||
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();
|
||||
execSync("git push", { cwd: otherClone });
|
||||
execSync("git fetch origin", { cwd: repoDir });
|
||||
|
||||
execSync("git checkout -b feature", { cwd: repoDir });
|
||||
writeFileSync(join(repoDir, "feature.txt"), "feature\n");
|
||||
execSync("git add feature.txt", { cwd: repoDir });
|
||||
execSync("git -c commit.gpgsign=false commit -m 'feature commit'", { cwd: repoDir });
|
||||
|
||||
await mergeFromBase(repoDir, { baseRef: "main", requireCleanTarget: true });
|
||||
|
||||
execSync(`git merge-base --is-ancestor ${remoteOnlyCommit} feature`, { cwd: repoDir });
|
||||
});
|
||||
|
||||
it("merges from the most-ahead base ref (local main when it is ahead)", async () => {
|
||||
const remoteDir = join(tempDir, "remote.git");
|
||||
execSync(`git init --bare ${remoteDir}`);
|
||||
execSync(`git remote add origin ${remoteDir}`, { cwd: repoDir });
|
||||
execSync("git push -u origin main", { cwd: repoDir });
|
||||
|
||||
// Advance local main without pushing.
|
||||
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();
|
||||
|
||||
execSync(`git checkout -b feature ${localOnlyCommit}~1`, { cwd: repoDir });
|
||||
writeFileSync(join(repoDir, "feature.txt"), "feature\n");
|
||||
execSync("git add feature.txt", { cwd: repoDir });
|
||||
execSync("git -c commit.gpgsign=false commit -m 'feature commit'", { cwd: repoDir });
|
||||
|
||||
await mergeFromBase(repoDir, { baseRef: "main", requireCleanTarget: true });
|
||||
|
||||
execSync(`git merge-base --is-ancestor ${localOnlyCommit} feature`, { cwd: repoDir });
|
||||
});
|
||||
|
||||
it("aborts merge-from-base on conflicts and leaves no merge in progress", async () => {
|
||||
writeFileSync(join(repoDir, "conflict.txt"), "base\n");
|
||||
execSync("git add conflict.txt", { cwd: repoDir });
|
||||
execSync("git -c commit.gpgsign=false commit -m 'add conflict file'", { cwd: repoDir });
|
||||
|
||||
execSync("git checkout -b feature", { cwd: repoDir });
|
||||
writeFileSync(join(repoDir, "conflict.txt"), "feature\n");
|
||||
execSync("git add conflict.txt", { cwd: repoDir });
|
||||
execSync("git -c commit.gpgsign=false commit -m 'feature change'", { cwd: repoDir });
|
||||
|
||||
execSync("git checkout main", { cwd: repoDir });
|
||||
writeFileSync(join(repoDir, "conflict.txt"), "main change\n");
|
||||
execSync("git add conflict.txt", { cwd: repoDir });
|
||||
execSync("git -c commit.gpgsign=false commit -m 'main change'", { cwd: repoDir });
|
||||
|
||||
execSync("git checkout feature", { cwd: repoDir });
|
||||
|
||||
await expect(
|
||||
mergeFromBase(repoDir, { baseRef: "main", requireCleanTarget: true })
|
||||
).rejects.toBeInstanceOf(MergeFromBaseConflictError);
|
||||
|
||||
const porcelain = execSync("git status --porcelain", { cwd: repoDir }).toString().trim();
|
||||
expect(porcelain).toBe("");
|
||||
expect(() => execSync("git rev-parse -q --verify MERGE_HEAD", { cwd: repoDir })).toThrow();
|
||||
});
|
||||
|
||||
it("pushes the current branch to origin", async () => {
|
||||
const remoteDir = join(tempDir, "remote.git");
|
||||
execSync(`git init --bare ${remoteDir}`);
|
||||
execSync(`git remote add origin ${remoteDir}`, { cwd: repoDir });
|
||||
execSync("git push -u origin main", { cwd: repoDir });
|
||||
|
||||
execSync("git checkout -b feature", { cwd: repoDir });
|
||||
writeFileSync(join(repoDir, "push.txt"), "push\n");
|
||||
execSync("git add push.txt", { cwd: repoDir });
|
||||
execSync("git -c commit.gpgsign=false commit -m 'push commit'", { cwd: repoDir });
|
||||
|
||||
await pushCurrentBranch(repoDir);
|
||||
|
||||
execSync(`git --git-dir ${remoteDir} show-ref --verify refs/heads/feature`);
|
||||
});
|
||||
|
||||
it("returns typed MergeConflictError on merge conflicts", async () => {
|
||||
const conflictFile = join(repoDir, "conflict.txt");
|
||||
writeFileSync(conflictFile, "base\n");
|
||||
@@ -198,6 +314,7 @@ describe("checkout git utilities", () => {
|
||||
cwd: repoDir,
|
||||
baseBranch: "develop",
|
||||
worktreeSlug: "feature",
|
||||
paseoHome,
|
||||
});
|
||||
|
||||
writeFileSync(join(worktree.worktreePath, "feature.txt"), "feature\n");
|
||||
@@ -206,12 +323,12 @@ describe("checkout git utilities", () => {
|
||||
cwd: worktree.worktreePath,
|
||||
});
|
||||
|
||||
const status = await getCheckoutStatus(worktree.worktreePath);
|
||||
const status = await getCheckoutStatus(worktree.worktreePath, { paseoHome });
|
||||
expect(status.isGit).toBe(true);
|
||||
expect(status.baseRef).toBe("develop");
|
||||
expect(status.aheadBehind?.ahead).toBe(1);
|
||||
|
||||
const baseDiff = await getCheckoutDiff(worktree.worktreePath, { mode: "base" });
|
||||
const baseDiff = await getCheckoutDiff(worktree.worktreePath, { mode: "base" }, { paseoHome });
|
||||
expect(baseDiff.diff).toContain("feature.txt");
|
||||
expect(baseDiff.diff).not.toContain("file.txt");
|
||||
});
|
||||
@@ -230,6 +347,7 @@ describe("checkout git utilities", () => {
|
||||
cwd: repoDir,
|
||||
baseBranch: "develop",
|
||||
worktreeSlug: "merge-to-develop",
|
||||
paseoHome,
|
||||
});
|
||||
|
||||
writeFileSync(join(worktree.worktreePath, "feature.txt"), "feature\n");
|
||||
@@ -242,7 +360,7 @@ describe("checkout git utilities", () => {
|
||||
.trim();
|
||||
|
||||
// No baseRef passed: should merge into the configured base (develop), not default/main.
|
||||
await mergeToBase(worktree.worktreePath);
|
||||
await mergeToBase(worktree.worktreePath, {}, { paseoHome });
|
||||
|
||||
execSync(`git merge-base --is-ancestor ${featureCommit} develop`, {
|
||||
cwd: repoDir,
|
||||
@@ -262,17 +380,18 @@ describe("checkout git utilities", () => {
|
||||
cwd: repoDir,
|
||||
baseBranch: "main",
|
||||
worktreeSlug: "missing-metadata",
|
||||
paseoHome,
|
||||
});
|
||||
|
||||
const metadataPath = getPaseoWorktreeMetadataPath(worktree.worktreePath);
|
||||
rmSync(metadataPath, { force: true });
|
||||
|
||||
await expect(getCheckoutStatus(worktree.worktreePath)).rejects.toThrow(
|
||||
await expect(getCheckoutStatus(worktree.worktreePath, { paseoHome })).rejects.toThrow(
|
||||
/base/i
|
||||
);
|
||||
await expect(getCheckoutDiff(worktree.worktreePath, { mode: "base" })).rejects.toThrow(
|
||||
await expect(getCheckoutDiff(worktree.worktreePath, { mode: "base" }, { paseoHome })).rejects.toThrow(
|
||||
/base/i
|
||||
);
|
||||
await expect(mergeToBase(worktree.worktreePath)).rejects.toThrow(/base/i);
|
||||
await expect(mergeToBase(worktree.worktreePath, {}, { paseoHome })).rejects.toThrow(/base/i);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -38,6 +38,22 @@ export class MergeConflictError extends Error {
|
||||
}
|
||||
}
|
||||
|
||||
export class MergeFromBaseConflictError extends Error {
|
||||
readonly baseRef: string;
|
||||
readonly currentBranch: string;
|
||||
readonly conflictFiles: string[];
|
||||
|
||||
constructor(options: { baseRef: string; currentBranch: string; conflictFiles: string[] }) {
|
||||
super(
|
||||
`Merge conflict while merging ${options.baseRef} into ${options.currentBranch}. Please merge manually.`
|
||||
);
|
||||
this.name = "MergeFromBaseConflictError";
|
||||
this.baseRef = options.baseRef;
|
||||
this.currentBranch = options.currentBranch;
|
||||
this.conflictFiles = options.conflictFiles;
|
||||
}
|
||||
}
|
||||
|
||||
export interface AheadBehind {
|
||||
ahead: number;
|
||||
behind: number;
|
||||
@@ -54,6 +70,7 @@ export type CheckoutStatusGitNonPaseo = {
|
||||
isDirty: boolean;
|
||||
baseRef: string | null;
|
||||
aheadBehind: AheadBehind | null;
|
||||
hasRemote: boolean;
|
||||
isPaseoOwnedWorktree: false;
|
||||
};
|
||||
|
||||
@@ -64,6 +81,7 @@ export type CheckoutStatusGitPaseo = {
|
||||
isDirty: boolean;
|
||||
baseRef: string;
|
||||
aheadBehind: AheadBehind | null;
|
||||
hasRemote: boolean;
|
||||
isPaseoOwnedWorktree: true;
|
||||
};
|
||||
|
||||
@@ -88,6 +106,15 @@ export interface MergeToBaseOptions {
|
||||
commitMessage?: string;
|
||||
}
|
||||
|
||||
export interface MergeFromBaseOptions {
|
||||
baseRef?: string;
|
||||
requireCleanTarget?: boolean;
|
||||
}
|
||||
|
||||
export type CheckoutContext = {
|
||||
paseoHome?: string;
|
||||
};
|
||||
|
||||
function isGitError(error: unknown): boolean {
|
||||
if (!(error instanceof Error)) {
|
||||
return false;
|
||||
@@ -204,9 +231,10 @@ type ConfiguredBaseRefForCwd =
|
||||
| { baseRef: string; isPaseoOwnedWorktree: true };
|
||||
|
||||
async function getConfiguredBaseRefForCwd(
|
||||
cwd: string
|
||||
cwd: string,
|
||||
context?: CheckoutContext
|
||||
): Promise<ConfiguredBaseRefForCwd> {
|
||||
const ownership = await isPaseoOwnedWorktreeCwd(cwd);
|
||||
const ownership = await isPaseoOwnedWorktreeCwd(cwd, { paseoHome: context?.paseoHome });
|
||||
if (!ownership.allowed) {
|
||||
return { baseRef: null, isPaseoOwnedWorktree: false };
|
||||
}
|
||||
@@ -229,6 +257,18 @@ async function isWorkingTreeDirty(cwd: string, repoType: "bare" | "normal"): Pro
|
||||
return stdout.trim().length > 0;
|
||||
}
|
||||
|
||||
async function hasOriginRemote(cwd: string): Promise<boolean> {
|
||||
try {
|
||||
const { stdout } = await execAsync("git config --get remote.origin.url", {
|
||||
cwd,
|
||||
env: READ_ONLY_GIT_ENV,
|
||||
});
|
||||
return stdout.trim().length > 0;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
async function resolveBaseRef(repoRoot: string): Promise<string | null> {
|
||||
try {
|
||||
const { stdout } = await execAsync("git symbolic-ref --quiet refs/remotes/origin/HEAD", {
|
||||
@@ -280,6 +320,53 @@ function normalizeLocalBranchRefName(input: string): string {
|
||||
return input.startsWith("origin/") ? input.slice("origin/".length) : input;
|
||||
}
|
||||
|
||||
async function doesGitRefExist(cwd: string, fullRef: string): Promise<boolean> {
|
||||
try {
|
||||
await execAsync(`git show-ref --verify --quiet ${fullRef}`, {
|
||||
cwd,
|
||||
env: READ_ONLY_GIT_ENV,
|
||||
});
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
async function resolveBestBaseRefForMerge(cwd: string, normalizedBaseRef: string): Promise<string> {
|
||||
const [hasLocal, hasOrigin] = await Promise.all([
|
||||
doesGitRefExist(cwd, `refs/heads/${normalizedBaseRef}`),
|
||||
doesGitRefExist(cwd, `refs/remotes/origin/${normalizedBaseRef}`),
|
||||
]);
|
||||
|
||||
if (hasLocal && !hasOrigin) {
|
||||
return normalizedBaseRef;
|
||||
}
|
||||
if (!hasLocal && hasOrigin) {
|
||||
return `origin/${normalizedBaseRef}`;
|
||||
}
|
||||
if (!hasLocal && !hasOrigin) {
|
||||
throw new Error(`Base branch not found locally or on origin: ${normalizedBaseRef}`);
|
||||
}
|
||||
|
||||
// Both exist: choose the ref with more unique commits compared to the other.
|
||||
try {
|
||||
const { stdout } = await execAsync(
|
||||
`git rev-list --left-right --count ${normalizedBaseRef}...origin/${normalizedBaseRef}`,
|
||||
{ cwd, env: READ_ONLY_GIT_ENV }
|
||||
);
|
||||
const [localOnlyRaw, originOnlyRaw] = stdout.trim().split(/\s+/);
|
||||
const localOnly = Number.parseInt(localOnlyRaw ?? "0", 10);
|
||||
const originOnly = Number.parseInt(originOnlyRaw ?? "0", 10);
|
||||
if (!Number.isNaN(localOnly) && !Number.isNaN(originOnly) && originOnly > localOnly) {
|
||||
return `origin/${normalizedBaseRef}`;
|
||||
}
|
||||
} catch {
|
||||
// ignore and fall back to local
|
||||
}
|
||||
|
||||
return normalizedBaseRef;
|
||||
}
|
||||
|
||||
async function getAheadBehind(cwd: string, baseRef: string, currentBranch: string): Promise<AheadBehind | null> {
|
||||
const normalizedBaseRef = normalizeLocalBranchRefName(baseRef);
|
||||
if (!normalizedBaseRef || !currentBranch || normalizedBaseRef === currentBranch) {
|
||||
@@ -326,7 +413,10 @@ async function getUntrackedDiff(cwd: string): Promise<string> {
|
||||
return untrackedDiff;
|
||||
}
|
||||
|
||||
export async function getCheckoutStatus(cwd: string): Promise<CheckoutStatusResult> {
|
||||
export async function getCheckoutStatus(
|
||||
cwd: string,
|
||||
context?: CheckoutContext
|
||||
): Promise<CheckoutStatusResult> {
|
||||
let repoInfo: Awaited<ReturnType<typeof detectRepoInfo>>;
|
||||
try {
|
||||
repoInfo = await detectRepoInfo(cwd);
|
||||
@@ -339,7 +429,8 @@ export async function getCheckoutStatus(cwd: string): Promise<CheckoutStatusResu
|
||||
|
||||
const currentBranch = await getCurrentBranch(cwd);
|
||||
const isDirty = await isWorkingTreeDirty(cwd, repoInfo.type);
|
||||
const configured = await getConfiguredBaseRefForCwd(cwd);
|
||||
const hasRemote = await hasOriginRemote(repoInfo.path);
|
||||
const configured = await getConfiguredBaseRefForCwd(cwd, context);
|
||||
const baseRef = configured.baseRef ?? (await resolveBaseRef(repoInfo.path));
|
||||
const aheadBehind =
|
||||
baseRef && currentBranch ? await getAheadBehind(cwd, baseRef, currentBranch) : null;
|
||||
@@ -352,6 +443,7 @@ export async function getCheckoutStatus(cwd: string): Promise<CheckoutStatusResu
|
||||
isDirty,
|
||||
baseRef: configured.baseRef,
|
||||
aheadBehind,
|
||||
hasRemote,
|
||||
isPaseoOwnedWorktree: true,
|
||||
};
|
||||
}
|
||||
@@ -363,13 +455,15 @@ export async function getCheckoutStatus(cwd: string): Promise<CheckoutStatusResu
|
||||
isDirty,
|
||||
baseRef,
|
||||
aheadBehind,
|
||||
hasRemote,
|
||||
isPaseoOwnedWorktree: false,
|
||||
};
|
||||
}
|
||||
|
||||
export async function getCheckoutDiff(
|
||||
cwd: string,
|
||||
compare: CheckoutDiffCompare
|
||||
compare: CheckoutDiffCompare,
|
||||
context?: CheckoutContext
|
||||
): Promise<CheckoutDiffResult> {
|
||||
await requireRepoInfo(cwd);
|
||||
|
||||
@@ -382,7 +476,7 @@ export async function getCheckoutDiff(
|
||||
const untrackedDiff = await getUntrackedDiff(cwd);
|
||||
diff = trackedDiff + untrackedDiff;
|
||||
} else {
|
||||
const configured = await getConfiguredBaseRefForCwd(cwd);
|
||||
const configured = await getConfiguredBaseRefForCwd(cwd, context);
|
||||
const repoInfo = await detectRepoInfo(cwd);
|
||||
const baseRef = configured.baseRef ?? compare.baseRef ?? (await resolveBaseRef(repoInfo.path));
|
||||
if (!baseRef) {
|
||||
@@ -422,10 +516,14 @@ export async function commitAll(cwd: string, message: string): Promise<void> {
|
||||
await commitChanges(cwd, { message, addAll: true });
|
||||
}
|
||||
|
||||
export async function mergeToBase(cwd: string, options: MergeToBaseOptions = {}): Promise<void> {
|
||||
export async function mergeToBase(
|
||||
cwd: string,
|
||||
options: MergeToBaseOptions = {},
|
||||
context?: CheckoutContext
|
||||
): Promise<void> {
|
||||
const repoInfo = await requireRepoInfo(cwd);
|
||||
const currentBranch = await getCurrentBranch(cwd);
|
||||
const configured = await getConfiguredBaseRefForCwd(cwd);
|
||||
const configured = await getConfiguredBaseRefForCwd(cwd, context);
|
||||
const baseRef = configured.baseRef ?? options.baseRef ?? (await resolveBaseRef(repoInfo.path));
|
||||
if (!baseRef) {
|
||||
throw new Error("Unable to determine base branch for merge");
|
||||
@@ -524,6 +622,112 @@ export async function mergeToBase(cwd: string, options: MergeToBaseOptions = {})
|
||||
}
|
||||
}
|
||||
|
||||
export async function mergeFromBase(
|
||||
cwd: string,
|
||||
options: MergeFromBaseOptions = {},
|
||||
context?: CheckoutContext
|
||||
): Promise<void> {
|
||||
const repoInfo = await requireRepoInfo(cwd);
|
||||
const currentBranch = await getCurrentBranch(cwd);
|
||||
if (!currentBranch || currentBranch === "HEAD") {
|
||||
throw new Error("Unable to determine current branch for merge");
|
||||
}
|
||||
|
||||
const configured = await getConfiguredBaseRefForCwd(cwd, context);
|
||||
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}`);
|
||||
}
|
||||
|
||||
const requireCleanTarget = options.requireCleanTarget ?? true;
|
||||
if (requireCleanTarget) {
|
||||
const { stdout } = await execAsync("git status --porcelain", {
|
||||
cwd,
|
||||
env: READ_ONLY_GIT_ENV,
|
||||
});
|
||||
if (stdout.trim().length > 0) {
|
||||
throw new Error("Working directory has uncommitted changes.");
|
||||
}
|
||||
}
|
||||
|
||||
const normalizedBaseRef = normalizeLocalBranchRefName(baseRef);
|
||||
const bestBaseRef = await resolveBestBaseRefForMerge(cwd, normalizedBaseRef);
|
||||
if (bestBaseRef === currentBranch) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
await execAsync(`git merge ${bestBaseRef}`, { cwd });
|
||||
} catch (error) {
|
||||
const errorDetails =
|
||||
error instanceof Error
|
||||
? `${error.message}\n${(error as any).stderr ?? ""}\n${(error as any).stdout ?? ""}`
|
||||
: String(error);
|
||||
try {
|
||||
const [unmergedOutput, lsFilesOutput, statusOutput] = await Promise.all([
|
||||
execAsync("git diff --name-only --diff-filter=U", { cwd }),
|
||||
execAsync("git ls-files -u", { cwd }),
|
||||
execAsync("git status --porcelain", { cwd }),
|
||||
]);
|
||||
const statusConflicts = statusOutput.stdout
|
||||
.split("\n")
|
||||
.map((line) => line.trim())
|
||||
.filter(Boolean)
|
||||
.filter((line) => /^(UU|AA|DD|AU|UA|UD|DU)\s/.test(line))
|
||||
.map((line) => line.slice(3).trim());
|
||||
const conflicts = [
|
||||
...unmergedOutput.stdout
|
||||
.split("\n")
|
||||
.map((line) => line.trim())
|
||||
.filter(Boolean),
|
||||
...lsFilesOutput.stdout
|
||||
.split("\n")
|
||||
.map((line) => line.trim())
|
||||
.filter(Boolean)
|
||||
.map((line) => line.split("\t").pop() as string),
|
||||
...statusConflicts,
|
||||
].filter(Boolean);
|
||||
const conflictDetected =
|
||||
conflicts.length > 0 || /CONFLICT|Automatic merge failed/i.test(errorDetails);
|
||||
if (conflictDetected) {
|
||||
try {
|
||||
await execAsync("git merge --abort", { cwd });
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
throw new MergeFromBaseConflictError({
|
||||
baseRef: bestBaseRef,
|
||||
currentBranch,
|
||||
conflictFiles: conflicts.length > 0 ? conflicts : [],
|
||||
});
|
||||
}
|
||||
} catch (innerError) {
|
||||
if (innerError instanceof MergeFromBaseConflictError) {
|
||||
throw innerError;
|
||||
}
|
||||
// ignore detection failures
|
||||
}
|
||||
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
export async function pushCurrentBranch(cwd: string): Promise<void> {
|
||||
await requireRepoInfo(cwd);
|
||||
const currentBranch = await getCurrentBranch(cwd);
|
||||
if (!currentBranch || currentBranch === "HEAD") {
|
||||
throw new Error("Unable to determine current branch for push");
|
||||
}
|
||||
const hasRemote = await hasOriginRemote(cwd);
|
||||
if (!hasRemote) {
|
||||
throw new Error("Remote 'origin' is not configured.");
|
||||
}
|
||||
await execAsync(`git push -u origin ${currentBranch}`, { cwd });
|
||||
}
|
||||
|
||||
export interface CreatePullRequestOptions {
|
||||
title: string;
|
||||
body?: string;
|
||||
|
||||
@@ -16,11 +16,13 @@ import { tmpdir } from "os";
|
||||
describe("createWorktree", () => {
|
||||
let tempDir: string;
|
||||
let repoDir: string;
|
||||
let paseoHome: string;
|
||||
|
||||
beforeEach(() => {
|
||||
// Use realpathSync to resolve symlinks (e.g., /var -> /private/var on macOS)
|
||||
tempDir = realpathSync(mkdtempSync(join(tmpdir(), "worktree-test-")));
|
||||
repoDir = join(tempDir, "test-repo");
|
||||
paseoHome = join(tempDir, "paseo-home");
|
||||
|
||||
// Create a git repo with an initial commit
|
||||
execSync(`mkdir -p ${repoDir}`);
|
||||
@@ -42,10 +44,11 @@ describe("createWorktree", () => {
|
||||
cwd: repoDir,
|
||||
baseBranch: "main",
|
||||
worktreeSlug: "hello-world",
|
||||
paseoHome,
|
||||
});
|
||||
|
||||
expect(result.worktreePath).toBe(
|
||||
join(repoDir, ".paseo", "worktrees", "hello-world")
|
||||
join(paseoHome, "worktrees", "test-repo", "hello-world")
|
||||
);
|
||||
expect(existsSync(result.worktreePath)).toBe(true);
|
||||
expect(existsSync(join(result.worktreePath, "file.txt"))).toBe(true);
|
||||
@@ -62,6 +65,7 @@ describe("createWorktree", () => {
|
||||
const varTempDir = mkdtempSync(join(tmpdir(), "worktree-realpath-test-"));
|
||||
const privateTempDir = realpathSync(varTempDir);
|
||||
const varRepoDir = join(varTempDir, "test-repo");
|
||||
const varPaseoHome = join(varTempDir, "paseo-home");
|
||||
execSync(`mkdir -p ${varRepoDir}`);
|
||||
execSync("git init -b main", { cwd: varRepoDir });
|
||||
execSync("git config user.email 'test@test.com'", { cwd: varRepoDir });
|
||||
@@ -75,12 +79,13 @@ describe("createWorktree", () => {
|
||||
cwd: varRepoDir,
|
||||
baseBranch: "main",
|
||||
worktreeSlug: "realpath-test",
|
||||
paseoHome: varPaseoHome,
|
||||
});
|
||||
|
||||
const privateWorktreePath = join(privateTempDir, "test-repo", ".paseo", "worktrees", "realpath-test");
|
||||
const privateWorktreePath = join(privateTempDir, "paseo-home", "worktrees", "test-repo", "realpath-test");
|
||||
expect(existsSync(privateWorktreePath)).toBe(true);
|
||||
|
||||
const ownership = await isPaseoOwnedWorktreeCwd(privateWorktreePath);
|
||||
const ownership = await isPaseoOwnedWorktreeCwd(privateWorktreePath, { paseoHome: varPaseoHome });
|
||||
expect(ownership.allowed).toBe(true);
|
||||
|
||||
rmSync(varTempDir, { recursive: true, force: true });
|
||||
@@ -92,10 +97,11 @@ describe("createWorktree", () => {
|
||||
cwd: repoDir,
|
||||
baseBranch: "main",
|
||||
worktreeSlug: "my-feature",
|
||||
paseoHome,
|
||||
});
|
||||
|
||||
expect(result.worktreePath).toBe(
|
||||
join(repoDir, ".paseo", "worktrees", "my-feature")
|
||||
join(paseoHome, "worktrees", "test-repo", "my-feature")
|
||||
);
|
||||
expect(existsSync(result.worktreePath)).toBe(true);
|
||||
|
||||
@@ -129,11 +135,12 @@ describe("createWorktree", () => {
|
||||
cwd: repoDir,
|
||||
baseBranch: "main",
|
||||
worktreeSlug: "hello",
|
||||
paseoHome,
|
||||
});
|
||||
|
||||
// Should create branch "hello-1" since "hello" exists
|
||||
expect(result.worktreePath).toBe(
|
||||
join(repoDir, ".paseo", "worktrees", "hello")
|
||||
join(paseoHome, "worktrees", "test-repo", "hello")
|
||||
);
|
||||
expect(existsSync(result.worktreePath)).toBe(true);
|
||||
|
||||
@@ -151,6 +158,7 @@ describe("createWorktree", () => {
|
||||
cwd: repoDir,
|
||||
baseBranch: "main",
|
||||
worktreeSlug: "hello",
|
||||
paseoHome,
|
||||
});
|
||||
|
||||
expect(existsSync(result.worktreePath)).toBe(true);
|
||||
@@ -178,6 +186,7 @@ describe("createWorktree", () => {
|
||||
cwd: repoDir,
|
||||
baseBranch: "main",
|
||||
worktreeSlug: "setup-test",
|
||||
paseoHome,
|
||||
});
|
||||
|
||||
expect(existsSync(result.worktreePath)).toBe(true);
|
||||
@@ -207,6 +216,7 @@ describe("createWorktree", () => {
|
||||
baseBranch: "main",
|
||||
worktreeSlug: "no-setup-test",
|
||||
runSetup: false,
|
||||
paseoHome,
|
||||
});
|
||||
|
||||
expect(existsSync(result.worktreePath)).toBe(true);
|
||||
@@ -224,9 +234,9 @@ describe("createWorktree", () => {
|
||||
execSync("git add paseo.json && git -c commit.gpgsign=false commit -m 'add paseo.json'", { cwd: repoDir });
|
||||
|
||||
const expectedWorktreePath = join(
|
||||
repoDir,
|
||||
".paseo",
|
||||
paseoHome,
|
||||
"worktrees",
|
||||
"test-repo",
|
||||
"fail-test"
|
||||
);
|
||||
|
||||
@@ -236,6 +246,7 @@ describe("createWorktree", () => {
|
||||
cwd: repoDir,
|
||||
baseBranch: "main",
|
||||
worktreeSlug: "fail-test",
|
||||
paseoHome,
|
||||
})
|
||||
).rejects.toThrow("Worktree setup command failed");
|
||||
|
||||
@@ -247,10 +258,12 @@ describe("createWorktree", () => {
|
||||
describe("paseo worktree manager", () => {
|
||||
let tempDir: string;
|
||||
let repoDir: string;
|
||||
let paseoHome: string;
|
||||
|
||||
beforeEach(() => {
|
||||
tempDir = realpathSync(mkdtempSync(join(tmpdir(), "worktree-manager-test-")));
|
||||
repoDir = join(tempDir, "test-repo");
|
||||
paseoHome = join(tempDir, "paseo-home");
|
||||
|
||||
execSync(`mkdir -p ${repoDir}`);
|
||||
execSync("git init", { cwd: repoDir });
|
||||
@@ -265,28 +278,30 @@ describe("paseo worktree manager", () => {
|
||||
rmSync(tempDir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
it("lists and deletes paseo worktrees under .paseo/worktrees", async () => {
|
||||
it("lists and deletes paseo worktrees under ~/.paseo/worktrees/{project}", async () => {
|
||||
const first = await createWorktree({
|
||||
branchName: "main",
|
||||
cwd: repoDir,
|
||||
baseBranch: "main",
|
||||
worktreeSlug: "alpha",
|
||||
paseoHome,
|
||||
});
|
||||
const second = await createWorktree({
|
||||
branchName: "main",
|
||||
cwd: repoDir,
|
||||
baseBranch: "main",
|
||||
worktreeSlug: "beta",
|
||||
paseoHome,
|
||||
});
|
||||
|
||||
const worktrees = await listPaseoWorktrees({ cwd: repoDir });
|
||||
const worktrees = await listPaseoWorktrees({ cwd: repoDir, paseoHome });
|
||||
const paths = worktrees.map((worktree) => worktree.path).sort();
|
||||
expect(paths).toEqual([first.worktreePath, second.worktreePath].sort());
|
||||
|
||||
await deletePaseoWorktree({ cwd: repoDir, worktreePath: first.worktreePath });
|
||||
await deletePaseoWorktree({ cwd: repoDir, worktreePath: first.worktreePath, paseoHome });
|
||||
expect(existsSync(first.worktreePath)).toBe(false);
|
||||
|
||||
const remaining = await listPaseoWorktrees({ cwd: repoDir });
|
||||
const remaining = await listPaseoWorktrees({ cwd: repoDir, paseoHome });
|
||||
expect(remaining.map((worktree) => worktree.path)).toEqual([second.worktreePath]);
|
||||
});
|
||||
|
||||
|
||||
@@ -1,9 +1,10 @@
|
||||
import { exec } from "child_process";
|
||||
import { promisify } from "util";
|
||||
import { existsSync, mkdirSync, readFileSync, rmSync, writeFileSync } from "fs";
|
||||
import { existsSync, mkdirSync, readFileSync, realpathSync, rmSync, writeFileSync } from "fs";
|
||||
import { join, basename, dirname, resolve, sep } from "path";
|
||||
import { createNameId } from "mnemonic-id";
|
||||
import { normalizeBaseRefName, writePaseoWorktreeMetadata } from "./worktree-metadata.js";
|
||||
import { resolvePaseoHome } from "../server/paseo-home.js";
|
||||
|
||||
interface PaseoConfig {
|
||||
worktree?: {
|
||||
@@ -67,6 +68,7 @@ interface CreateWorktreeOptions {
|
||||
baseBranch: string;
|
||||
worktreeSlug?: string;
|
||||
runSetup?: boolean;
|
||||
paseoHome?: string;
|
||||
}
|
||||
|
||||
function readPaseoConfig(repoRoot: string): PaseoConfig | null {
|
||||
@@ -315,17 +317,87 @@ function generateWorktreeSlug(): string {
|
||||
return createNameId();
|
||||
}
|
||||
|
||||
function getPaseoWorktreesRoot(repoRoot: string): string {
|
||||
return join(repoRoot, ".paseo", "worktrees");
|
||||
function tryParseGitRemote(remoteUrl: string): { host?: string; path: string } | null {
|
||||
const cleaned = remoteUrl.trim().replace(/\.git$/i, "");
|
||||
if (!cleaned) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (cleaned.includes("://")) {
|
||||
try {
|
||||
const url = new URL(cleaned);
|
||||
return { host: url.hostname, path: url.pathname.replace(/^\/+/, "") };
|
||||
} catch {
|
||||
// fall through
|
||||
}
|
||||
}
|
||||
|
||||
// Support scp-like syntax: git@github.com:owner/repo
|
||||
const scpMatch = cleaned.match(/^(?:.+@)?([^:]+):(.+)$/);
|
||||
if (scpMatch?.[2]) {
|
||||
return { host: scpMatch[1], path: scpMatch[2].replace(/^\/+/, "") };
|
||||
}
|
||||
|
||||
return { path: cleaned.replace(/^\/+/, "") };
|
||||
}
|
||||
|
||||
function inferProjectNameFromRemote(remoteUrl: string): string | null {
|
||||
const parsed = tryParseGitRemote(remoteUrl);
|
||||
if (!parsed?.path) {
|
||||
return null;
|
||||
}
|
||||
const segments = parsed.path.split("/").filter((segment) => segment.length > 0);
|
||||
if (segments.length === 0) {
|
||||
return null;
|
||||
}
|
||||
return segments[segments.length - 1] ?? null;
|
||||
}
|
||||
|
||||
async function detectWorktreeProject(repoRoot: string): Promise<string> {
|
||||
try {
|
||||
const { stdout } = await execAsync("git config --get remote.origin.url", {
|
||||
cwd: repoRoot,
|
||||
env: READ_ONLY_GIT_ENV,
|
||||
});
|
||||
const remote = stdout.trim();
|
||||
if (remote) {
|
||||
const inferred = inferProjectNameFromRemote(remote);
|
||||
if (inferred) {
|
||||
const projected = slugify(inferred);
|
||||
if (projected) {
|
||||
return projected;
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
|
||||
return slugify(basename(repoRoot));
|
||||
}
|
||||
|
||||
async function getPaseoWorktreesRoot(repoRoot: string, paseoHome?: string): Promise<string> {
|
||||
const home = paseoHome ? resolve(paseoHome) : resolvePaseoHome();
|
||||
const project = await detectWorktreeProject(repoRoot);
|
||||
return join(home, "worktrees", project);
|
||||
}
|
||||
|
||||
function normalizePathForOwnership(input: string): string {
|
||||
try {
|
||||
return realpathSync(input);
|
||||
} catch {
|
||||
return resolve(input);
|
||||
}
|
||||
}
|
||||
|
||||
export async function isPaseoOwnedWorktreeCwd(
|
||||
cwd: string
|
||||
cwd: string,
|
||||
options?: { paseoHome?: string }
|
||||
): Promise<PaseoWorktreeOwnership> {
|
||||
const repoInfo = await detectRepoInfo(cwd);
|
||||
const worktreesRoot = getPaseoWorktreesRoot(repoInfo.path);
|
||||
const resolvedRoot = resolve(worktreesRoot) + sep;
|
||||
const resolvedCwd = resolve(cwd);
|
||||
const worktreesRoot = await getPaseoWorktreesRoot(repoInfo.path, options?.paseoHome);
|
||||
const resolvedRoot = normalizePathForOwnership(worktreesRoot) + sep;
|
||||
const resolvedCwd = normalizePathForOwnership(cwd);
|
||||
|
||||
if (!resolvedCwd.startsWith(resolvedRoot)) {
|
||||
return {
|
||||
@@ -336,7 +408,7 @@ export async function isPaseoOwnedWorktreeCwd(
|
||||
};
|
||||
}
|
||||
|
||||
const worktrees = await listPaseoWorktrees({ cwd: repoInfo.path });
|
||||
const worktrees = await listPaseoWorktrees({ cwd: repoInfo.path, paseoHome: options?.paseoHome });
|
||||
const allowed = worktrees.some((entry) => {
|
||||
const worktreePath = resolve(entry.path);
|
||||
return resolvedCwd === worktreePath || resolvedCwd.startsWith(worktreePath + sep);
|
||||
@@ -424,51 +496,56 @@ function parseWorktreeList(output: string): PaseoWorktreeInfo[] {
|
||||
|
||||
export async function listPaseoWorktrees({
|
||||
cwd,
|
||||
paseoHome,
|
||||
}: {
|
||||
cwd: string;
|
||||
paseoHome?: string;
|
||||
}): Promise<PaseoWorktreeInfo[]> {
|
||||
const repoInfo = await detectRepoInfo(cwd);
|
||||
const worktreesRoot = getPaseoWorktreesRoot(repoInfo.path);
|
||||
const worktreesRoot = await getPaseoWorktreesRoot(repoInfo.path, paseoHome);
|
||||
const { stdout } = await execAsync("git worktree list --porcelain", {
|
||||
cwd: repoInfo.path,
|
||||
env: READ_ONLY_GIT_ENV,
|
||||
});
|
||||
|
||||
const rootPrefix = resolve(worktreesRoot) + sep;
|
||||
return parseWorktreeList(stdout).filter((entry) =>
|
||||
resolve(entry.path).startsWith(rootPrefix)
|
||||
);
|
||||
const rootPrefix = normalizePathForOwnership(worktreesRoot) + sep;
|
||||
return parseWorktreeList(stdout)
|
||||
.map((entry) => ({ ...entry, path: normalizePathForOwnership(entry.path) }))
|
||||
.filter((entry) => entry.path.startsWith(rootPrefix));
|
||||
}
|
||||
|
||||
export async function deletePaseoWorktree({
|
||||
cwd,
|
||||
worktreePath,
|
||||
worktreeSlug,
|
||||
paseoHome,
|
||||
}: {
|
||||
cwd: string;
|
||||
worktreePath?: string;
|
||||
worktreeSlug?: string;
|
||||
paseoHome?: string;
|
||||
}): Promise<void> {
|
||||
if (!worktreePath && !worktreeSlug) {
|
||||
throw new Error("worktreePath or worktreeSlug is required");
|
||||
}
|
||||
|
||||
const repoInfo = await detectRepoInfo(cwd);
|
||||
const worktreesRoot = getPaseoWorktreesRoot(repoInfo.path);
|
||||
const worktreesRoot = await getPaseoWorktreesRoot(repoInfo.path, paseoHome);
|
||||
const targetPath = worktreePath ?? join(worktreesRoot, worktreeSlug!);
|
||||
const resolvedRoot = resolve(worktreesRoot) + sep;
|
||||
const resolvedTarget = resolve(targetPath);
|
||||
const resolvedRoot = normalizePathForOwnership(worktreesRoot) + sep;
|
||||
const resolvedTarget = normalizePathForOwnership(targetPath);
|
||||
|
||||
if (!resolvedTarget.startsWith(resolvedRoot)) {
|
||||
throw new Error("Refusing to delete non-Paseo worktree");
|
||||
}
|
||||
|
||||
await execAsync(`git worktree remove "${targetPath}" --force`, {
|
||||
const canonicalTargetPath = normalizePathForOwnership(targetPath);
|
||||
await execAsync(`git worktree remove "${canonicalTargetPath}" --force`, {
|
||||
cwd: repoInfo.path,
|
||||
});
|
||||
|
||||
if (existsSync(targetPath)) {
|
||||
rmSync(targetPath, { recursive: true, force: true });
|
||||
if (existsSync(canonicalTargetPath)) {
|
||||
rmSync(canonicalTargetPath, { recursive: true, force: true });
|
||||
}
|
||||
}
|
||||
|
||||
@@ -482,6 +559,7 @@ export async function createWorktree({
|
||||
baseBranch,
|
||||
worktreeSlug,
|
||||
runSetup = true,
|
||||
paseoHome,
|
||||
}: CreateWorktreeOptions): Promise<WorktreeConfig> {
|
||||
// Validate branch name
|
||||
const validation = validateBranchSlug(branchName);
|
||||
@@ -512,7 +590,7 @@ export async function createWorktree({
|
||||
let worktreePath: string;
|
||||
const desiredSlug = worktreeSlug || generateWorktreeSlug();
|
||||
|
||||
worktreePath = join(getPaseoWorktreesRoot(repoInfo.path), desiredSlug);
|
||||
worktreePath = join(await getPaseoWorktreesRoot(repoInfo.path, paseoHome), desiredSlug);
|
||||
mkdirSync(dirname(worktreePath), { recursive: true });
|
||||
|
||||
// Check if branch already exists
|
||||
@@ -560,7 +638,7 @@ export async function createWorktree({
|
||||
|
||||
const command = `git worktree add "${finalWorktreePath}" -b "${newBranchName}" "${base}"`;
|
||||
await execAsync(command, { cwd: repoInfo.path });
|
||||
worktreePath = finalWorktreePath;
|
||||
worktreePath = normalizePathForOwnership(finalWorktreePath);
|
||||
|
||||
writePaseoWorktreeMetadata(worktreePath, { baseRefName: normalizedBaseBranch });
|
||||
|
||||
|
||||
Reference in New Issue
Block a user