feat(app): checkout diff pane styling and e2e test support
- Style diff file cards edge-to-edge with single divider between files - Remove monospace font from filenames for better readability - Add checkout-ship e2e test for app - Export Page type from e2e fixtures - Add checkout query hooks for diff, status, and PR status
This commit is contained in:
@@ -204,12 +204,16 @@ describe("daemon checkout ship loop", () => {
|
||||
expect(diffUncommitted.error).toBeNull();
|
||||
expect(diffUncommitted.files.length).toBeGreaterThan(0);
|
||||
|
||||
const timelineBeforeCommit =
|
||||
ctx.daemon.daemon.agentManager.getTimeline(agent.id).length;
|
||||
const commitResult = await ctx.client.checkoutCommit(agent.id, {
|
||||
message: "Ship loop update",
|
||||
addAll: true,
|
||||
});
|
||||
expect(commitResult.error).toBeNull();
|
||||
expect(commitResult.success).toBe(true);
|
||||
const timelineAfterCommit =
|
||||
ctx.daemon.daemon.agentManager.getTimeline(agent.id).length;
|
||||
expect(timelineAfterCommit).toBe(timelineBeforeCommit);
|
||||
|
||||
const diffAfterCommit = await ctx.client.getCheckoutDiff(agent.id, {
|
||||
mode: "uncommitted",
|
||||
@@ -222,13 +226,16 @@ describe("daemon checkout ship loop", () => {
|
||||
});
|
||||
expect(baseDiff.files.length).toBeGreaterThan(0);
|
||||
|
||||
const timelineBeforePr =
|
||||
ctx.daemon.daemon.agentManager.getTimeline(agent.id).length;
|
||||
const prCreate = await ctx.client.checkoutPrCreate(agent.id, {
|
||||
title: "Ship loop update",
|
||||
body: "Testing checkout ship loop",
|
||||
baseRef: "main",
|
||||
});
|
||||
expect(prCreate.error).toBeNull();
|
||||
expect(prCreate.url).toContain(repoName);
|
||||
const timelineAfterPr =
|
||||
ctx.daemon.daemon.agentManager.getTimeline(agent.id).length;
|
||||
expect(timelineAfterPr).toBe(timelineBeforePr);
|
||||
|
||||
const prStatus = await ctx.client.checkoutPrStatus(agent.id);
|
||||
expect(prStatus.error).toBeNull();
|
||||
|
||||
@@ -46,7 +46,10 @@ import { buildProviderRegistry } from "./agent/provider-registry.js";
|
||||
import { AgentManager } from "./agent/agent-manager.js";
|
||||
import type { ManagedAgent } from "./agent/agent-manager.js";
|
||||
import { toAgentPayload } from "./agent/agent-projections.js";
|
||||
import { getStructuredAgentResponse } from "./agent/agent-response-loop.js";
|
||||
import {
|
||||
StructuredAgentResponseError,
|
||||
generateStructuredAgentResponse,
|
||||
} from "./agent/agent-response-loop.js";
|
||||
import type {
|
||||
AgentPermissionResponse,
|
||||
AgentPromptContentBlock,
|
||||
@@ -1767,6 +1770,15 @@ export class Session {
|
||||
return resolvedCandidate.startsWith(resolvedRoot + sep);
|
||||
}
|
||||
|
||||
private buildEphemeralAgentConfig(agent: ManagedAgent, title: string): AgentSessionConfig {
|
||||
return {
|
||||
...agent.config,
|
||||
cwd: agent.cwd,
|
||||
title,
|
||||
parentAgentId: agent.id,
|
||||
};
|
||||
}
|
||||
|
||||
private async generateCommitMessage(agent: ManagedAgent): Promise<string> {
|
||||
const diff = await getCheckoutDiff(agent.cwd, { mode: "uncommitted" });
|
||||
const schema = z.object({
|
||||
@@ -1782,17 +1794,22 @@ export class Session {
|
||||
"",
|
||||
diff.diff.length > 0 ? diff.diff : "(No diff available)",
|
||||
].join("\n");
|
||||
const result = await getStructuredAgentResponse({
|
||||
caller: async (nextPrompt) => {
|
||||
const run = await this.agentManager.runAgent(agent.id, nextPrompt);
|
||||
return run.finalText;
|
||||
},
|
||||
prompt,
|
||||
schema,
|
||||
schemaName: "CommitMessage",
|
||||
maxRetries: 2,
|
||||
});
|
||||
return result.message;
|
||||
try {
|
||||
const result = await generateStructuredAgentResponse({
|
||||
manager: this.agentManager,
|
||||
agentConfig: this.buildEphemeralAgentConfig(agent, "Commit generator"),
|
||||
prompt,
|
||||
schema,
|
||||
schemaName: "CommitMessage",
|
||||
maxRetries: 2,
|
||||
});
|
||||
return result.message;
|
||||
} catch (error) {
|
||||
if (error instanceof StructuredAgentResponseError) {
|
||||
return "Update files";
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
private async generatePullRequestText(agent: ManagedAgent, baseRef?: string): Promise<{
|
||||
@@ -1813,16 +1830,24 @@ export class Session {
|
||||
"",
|
||||
diff.diff.length > 0 ? diff.diff : "(No diff available)",
|
||||
].join("\n");
|
||||
return await getStructuredAgentResponse({
|
||||
caller: async (nextPrompt) => {
|
||||
const run = await this.agentManager.runAgent(agent.id, nextPrompt);
|
||||
return run.finalText;
|
||||
},
|
||||
prompt,
|
||||
schema,
|
||||
schemaName: "PullRequest",
|
||||
maxRetries: 2,
|
||||
});
|
||||
try {
|
||||
return await generateStructuredAgentResponse({
|
||||
manager: this.agentManager,
|
||||
agentConfig: this.buildEphemeralAgentConfig(agent, "PR generator"),
|
||||
prompt,
|
||||
schema,
|
||||
schemaName: "PullRequest",
|
||||
maxRetries: 2,
|
||||
});
|
||||
} catch (error) {
|
||||
if (error instanceof StructuredAgentResponseError) {
|
||||
return {
|
||||
title: "Update changes",
|
||||
body: "Automated PR generated by Paseo.",
|
||||
};
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
private async ensureCleanWorkingTree(cwd: string): Promise<void> {
|
||||
|
||||
@@ -113,16 +113,24 @@ describe("checkout git utilities", () => {
|
||||
expect(message).toBe("worktree update");
|
||||
});
|
||||
|
||||
it("merges the current branch into base", async () => {
|
||||
writeFileSync(join(repoDir, "merge.txt"), "feature\n");
|
||||
execSync("git checkout -b feature", { cwd: repoDir });
|
||||
execSync("git add merge.txt", { cwd: repoDir });
|
||||
execSync("git -c commit.gpgsign=false commit -m 'feature commit'", { cwd: repoDir });
|
||||
const featureCommit = execSync("git rev-parse HEAD", { cwd: repoDir })
|
||||
it("merges the current branch into base from a worktree checkout", async () => {
|
||||
const worktree = await createWorktree({
|
||||
branchName: "main",
|
||||
cwd: repoDir,
|
||||
worktreeSlug: "merge",
|
||||
});
|
||||
|
||||
writeFileSync(join(worktree.worktreePath, "merge.txt"), "feature\n");
|
||||
execSync("git checkout -b feature", { cwd: worktree.worktreePath });
|
||||
execSync("git add merge.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();
|
||||
|
||||
await mergeToBase(repoDir, { baseRef: "main" });
|
||||
await mergeToBase(worktree.worktreePath, { baseRef: "main" });
|
||||
|
||||
const baseContainsFeature = execSync(`git merge-base --is-ancestor ${featureCommit} main`, {
|
||||
cwd: repoDir,
|
||||
@@ -130,7 +138,9 @@ describe("checkout git utilities", () => {
|
||||
});
|
||||
expect(baseContainsFeature).toBeDefined();
|
||||
|
||||
const currentBranch = execSync("git rev-parse --abbrev-ref HEAD", { cwd: repoDir })
|
||||
const currentBranch = execSync("git rev-parse --abbrev-ref HEAD", {
|
||||
cwd: worktree.worktreePath,
|
||||
})
|
||||
.toString()
|
||||
.trim();
|
||||
expect(currentBranch).toBe("feature");
|
||||
|
||||
@@ -98,6 +98,68 @@ async function getCurrentBranch(cwd: string): Promise<string | null> {
|
||||
return branch.length > 0 ? branch : null;
|
||||
}
|
||||
|
||||
async function getWorktreeRoot(cwd: string): Promise<string | null> {
|
||||
try {
|
||||
const { stdout } = await execAsync(
|
||||
"git rev-parse --path-format=absolute --show-toplevel",
|
||||
{ cwd, env: READ_ONLY_GIT_ENV }
|
||||
);
|
||||
const root = stdout.trim();
|
||||
return root.length > 0 ? root : null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
type GitWorktreeEntry = {
|
||||
path: string;
|
||||
branchRef?: string;
|
||||
};
|
||||
|
||||
function parseWorktreeList(output: string): GitWorktreeEntry[] {
|
||||
const entries: GitWorktreeEntry[] = [];
|
||||
let current: GitWorktreeEntry | null = null;
|
||||
for (const line of output.split("\n")) {
|
||||
const trimmed = line.trim();
|
||||
if (!trimmed) {
|
||||
continue;
|
||||
}
|
||||
if (trimmed.startsWith("worktree ")) {
|
||||
if (current) {
|
||||
entries.push(current);
|
||||
}
|
||||
current = { path: trimmed.slice("worktree ".length).trim() };
|
||||
continue;
|
||||
}
|
||||
if (current && trimmed.startsWith("branch ")) {
|
||||
current.branchRef = trimmed.slice("branch ".length).trim();
|
||||
}
|
||||
}
|
||||
if (current) {
|
||||
entries.push(current);
|
||||
}
|
||||
return entries;
|
||||
}
|
||||
|
||||
async function getWorktreePathForBranch(
|
||||
cwd: string,
|
||||
branchName: string
|
||||
): Promise<string | null> {
|
||||
try {
|
||||
const { stdout } = await execAsync("git worktree list --porcelain", {
|
||||
cwd,
|
||||
env: READ_ONLY_GIT_ENV,
|
||||
});
|
||||
const entries = parseWorktreeList(stdout);
|
||||
const ref = branchName.startsWith("refs/heads/")
|
||||
? branchName
|
||||
: `refs/heads/${branchName}`;
|
||||
return entries.find((entry) => entry.branchRef === ref)?.path ?? null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export async function renameCurrentBranch(
|
||||
cwd: string,
|
||||
newName: string
|
||||
@@ -295,19 +357,26 @@ export async function mergeToBase(cwd: string, options: MergeToBaseOptions = {})
|
||||
if (!currentBranch) {
|
||||
throw new Error("Unable to determine current branch for merge");
|
||||
}
|
||||
if (baseRef === currentBranch) {
|
||||
let normalizedBaseRef = baseRef;
|
||||
if (normalizedBaseRef.startsWith("origin/")) {
|
||||
normalizedBaseRef = normalizedBaseRef.slice("origin/".length);
|
||||
}
|
||||
if (normalizedBaseRef === currentBranch) {
|
||||
return;
|
||||
}
|
||||
|
||||
const operationCwd = repoInfo.path;
|
||||
const isSameCheckout = resolve(operationCwd) === resolve(cwd);
|
||||
const currentWorktreeRoot = (await getWorktreeRoot(cwd)) ?? cwd;
|
||||
const baseWorktree = await getWorktreePathForBranch(repoInfo.path, normalizedBaseRef);
|
||||
const operationCwd = baseWorktree ?? currentWorktreeRoot;
|
||||
const isSameCheckout = resolve(operationCwd) === resolve(currentWorktreeRoot);
|
||||
const originalBranch = await getCurrentBranch(operationCwd);
|
||||
const mode = options.mode ?? "merge";
|
||||
try {
|
||||
await execAsync(`git checkout ${baseRef}`, { cwd: operationCwd });
|
||||
await execAsync(`git checkout ${normalizedBaseRef}`, { cwd: operationCwd });
|
||||
if (mode === "squash") {
|
||||
await execAsync(`git merge --squash ${currentBranch}`, { cwd: operationCwd });
|
||||
const message = options.commitMessage ?? `Squash merge ${currentBranch} into ${baseRef}`;
|
||||
const message =
|
||||
options.commitMessage ?? `Squash merge ${currentBranch} into ${normalizedBaseRef}`;
|
||||
await execFileAsync(
|
||||
"git",
|
||||
["-c", "commit.gpgsign=false", "commit", "-m", message],
|
||||
@@ -354,7 +423,7 @@ export async function mergeToBase(cwd: string, options: MergeToBaseOptions = {})
|
||||
// ignore
|
||||
}
|
||||
throw new MergeConflictError({
|
||||
baseRef,
|
||||
baseRef: normalizedBaseRef,
|
||||
currentBranch,
|
||||
conflictFiles: conflicts.length > 0 ? conflicts : [],
|
||||
});
|
||||
@@ -368,7 +437,7 @@ export async function mergeToBase(cwd: string, options: MergeToBaseOptions = {})
|
||||
|
||||
throw error;
|
||||
} finally {
|
||||
if (isSameCheckout && originalBranch && originalBranch !== baseRef) {
|
||||
if (isSameCheckout && originalBranch && originalBranch !== normalizedBaseRef) {
|
||||
try {
|
||||
await execAsync(`git checkout ${originalBranch}`, { cwd: operationCwd });
|
||||
} catch {
|
||||
|
||||
Reference in New Issue
Block a user