Merge branch 'main' into dev

This commit is contained in:
Mohamed Boudra
2026-04-10 20:59:14 +07:00
23 changed files with 947 additions and 275 deletions
@@ -911,6 +911,61 @@ describe("DaemonClient", () => {
});
});
test("requests checkout pull via RPC", async () => {
const logger = createMockLogger();
const mock = createMockTransport();
const client = new DaemonClient({
url: "ws://test",
clientId: "clsk_unit_test",
logger,
reconnect: { enabled: false },
transportFactory: () => mock.transport,
});
clients.push(client);
const connectPromise = client.connect();
mock.triggerOpen();
await connectPromise;
const promise = client.checkoutPull("/tmp/project", "req-pull");
expect(mock.sent).toHaveLength(1);
const request = JSON.parse(mock.sent[0]) as {
type: "session";
message: {
type: "checkout_pull_request";
cwd: string;
requestId: string;
};
};
expect(request.message.type).toBe("checkout_pull_request");
expect(request.message.cwd).toBe("/tmp/project");
expect(request.message.requestId).toBe("req-pull");
mock.triggerMessage(
JSON.stringify({
type: "session",
message: {
type: "checkout_pull_response",
payload: {
cwd: "/tmp/project",
requestId: "req-pull",
success: true,
error: null,
},
},
}),
);
await expect(promise).resolves.toEqual({
cwd: "/tmp/project",
requestId: "req-pull",
success: true,
error: null,
});
});
test("resubscribes checkout diff streams after reconnect", async () => {
const logger = createMockLogger();
const mock = createMockTransport();
@@ -25,6 +25,7 @@ import type {
CheckoutCommitResponse,
CheckoutMergeResponse,
CheckoutMergeFromBaseResponse,
CheckoutPullResponse,
CheckoutPushResponse,
CheckoutPrCreateResponse,
CheckoutPrStatusResponse,
@@ -225,6 +226,7 @@ type CheckoutDiffPayload = Omit<SubscribeCheckoutDiffPayload, "subscriptionId">;
type CheckoutCommitPayload = CheckoutCommitResponse["payload"];
type CheckoutMergePayload = CheckoutMergeResponse["payload"];
type CheckoutMergeFromBasePayload = CheckoutMergeFromBaseResponse["payload"];
type CheckoutPullPayload = CheckoutPullResponse["payload"];
type CheckoutPushPayload = CheckoutPushResponse["payload"];
type CheckoutPrCreatePayload = CheckoutPrCreateResponse["payload"];
type CheckoutPrStatusPayload = CheckoutPrStatusResponse["payload"];
@@ -2385,6 +2387,18 @@ export class DaemonClient {
});
}
async checkoutPull(cwd: string, requestId?: string): Promise<CheckoutPullPayload> {
return this.sendCorrelatedSessionRequest({
requestId,
message: {
type: "checkout_pull_request",
cwd,
},
responseType: "checkout_pull_response",
timeout: 60000,
});
}
async checkoutPush(cwd: string, requestId?: string): Promise<CheckoutPushPayload> {
return this.sendCorrelatedSessionRequest({
requestId,
+36
View File
@@ -148,6 +148,7 @@ import {
commitChanges,
mergeToBase,
mergeFromBase,
pullCurrentBranch,
pushCurrentBranch,
createPullRequest,
getPullRequestStatus,
@@ -1685,6 +1686,10 @@ export class Session {
await this.handleCheckoutMergeFromBaseRequest(msg);
break;
case "checkout_pull_request":
await this.handleCheckoutPullRequest(msg);
break;
case "checkout_push_request":
await this.handleCheckoutPushRequest(msg);
break;
@@ -4706,6 +4711,37 @@ export class Session {
}
}
private async handleCheckoutPullRequest(
msg: Extract<SessionInboundMessage, { type: "checkout_pull_request" }>,
): Promise<void> {
const { cwd, requestId } = msg;
try {
await pullCurrentBranch(cwd);
this.checkoutDiffManager.scheduleRefreshForCwd(cwd);
this.emit({
type: "checkout_pull_response",
payload: {
cwd,
success: true,
error: null,
requestId,
},
});
} catch (error) {
this.emit({
type: "checkout_pull_response",
payload: {
cwd,
success: false,
error: toCheckoutError(error),
requestId,
},
});
}
}
private async handleCheckoutPushRequest(
msg: Extract<SessionInboundMessage, { type: "checkout_push_request" }>,
): Promise<void> {
+20
View File
@@ -1038,6 +1038,12 @@ export const CheckoutMergeFromBaseRequestSchema = z.object({
requestId: z.string(),
});
export const CheckoutPullRequestSchema = z.object({
type: z.literal("checkout_pull_request"),
cwd: z.string(),
requestId: z.string(),
});
export const CheckoutPushRequestSchema = z.object({
type: z.literal("checkout_push_request"),
cwd: z.string(),
@@ -1464,6 +1470,7 @@ export const SessionInboundMessageSchema = z.discriminatedUnion("type", [
CheckoutCommitRequestSchema,
CheckoutMergeRequestSchema,
CheckoutMergeFromBaseRequestSchema,
CheckoutPullRequestSchema,
CheckoutPushRequestSchema,
CheckoutPrCreateRequestSchema,
CheckoutPrStatusRequestSchema,
@@ -2326,6 +2333,16 @@ export const CheckoutMergeFromBaseResponseSchema = z.object({
}),
});
export const CheckoutPullResponseSchema = z.object({
type: z.literal("checkout_pull_response"),
payload: z.object({
cwd: z.string(),
success: z.boolean(),
error: CheckoutErrorSchema.nullable(),
requestId: z.string(),
}),
});
export const CheckoutPushResponseSchema = z.object({
type: z.literal("checkout_push_response"),
payload: z.object({
@@ -2825,6 +2842,7 @@ export const SessionOutboundMessageSchema = z.discriminatedUnion("type", [
CheckoutCommitResponseSchema,
CheckoutMergeResponseSchema,
CheckoutMergeFromBaseResponseSchema,
CheckoutPullResponseSchema,
CheckoutPushResponseSchema,
CheckoutPrCreateResponseSchema,
CheckoutPrStatusResponseSchema,
@@ -3054,6 +3072,8 @@ export type CheckoutMergeRequest = z.infer<typeof CheckoutMergeRequestSchema>;
export type CheckoutMergeResponse = z.infer<typeof CheckoutMergeResponseSchema>;
export type CheckoutMergeFromBaseRequest = z.infer<typeof CheckoutMergeFromBaseRequestSchema>;
export type CheckoutMergeFromBaseResponse = z.infer<typeof CheckoutMergeFromBaseResponseSchema>;
export type CheckoutPullRequest = z.infer<typeof CheckoutPullRequestSchema>;
export type CheckoutPullResponse = z.infer<typeof CheckoutPullResponseSchema>;
export type CheckoutPushRequest = z.infer<typeof CheckoutPushRequestSchema>;
export type CheckoutPushResponse = z.infer<typeof CheckoutPushResponseSchema>;
export type CheckoutPrCreateRequest = z.infer<typeof CheckoutPrCreateRequestSchema>;
@@ -1,6 +1,7 @@
import { describe, it, expect, beforeEach, afterEach } from "vitest";
import { execSync } from "child_process";
import {
existsSync,
mkdtempSync,
rmSync,
writeFileSync,
@@ -30,6 +31,7 @@ import {
MergeConflictError,
MergeFromBaseConflictError,
NotGitRepoError,
pullCurrentBranch,
pushCurrentBranch,
resolveRepositoryDefaultBranch,
parseWorktreeList,
@@ -558,6 +560,85 @@ const x = 1;
expect(() => execSync("git rev-parse -q --verify MERGE_HEAD", { cwd: repoDir })).toThrow();
});
it("pulls the current branch from origin", 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 });
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, "pulled.txt"), "remote\n");
execSync("git add pulled.txt", { cwd: otherClone });
execSync("git -c commit.gpgsign=false commit -m 'remote pull commit'", { cwd: otherClone });
const remoteCommit = execSync("git rev-parse HEAD", { cwd: otherClone }).toString().trim();
execSync("git push", { cwd: otherClone });
await pullCurrentBranch(repoDir);
execSync(`git merge-base --is-ancestor ${remoteCommit} HEAD`, { cwd: repoDir });
expect(readFileSync(join(repoDir, "pulled.txt"), "utf8")).toBe("remote\n");
});
it("aborts pull on merge conflicts and leaves no merge in progress", 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 });
writeFileSync(join(repoDir, "conflict.txt"), "local\n");
execSync("git add conflict.txt", { cwd: repoDir });
execSync("git -c commit.gpgsign=false commit -m 'local conflict commit'", { 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 });
writeFileSync(join(otherClone, "conflict.txt"), "remote\n");
execSync("git add conflict.txt", { cwd: otherClone });
execSync("git -c commit.gpgsign=false commit -m 'remote conflict commit'", { cwd: otherClone });
execSync("git push", { cwd: otherClone });
await expect(pullCurrentBranch(repoDir)).rejects.toBeInstanceOf(Error);
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("aborts pull on rebase conflicts and leaves no rebase in progress", 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 config pull.rebase true", { cwd: repoDir });
writeFileSync(join(repoDir, "conflict.txt"), "local\n");
execSync("git add conflict.txt", { cwd: repoDir });
execSync("git -c commit.gpgsign=false commit -m 'local rebase conflict commit'", { 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 });
writeFileSync(join(otherClone, "conflict.txt"), "remote\n");
execSync("git add conflict.txt", { cwd: otherClone });
execSync("git -c commit.gpgsign=false commit -m 'remote rebase conflict commit'", {
cwd: otherClone,
});
execSync("git push", { cwd: otherClone });
await expect(pullCurrentBranch(repoDir)).rejects.toBeInstanceOf(Error);
const gitDir = execSync("git rev-parse --absolute-git-dir", { cwd: repoDir }).toString().trim();
const porcelain = execSync("git status --porcelain", { cwd: repoDir }).toString().trim();
expect(porcelain).toBe("");
expect(existsSync(join(gitDir, "rebase-merge"))).toBe(false);
expect(existsSync(join(gitDir, "rebase-apply"))).toBe(false);
});
it("pushes the current branch to origin", async () => {
const remoteDir = join(tempDir, "remote.git");
execSync(`git init --bare -b main ${remoteDir}`);
+63 -3
View File
@@ -2,7 +2,7 @@ import { exec, execFile } from "child_process";
import { spawnProcess } from "./spawn.js";
import { promisify } from "util";
import { resolve, dirname, basename } from "path";
import { realpathSync } from "fs";
import { existsSync, realpathSync } from "fs";
import { open as openFile, stat as statFile } from "fs/promises";
import { TTLCache } from "@isaacs/ttlcache";
import { z } from "zod";
@@ -862,6 +862,46 @@ async function hasOriginRemote(cwd: string): Promise<boolean> {
return url !== null;
}
async function resolveAbsoluteGitDir(cwd: string): Promise<string | null> {
try {
const { stdout } = await execAsync("git rev-parse --absolute-git-dir", {
cwd,
env: READ_ONLY_GIT_ENV,
});
const gitDir = stdout.trim();
return gitDir.length > 0 ? gitDir : null;
} catch {
return null;
}
}
async function abortGitPullConflictState(cwd: string): Promise<void> {
const gitDir = await resolveAbsoluteGitDir(cwd);
if (!gitDir) {
return;
}
const mergeHeadPath = resolve(gitDir, "MERGE_HEAD");
const rebaseMergePath = resolve(gitDir, "rebase-merge");
const rebaseApplyPath = resolve(gitDir, "rebase-apply");
if (existsSync(mergeHeadPath)) {
try {
await execAsync("git merge --abort", { cwd });
} catch {
// ignore
}
}
if (existsSync(rebaseMergePath) || existsSync(rebaseApplyPath)) {
try {
await execAsync("git rebase --abort", { cwd });
} catch {
// ignore
}
}
}
export async function resolveRepositoryDefaultBranch(repoRoot: string): Promise<string | null> {
try {
const { stdout } = await execAsync("git symbolic-ref --quiet refs/remotes/origin/HEAD", {
@@ -1555,10 +1595,12 @@ export async function getCheckoutDiff(
continue;
}
// `git diff -w --name-status` can still surface paths whose only changes were
// whitespace. Skip those empty tracked entries so callers don't see phantom files.
// `git diff -w --name-status` can still report a modified path even when the
// whitespace-filtered patch and numstat are both empty. Skip emitting a
// structured placeholder in that case so whitespace-only edits truly disappear.
if (
ignoreWhitespace &&
!trackedDiffTruncated &&
change.status.startsWith("M") &&
(!stat || (!stat.isBinary && stat.additions === 0 && stat.deletions === 0))
) {
@@ -1859,6 +1901,24 @@ export async function mergeFromBase(
}
}
export async function pullCurrentBranch(cwd: string): Promise<void> {
await requireGitRepo(cwd);
const currentBranch = await getCurrentBranch(cwd);
if (!currentBranch || currentBranch === "HEAD") {
throw new Error("Unable to determine current branch for pull");
}
const hasRemote = await hasOriginRemote(cwd);
if (!hasRemote) {
throw new Error("Remote 'origin' is not configured.");
}
try {
await execAsync("git pull", { cwd });
} catch (error) {
await abortGitPullConflictState(cwd);
throw error;
}
}
export async function pushCurrentBranch(cwd: string): Promise<void> {
await requireGitRepo(cwd);
const currentBranch = await getCurrentBranch(cwd);