Skip to content

Commit e2b74bd

Browse files
Add selectable repo diff scopes
- Split repo diffs into working tree, unstaged, staged, and branch scopes - Persist the selected scope and reuse it in the diff panel and chat header
1 parent 99ce7c3 commit e2b74bd

9 files changed

Lines changed: 364 additions & 105 deletions

File tree

apps/server/src/git/Layers/GitCore.test.ts

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1578,6 +1578,40 @@ it.layer(TestLayer)("git integration", (it) => {
15781578
}),
15791579
);
15801580

1581+
it.effect("reads branch, staged, and unstaged patches as separate scopes", () =>
1582+
Effect.gen(function* () {
1583+
const tmp = yield* makeTmpDir();
1584+
yield* initRepoWithCommit(tmp);
1585+
const core = yield* GitCore;
1586+
1587+
yield* core.createBranch({ cwd: tmp, branch: "feature/diff-scopes" });
1588+
yield* core.checkoutBranch({ cwd: tmp, branch: "feature/diff-scopes" });
1589+
yield* writeTextFile(path.join(tmp, "branch.txt"), "branch change\n");
1590+
yield* git(tmp, ["add", "branch.txt"]);
1591+
yield* git(tmp, ["commit", "-m", "branch change"]);
1592+
1593+
yield* writeTextFile(path.join(tmp, "staged.txt"), "staged change\n");
1594+
yield* git(tmp, ["add", "staged.txt"]);
1595+
yield* writeTextFile(path.join(tmp, "README.md"), "# test\nunstaged change\n");
1596+
yield* writeTextFile(path.join(tmp, "untracked.txt"), "untracked change\n");
1597+
1598+
const branchPatch = (yield* core.readBranchPatch(tmp)).patch;
1599+
expect(branchPatch).toContain("diff --git a/branch.txt b/branch.txt");
1600+
expect(branchPatch).not.toContain("staged.txt");
1601+
expect(branchPatch).not.toContain("untracked.txt");
1602+
1603+
const stagedPatch = (yield* core.readStagedPatch(tmp)).patch;
1604+
expect(stagedPatch).toContain("diff --git a/staged.txt b/staged.txt");
1605+
expect(stagedPatch).not.toContain("README.md");
1606+
expect(stagedPatch).not.toContain("untracked.txt");
1607+
1608+
const unstagedPatch = (yield* core.readUnstagedPatch(tmp)).patch;
1609+
expect(unstagedPatch).toContain("diff --git a/README.md b/README.md");
1610+
expect(unstagedPatch).toContain("diff --git a/untracked.txt b/untracked.txt");
1611+
expect(unstagedPatch).not.toContain("staged.txt");
1612+
}),
1613+
);
1614+
15811615
it.effect("computes ahead count against base branch when no upstream is configured", () =>
15821616
Effect.gen(function* () {
15831617
const tmp = yield* makeTmpDir();

apps/server/src/git/Layers/GitCore.ts

Lines changed: 106 additions & 31 deletions
Original file line numberDiff line numberDiff line change
@@ -1453,6 +1453,72 @@ export const makeGitCore = (options?: { executeOverride?: GitCoreShape["execute"
14531453
})),
14541454
);
14551455

1456+
const readUntrackedPatches = (cwd: string, operationPrefix: string) =>
1457+
runGitStdout(
1458+
`${operationPrefix}.untrackedFiles`,
1459+
cwd,
1460+
["ls-files", "--others", "--exclude-standard", "-z"],
1461+
true,
1462+
).pipe(
1463+
Effect.map((stdout) => stdout.split("\0").filter((entry) => entry.length > 0)),
1464+
Effect.flatMap((untrackedFiles) =>
1465+
Effect.forEach(
1466+
untrackedFiles,
1467+
(filePath) =>
1468+
// Git diff omits untracked files, so synthesize a normal patch for each one.
1469+
executeGit(
1470+
`${operationPrefix}.untrackedPatch`,
1471+
cwd,
1472+
[
1473+
"diff",
1474+
"--no-index",
1475+
"--patch",
1476+
"--no-color",
1477+
"--src-prefix=a/",
1478+
"--dst-prefix=b/",
1479+
"--",
1480+
"/dev/null",
1481+
filePath,
1482+
],
1483+
{
1484+
allowNonZeroExit: true,
1485+
timeoutMs: WORKING_TREE_DIFF_TIMEOUT_MS,
1486+
},
1487+
).pipe(Effect.map((result) => result.stdout)),
1488+
{ concurrency: MAX_UNTRACKED_DIFF_CONCURRENCY },
1489+
),
1490+
),
1491+
);
1492+
1493+
const readUnstagedPatch: GitCoreShape["readUnstagedPatch"] = (cwd) =>
1494+
Effect.gen(function* () {
1495+
const trackedPatch = yield* executeGit(
1496+
"GitCore.readUnstagedPatch.trackedPatch",
1497+
cwd,
1498+
["diff", "--patch", "--no-color", "--no-ext-diff"],
1499+
{
1500+
allowNonZeroExit: true,
1501+
timeoutMs: WORKING_TREE_DIFF_TIMEOUT_MS,
1502+
},
1503+
).pipe(Effect.map((result) => result.stdout));
1504+
const untrackedPatches = yield* readUntrackedPatches(cwd, "GitCore.readUnstagedPatch");
1505+
1506+
return {
1507+
patch: joinPatchSegments([trackedPatch, ...untrackedPatches]),
1508+
};
1509+
});
1510+
1511+
const readStagedPatch: GitCoreShape["readStagedPatch"] = (cwd) =>
1512+
executeGit(
1513+
"GitCore.readStagedPatch",
1514+
cwd,
1515+
["diff", "--cached", "--patch", "--no-color", "--no-ext-diff"],
1516+
{
1517+
allowNonZeroExit: true,
1518+
timeoutMs: WORKING_TREE_DIFF_TIMEOUT_MS,
1519+
},
1520+
).pipe(Effect.map((result) => ({ patch: result.stdout })));
1521+
14561522
const readWorkingTreePatch: GitCoreShape["readWorkingTreePatch"] = (cwd) =>
14571523
Effect.gen(function* () {
14581524
const headExists = yield* executeGit(
@@ -1474,43 +1540,49 @@ export const makeGitCore = (options?: { executeOverride?: GitCoreShape["execute"
14741540
},
14751541
).pipe(Effect.map((result) => result.stdout));
14761542

1477-
const untrackedFiles = yield* runGitStdout(
1478-
"GitCore.readWorkingTreePatch.untrackedFiles",
1479-
cwd,
1480-
["ls-files", "--others", "--exclude-standard", "-z"],
1481-
true,
1482-
).pipe(Effect.map((stdout) => stdout.split("\0").filter((entry) => entry.length > 0)));
1483-
1484-
const untrackedPatches = yield* Effect.forEach(
1485-
untrackedFiles,
1486-
(filePath) =>
1487-
executeGit(
1488-
"GitCore.readWorkingTreePatch.untrackedPatch",
1489-
cwd,
1490-
[
1491-
"diff",
1492-
"--no-index",
1493-
"--patch",
1494-
"--no-color",
1495-
"--src-prefix=a/",
1496-
"--dst-prefix=b/",
1497-
"--",
1498-
"/dev/null",
1499-
filePath,
1500-
],
1501-
{
1502-
allowNonZeroExit: true,
1503-
timeoutMs: WORKING_TREE_DIFF_TIMEOUT_MS,
1504-
},
1505-
).pipe(Effect.map((result) => result.stdout)),
1506-
{ concurrency: MAX_UNTRACKED_DIFF_CONCURRENCY },
1507-
);
1543+
const untrackedPatches = yield* readUntrackedPatches(cwd, "GitCore.readWorkingTreePatch");
15081544

15091545
return {
15101546
patch: joinPatchSegments([trackedPatch, ...untrackedPatches]),
15111547
};
15121548
});
15131549

1550+
const readBranchPatch: GitCoreShape["readBranchPatch"] = (cwd) =>
1551+
Effect.gen(function* () {
1552+
const details = yield* statusDetails(cwd);
1553+
const baseBranch =
1554+
details.upstreamRef ??
1555+
(details.branch
1556+
? yield* resolveBaseBranchForNoUpstream(cwd, details.branch).pipe(
1557+
Effect.catch(() => Effect.succeed(null)),
1558+
)
1559+
: null);
1560+
if (!baseBranch) {
1561+
return yield* createGitCommandError(
1562+
"GitCore.readBranchPatch.base",
1563+
cwd,
1564+
["diff", "--patch", "--minimal", "<base>...HEAD"],
1565+
"Cannot resolve a base branch for the current branch diff.",
1566+
);
1567+
}
1568+
1569+
const result = yield* execute({
1570+
operation: "GitCore.readBranchPatch.diffPatch",
1571+
cwd,
1572+
args: [
1573+
"diff",
1574+
"--patch",
1575+
"--minimal",
1576+
"--no-color",
1577+
"--no-ext-diff",
1578+
`${baseBranch}...HEAD`,
1579+
],
1580+
maxOutputBytes: 10_000_000,
1581+
});
1582+
1583+
return { patch: result.stdout };
1584+
});
1585+
15141586
const prepareCommitContext: GitCoreShape["prepareCommitContext"] = (cwd, filePaths) =>
15151587
Effect.gen(function* () {
15161588
if (filePaths && filePaths.length > 0) {
@@ -2465,6 +2537,9 @@ export const makeGitCore = (options?: { executeOverride?: GitCoreShape["execute"
24652537
status,
24662538
statusDetails,
24672539
readWorkingTreePatch,
2540+
readUnstagedPatch,
2541+
readStagedPatch,
2542+
readBranchPatch,
24682543
prepareCommitContext,
24692544
commit,
24702545
pushCurrentBranch,

apps/server/src/git/Layers/GitManager.ts

Lines changed: 11 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1483,7 +1483,17 @@ export const makeGitManager = Effect.gen(function* () {
14831483

14841484
const readWorkingTreeDiff: GitManagerShape["readWorkingTreeDiff"] = Effect.fnUntraced(
14851485
function* (input) {
1486-
return yield* gitCore.readWorkingTreePatch(input.cwd);
1486+
switch (input.scope) {
1487+
case "branch":
1488+
return yield* gitCore.readBranchPatch(input.cwd);
1489+
case "staged":
1490+
return yield* gitCore.readStagedPatch(input.cwd);
1491+
case "unstaged":
1492+
return yield* gitCore.readUnstagedPatch(input.cwd);
1493+
case "workingTree":
1494+
default:
1495+
return yield* gitCore.readWorkingTreePatch(input.cwd);
1496+
}
14871497
},
14881498
);
14891499

apps/server/src/git/Services/GitCore.ts

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -170,6 +170,25 @@ export interface GitCoreShape {
170170
cwd: string,
171171
) => Effect.Effect<GitWorkingTreePatch, GitCommandError>;
172172

173+
/**
174+
* Read only unstaged tracked changes plus untracked files.
175+
*/
176+
readonly readUnstagedPatch: (
177+
cwd: string,
178+
) => Effect.Effect<GitWorkingTreePatch, GitCommandError>;
179+
180+
/**
181+
* Read only staged changes.
182+
*/
183+
readonly readStagedPatch: (
184+
cwd: string,
185+
) => Effect.Effect<GitWorkingTreePatch, GitCommandError>;
186+
187+
/**
188+
* Read committed branch changes against the upstream/base branch.
189+
*/
190+
readonly readBranchPatch: (cwd: string) => Effect.Effect<GitWorkingTreePatch, GitCommandError>;
191+
173192
/**
174193
* Build staged change context for commit generation.
175194
*/

0 commit comments

Comments
 (0)