feat(checkout): add merge-from-base and push operations for worktree branches

This commit is contained in:
Mohamed Boudra
2026-01-24 21:24:48 +07:00
parent 56399bcb42
commit 8fe30eae75
19 changed files with 947 additions and 121 deletions
+130 -11
View File
@@ -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);
});
});