Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
47 changes: 39 additions & 8 deletions apps/server/src/routes/agents/lifecycle-routes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import {
import { RENAME_PROMPT } from "../../agents/auto-rename-prompter.js";
import { shouldSuggestSessionRename } from "../../agents/tmux/session-name.js";
import { getAgentDiff, getAgentFileDiff } from "../../shared/git/agent-diff.js";
import { getDiffStats } from "../../shared/git/diff-stats.js";
import {
AGENT_LATEST_EVENT_TYPES,
isAgentLatestEventType,
Expand Down Expand Up @@ -325,6 +326,28 @@ export async function registerAgentLifecycleRoutes(
return reply.code(404).send({ error: "Agent not found." });
}

const includeUncommitted =
(request.query as { includeUncommitted?: string }).includeUncommitted !==
"false";

if (!includeUncommitted) {
const gitContextWorktreePath = agent.gitContext?.isWorktree
? agent.gitContext.worktreePath
: null;
const worktreePath =
agent.worktreePath ?? gitContextWorktreePath ?? agent.cwd ?? null;
if (!worktreePath) return { diffStats: null };

const baseRef =
agent.baseBranch ??
(agent.worktreePath || gitContextWorktreePath ? "main" : null);
return {
diffStats: await getDiffStats(worktreePath, baseRef, {
includeUncommitted: false,
}),
};
}

// Await the signal so first-paint always sees a fresh value rather
// than the cold-cache `null` followed by an SSE update milliseconds
// later. The 3s freshness window inside the refresher still absorbs
Expand Down Expand Up @@ -360,11 +383,15 @@ export async function registerAgentLifecycleRoutes(
(agent.worktreePath || gitContextWorktreePath ? "main" : null);

try {
const ignoreWhitespace =
(request.query as { ignoreWhitespace?: string }).ignoreWhitespace !==
"false";
const query = request.query as {
ignoreWhitespace?: string;
includeUncommitted?: string;
};
const ignoreWhitespace = query.ignoreWhitespace !== "false";
const includeUncommitted = query.includeUncommitted !== "false";
const result = await getAgentDiff(worktreePath, baseRef, undefined, {
ignoreWhitespace,
includeUncommitted,
});
if (!result) {
return { baseRef: null, files: [] };
Expand All @@ -378,7 +405,12 @@ export async function registerAgentLifecycleRoutes(

app.get("/api/v1/agents/:id/diff/file", async (request, reply) => {
const params = request.params as { id?: string };
const query = request.query as { path?: string; force?: string };
const query = request.query as {
path?: string;
force?: string;
ignoreWhitespace?: string;
includeUncommitted?: string;
};
const id = params.id ?? "";

if (!query.path) {
Expand Down Expand Up @@ -410,15 +442,14 @@ export async function registerAgentLifecycleRoutes(
(agent.worktreePath || gitContextWorktreePath ? "main" : null);

try {
const ignoreWhitespace =
(request.query as { ignoreWhitespace?: string }).ignoreWhitespace !==
"false";
const ignoreWhitespace = query.ignoreWhitespace !== "false";
const includeUncommitted = query.includeUncommitted !== "false";
const result = await getAgentFileDiff(
worktreePath,
baseRef,
query.path,
undefined,
{ ignoreWhitespace }
{ ignoreWhitespace, includeUncommitted }
);
if (!result) {
return reply.code(404).send({ error: "File not found in diff." });
Expand Down
66 changes: 41 additions & 25 deletions apps/server/src/shared/git/agent-diff.ts
Original file line number Diff line number Diff line change
Expand Up @@ -188,7 +188,7 @@ export async function getAgentDiff(
worktreePath: string,
baseRef: string | null,
run: CommandRunner = runCommand,
options?: { ignoreWhitespace?: boolean }
options?: { ignoreWhitespace?: boolean; includeUncommitted?: boolean }
): Promise<DiffResponse | null> {
const resolvedBase = await resolveBaseRef(worktreePath, baseRef, {
runCommand: run,
Expand All @@ -206,19 +206,23 @@ export async function getAgentDiff(
const mergeBaseSha = mergeBaseResult.stdout.trim();

const wsFlag = options?.ignoreWhitespace ? ["-w"] : [];
const includeUncommitted = options?.includeUncommitted !== false;
const diffRange = includeUncommitted
? [mergeBaseSha]
: [mergeBaseSha, "HEAD"];
const [numstatResult, statusResult, diffResult, untrackedResult] =
await Promise.all([
run(
"git",
["-C", worktreePath, "diff", mergeBaseSha, "--numstat", ...wsFlag],
["-C", worktreePath, "diff", ...diffRange, "--numstat", ...wsFlag],
{
allowedExitCodes: [0],
timeoutMs: GIT_TIMEOUT_MS,
}
),
run(
"git",
["-C", worktreePath, "diff", mergeBaseSha, "--name-status", ...wsFlag],
["-C", worktreePath, "diff", ...diffRange, "--name-status", ...wsFlag],
{
allowedExitCodes: [0],
timeoutMs: GIT_TIMEOUT_MS,
Expand All @@ -230,7 +234,7 @@ export async function getAgentDiff(
"-C",
worktreePath,
"diff",
mergeBaseSha,
...diffRange,
"-U3",
"--no-color",
...wsFlag,
Expand All @@ -240,14 +244,16 @@ export async function getAgentDiff(
timeoutMs: GIT_TIMEOUT_MS,
}
),
run(
"git",
["-C", worktreePath, "ls-files", "--others", "--exclude-standard"],
{
allowedExitCodes: [0],
timeoutMs: GIT_TIMEOUT_MS,
}
),
includeUncommitted
? run(
"git",
["-C", worktreePath, "ls-files", "--others", "--exclude-standard"],
{
allowedExitCodes: [0],
timeoutMs: GIT_TIMEOUT_MS,
}
)
: Promise.resolve({ exitCode: 0, stdout: "", stderr: "" }),
]);

const entries = parseNumstatWithStatus(
Expand Down Expand Up @@ -317,7 +323,7 @@ export async function getAgentFileDiff(
baseRef: string | null,
filePath: string,
run: CommandRunner = runCommand,
options?: { ignoreWhitespace?: boolean }
options?: { ignoreWhitespace?: boolean; includeUncommitted?: boolean }
): Promise<FileDiffResponse | null> {
const resolvedBase = await resolveBaseRef(worktreePath, baseRef, {
runCommand: run,
Expand All @@ -335,14 +341,18 @@ export async function getAgentFileDiff(
const mergeBaseSha = mergeBaseResult.stdout.trim();

const wsFlag2 = options?.ignoreWhitespace ? ["-w"] : [];
const includeUncommitted = options?.includeUncommitted !== false;
const diffRange2 = includeUncommitted
? [mergeBaseSha]
: [mergeBaseSha, "HEAD"];
const [numstatResult, statusResult, diffResult] = await Promise.all([
run(
"git",
[
"-C",
worktreePath,
"diff",
mergeBaseSha,
...diffRange2,
"--numstat",
...wsFlag2,
"--",
Expand All @@ -356,7 +366,7 @@ export async function getAgentFileDiff(
"-C",
worktreePath,
"diff",
mergeBaseSha,
...diffRange2,
"--name-status",
...wsFlag2,
"--",
Expand All @@ -370,7 +380,7 @@ export async function getAgentFileDiff(
"-C",
worktreePath,
"diff",
mergeBaseSha,
...diffRange2,
"-U3",
"--no-color",
...wsFlag2,
Expand All @@ -387,15 +397,21 @@ export async function getAgentFileDiff(
);

if (entries.length === 0) {
const { lines, content } = await readUntrackedFile(worktreePath, filePath);
if (lines === 0 && content === null) return null;
return {
path: filePath,
status: "added" as DiffFileStatus,
added: lines,
deleted: 0,
diff: buildUntrackedDiff(filePath, content!),
};
if (includeUncommitted) {
const { lines, content } = await readUntrackedFile(
worktreePath,
filePath
);
if (lines === 0 && content === null) return null;
return {
path: filePath,
status: "added" as DiffFileStatus,
added: lines,
deleted: 0,
diff: buildUntrackedDiff(filePath, content!),
};
}
return null;
}

const entry = entries[0]!;
Expand Down
20 changes: 14 additions & 6 deletions apps/server/src/shared/git/diff-stats.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,8 @@ type CommandRunner = (
export type GetDiffStatsOptions = {
/** Override for tests. */
runCommand?: CommandRunner;
/** Include staged, unstaged, and untracked working-tree changes. */
includeUncommitted?: boolean;
};

/**
Expand All @@ -45,6 +47,7 @@ export async function getDiffStats(
options: GetDiffStatsOptions = {}
): Promise<DiffStats | null> {
const run = options.runCommand ?? runCommand;
const includeUncommitted = options.includeUncommitted !== false;
try {
const resolvedBase = await resolveBaseRef(worktreePath, baseRef, {
runCommand: run,
Expand All @@ -61,16 +64,21 @@ export async function getDiffStats(
}
const mergeBaseSha = mergeBase.stdout.trim();

const diffRange = includeUncommitted
? [mergeBaseSha]
: [mergeBaseSha, "HEAD"];
const [tracked, untracked] = await Promise.all([
run("git", ["-C", worktreePath, "diff", mergeBaseSha, "--numstat"], {
run("git", ["-C", worktreePath, "diff", ...diffRange, "--numstat"], {
allowedExitCodes: [0],
timeoutMs: GIT_TIMEOUT_MS,
}),
run(
"git",
["-C", worktreePath, "ls-files", "--others", "--exclude-standard"],
{ allowedExitCodes: [0], timeoutMs: GIT_TIMEOUT_MS }
),
includeUncommitted
? run(
"git",
["-C", worktreePath, "ls-files", "--others", "--exclude-standard"],
{ allowedExitCodes: [0], timeoutMs: GIT_TIMEOUT_MS }
)
: Promise.resolve({ exitCode: 0, stdout: "", stderr: "" }),
]);

const trackedPaths = extractPathsFromNumstat(tracked.stdout);
Expand Down
53 changes: 53 additions & 0 deletions apps/server/test/agent-diff.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -362,6 +362,37 @@ describe("getAgentDiff stats consistency", () => {
expect(file.added).toBe(2500);
expect(file.deleted).toBe(3);
});

it("uses committed-only ranges and omits untracked files when requested", async () => {
await writeFile(path.join(tmpDir, "uncommitted.ts"), "not committed\n");
const committedDiff = generateDiffLines("committed.ts", 2);
const runner = vi.fn(
makeMockRunner(
"2\t0\tcommitted.ts",
"A\tcommitted.ts",
committedDiff,
"uncommitted.ts"
)
);

const result = await getAgentDiff(tmpDir, BASE_REF, runner, {
includeUncommitted: false,
});

expect(result!.files.map((file) => file.path)).toEqual(["committed.ts"]);
expect(
runner.mock.calls
.map(([, args]) => args.join(" "))
.filter((args) => args.includes(" diff "))
).toEqual(
expect.arrayContaining([
expect.stringContaining(`diff ${MERGE_BASE_SHA} HEAD`),
])
);
expect(
runner.mock.calls.some(([, args]) => args.includes("ls-files"))
).toBe(false);
});
});

describe("getAgentFileDiff (force load)", () => {
Expand Down Expand Up @@ -434,4 +465,26 @@ describe("getAgentFileDiff (force load)", () => {
await rm(tmpDir, { recursive: true, force: true });
}
});

it("does not force-load an untracked file in committed-only mode", async () => {
const tmpDir = await mkdtemp(
path.join(os.tmpdir(), "agent-diff-file-committed-test-")
);
try {
await writeFile(path.join(tmpDir, "new.ts"), "uncommitted\n");
const runner = makeMockRunner("", "", "");

const result = await getAgentFileDiff(
tmpDir,
BASE_REF,
"new.ts",
runner,
{ includeUncommitted: false }
);

expect(result).toBeNull();
} finally {
await rm(tmpDir, { recursive: true, force: true });
}
});
});
25 changes: 25 additions & 0 deletions apps/server/test/diff-stats.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -110,6 +110,31 @@ describe("getDiffStats", () => {
expect(result).toMatchObject({ added: 15, deleted: 2, files: 2 });
});

it("compares merge-base to HEAD and skips untracked files when requested", async () => {
const worktreePath = tempRoot;
const committedOnlyKey = `-C ${worktreePath} diff ${MERGE_BASE_SHA} HEAD --numstat`;
const runCommand = withCommands(worktreePath, "main", {
[committedOnlyKey]: () => ok("4\t1\tsrc/committed.ts"),
});

const result = await getDiffStats(worktreePath, "main", {
runCommand,
includeUncommitted: false,
});

expect(result).toMatchObject({ added: 4, deleted: 1, files: 1 });
expect(runCommand).toHaveBeenCalledWith(
"git",
["-C", worktreePath, "diff", MERGE_BASE_SHA, "HEAD", "--numstat"],
expect.any(Object)
);
expect(runCommand).not.toHaveBeenCalledWith(
"git",
expect.arrayContaining(["ls-files"]),
expect.anything()
);
});

it("captures committed AND uncommitted edits to the same file as one net diff", async () => {
// Regression: the previous two-stream approach deduped by path and
// dropped the uncommitted slice when a file appeared in both. With a
Expand Down
4 changes: 2 additions & 2 deletions apps/web/src/components/app/agents-view.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -717,8 +717,8 @@ export function AgentsView({
) : null}
</div>
<div className="flex items-center justify-end gap-1">
{changesMatch && !isMobile && !isSplit ? (
<ChangesSettingsPopover />
{changesMatch && !isSplit ? (
<ChangesSettingsPopover isMobile={isMobile} />
) : null}
{hasActiveAgent && (!mediaPanelOpen || isMobile) ? (
<Button
Expand Down
Loading
Loading