diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/components/WorkspaceSidebar/hooks/useChangesTab/components/CommitFilterDropdown/CommitFilterDropdown.tsx b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/components/WorkspaceSidebar/hooks/useChangesTab/components/CommitFilterDropdown/CommitFilterDropdown.tsx index b6b373412c3..5a1b0dbf448 100644 --- a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/components/WorkspaceSidebar/hooks/useChangesTab/components/CommitFilterDropdown/CommitFilterDropdown.tsx +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/components/WorkspaceSidebar/hooks/useChangesTab/components/CommitFilterDropdown/CommitFilterDropdown.tsx @@ -272,7 +272,7 @@ export function CommitFilterDropdown({ onOpenChange={setRangeModalOpen} commits={commits} onSelect={(fromHash, toHash) => - onFilterChange({ kind: "range", fromHash, toHash }) + onFilterChange({ kind: "range", fromHash, toHash, includeFrom: true }) } /> diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/useChangeset/types.ts b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/useChangeset/types.ts index b04148919bc..267e12f571d 100644 --- a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/useChangeset/types.ts +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/useChangeset/types.ts @@ -3,13 +3,23 @@ import type { FileStatus } from "../../components/StatusIndicator"; export type DiffRef = | { kind: "against-base"; baseBranch: string | null } | { kind: "uncommitted" } - | { kind: "commit"; commitHash: string; fromHash?: string }; + | { + kind: "commit"; + commitHash: string; + fromHash?: string; + includeFrom?: boolean; + }; export type DiffFileSource = | { kind: "against-base"; baseBranch: string | null } | { kind: "staged" } | { kind: "unstaged" } - | { kind: "commit"; commitHash: string; fromHash?: string }; + | { + kind: "commit"; + commitHash: string; + fromHash?: string; + includeFrom?: boolean; + }; export interface ChangesetFile { path: string; diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/useChangeset/useChangeset.ts b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/useChangeset/useChangeset.ts index 2876cd813a5..a22e3f50887 100644 --- a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/useChangeset/useChangeset.ts +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/useChangeset/useChangeset.ts @@ -28,6 +28,7 @@ export function useChangeset({ workspaceId, commitHash: ref.commitHash, fromHash: ref.fromHash, + includeFrom: ref.includeFrom, } : { workspaceId, commitHash: "" }, { @@ -49,6 +50,7 @@ export function useChangeset({ kind: "commit", commitHash: ref.commitHash, fromHash: ref.fromHash, + includeFrom: ref.includeFrom, }, })); } diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/components/DiffPane/hooks/useDiffCodeViewItems/useDiffCodeViewItems.ts b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/components/DiffPane/hooks/useDiffCodeViewItems/useDiffCodeViewItems.ts index 4765b6046cb..8539b5a3404 100644 --- a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/components/DiffPane/hooks/useDiffCodeViewItems/useDiffCodeViewItems.ts +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/components/DiffPane/hooks/useDiffCodeViewItems/useDiffCodeViewItems.ts @@ -64,7 +64,7 @@ interface UseDiffCodeViewItemsResult { } /** A patch request: every file sharing a (category, baseBranch, commitHash, - * fromHash) resolves to one `git diff`. A DiffPane's file list can mix + * fromHash, includeFrom) resolves to one `git diff`. A DiffPane's file list can mix * categories (staged + unstaged + against-base in one "changes" view), so * this is usually 1-3 groups, never one per file. */ interface PatchGroup { @@ -160,6 +160,7 @@ function groupKeyFor(input: DiffPatchScope): string { input.baseBranch ?? "", input.commitHash ?? "", input.fromHash ?? "", + input.includeFrom ?? false, ].join("\0"); } @@ -629,6 +630,7 @@ function createGetDiffPatchInput( category: "commit", commitHash: source.commitHash, fromHash: source.fromHash, + includeFrom: source.includeFrom, }; } return { workspaceId, category: source.kind }; diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/components/DiffPane/utils/createGetDiffInput/createGetDiffInput.ts b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/components/DiffPane/utils/createGetDiffInput/createGetDiffInput.ts index 361dc311bd1..7d6ece66e0b 100644 --- a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/components/DiffPane/utils/createGetDiffInput/createGetDiffInput.ts +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/components/DiffPane/utils/createGetDiffInput/createGetDiffInput.ts @@ -27,6 +27,7 @@ export function createGetDiffInput( category: "commit", commitHash: source.commitHash, fromHash: source.fromHash, + includeFrom: source.includeFrom, }; } return { diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/useSidebarDiffRef/useSidebarDiffRef.ts b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/useSidebarDiffRef/useSidebarDiffRef.ts index cbf779c25b7..2585c9ffd89 100644 --- a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/useSidebarDiffRef/useSidebarDiffRef.ts +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/useSidebarDiffRef/useSidebarDiffRef.ts @@ -31,6 +31,7 @@ export function useSidebarDiffRef(workspaceId: string): DiffRef { ? filter.toHash : null; const fromHash = filter.kind === "range" ? filter.fromHash : null; + const includeFrom = filter.kind === "range" ? filter.includeFrom : false; return useMemo(() => { switch (filterKind) { @@ -43,9 +44,10 @@ export function useSidebarDiffRef(workspaceId: string): DiffRef { kind: "commit", commitHash: commitHash ?? "", fromHash: fromHash ?? undefined, + includeFrom, }; default: return { kind: "against-base", baseBranch }; } - }, [filterKind, commitHash, fromHash, baseBranch]); + }, [filterKind, commitHash, fromHash, includeFrom, baseBranch]); } diff --git a/apps/desktop/src/renderer/routes/_authenticated/providers/CollectionsProvider/dashboardSidebarLocal/schema.ts b/apps/desktop/src/renderer/routes/_authenticated/providers/CollectionsProvider/dashboardSidebarLocal/schema.ts index 41fb48b8eac..0fc2df9f0bd 100644 --- a/apps/desktop/src/renderer/routes/_authenticated/providers/CollectionsProvider/dashboardSidebarLocal/schema.ts +++ b/apps/desktop/src/renderer/routes/_authenticated/providers/CollectionsProvider/dashboardSidebarLocal/schema.ts @@ -151,6 +151,7 @@ const changesFilterSchema = z.discriminatedUnion("kind", [ kind: z.literal("range"), fromHash: z.string(), toHash: z.string(), + includeFrom: z.boolean().optional(), }), ]); diff --git a/packages/host-service/src/trpc/router/git/git.ts b/packages/host-service/src/trpc/router/git/git.ts index ddbb0e45e83..019561d36bd 100644 --- a/packages/host-service/src/trpc/router/git/git.ts +++ b/packages/host-service/src/trpc/router/git/git.ts @@ -232,6 +232,7 @@ const getDiffInputShape = z.object({ baseBranch: z.string().optional(), commitHash: z.string().optional(), fromHash: z.string().optional(), + includeFrom: z.boolean().optional(), }); /** Upper bound on one getDiffBulk call — generous headroom over the largest @@ -419,12 +420,13 @@ export const gitRouter = router({ workspaceId: z.string(), commitHash: z.string(), fromHash: z.string().optional(), + includeFrom: z.boolean().optional(), }), ) .query(async ({ ctx, input }) => { const worktreePath = resolveWorktreePath(ctx, input.workspaceId); const gitEnv = await resolveGitTaskEnv(ctx, worktreePath); - const dedupeKey = `${input.workspaceId}:commit-files:${input.fromHash ?? ""}:${input.commitHash}`; + const dedupeKey = `${input.workspaceId}:commit-files:${input.fromHash ?? ""}:${input.includeFrom ?? false}:${input.commitHash}`; const files = await runCommitFilesDeduped(dedupeKey, () => getHostWorkerPool().run( gitCommitFilesTask, @@ -432,6 +434,7 @@ export const gitRouter = router({ worktreePath, commitHash: input.commitHash, fromHash: input.fromHash, + includeFrom: input.includeFrom, gitEnv, }, { @@ -821,6 +824,7 @@ export const gitRouter = router({ baseBranch: input.baseBranch, commitHash: input.commitHash, fromHash: input.fromHash, + includeFrom: input.includeFrom, gitEnv, }, { timeoutMs: 30_000 }, @@ -843,6 +847,7 @@ export const gitRouter = router({ baseBranch: z.string().optional(), commitHash: z.string().optional(), fromHash: z.string().optional(), + includeFrom: z.boolean().optional(), }), ) .query(async ({ ctx, input }) => { @@ -861,6 +866,7 @@ export const gitRouter = router({ baseBranch: input.baseBranch, commitHash: input.commitHash, fromHash: input.fromHash, + includeFrom: input.includeFrom, gitEnv, }, { timeoutMs: 60_000 }, @@ -882,6 +888,7 @@ export const gitRouter = router({ baseBranch: z.string().optional(), commitHash: z.string().optional(), fromHash: z.string().optional(), + includeFrom: z.boolean().optional(), }), ) .query(async ({ ctx, input }) => { @@ -901,6 +908,7 @@ export const gitRouter = router({ baseBranch: input.baseBranch, commitHash: input.commitHash, fromHash: input.fromHash, + includeFrom: input.includeFrom, gitEnv, }, { timeoutMs: 60_000 }, diff --git a/packages/host-service/src/trpc/router/git/utils/git-helpers.ts b/packages/host-service/src/trpc/router/git/utils/git-helpers.ts index fb4de8b1077..9d319febe5b 100644 --- a/packages/host-service/src/trpc/router/git/utils/git-helpers.ts +++ b/packages/host-service/src/trpc/router/git/utils/git-helpers.ts @@ -553,10 +553,31 @@ export interface DiffCategoryRefs { toRef?: string; } +const EMPTY_TREE_HASH = "4b825dc642cb6eb9a060e54bf8d69288fbee4904"; + +async function resolveCommitBaseRef( + git: SimpleGit, + commitHash: string, + fromHash: string | undefined, + includeFrom: boolean | undefined, +): Promise { + if (!includeFrom || !fromHash) return fromHash ?? `${commitHash}^`; + + const parents = await git.raw(["rev-list", "--parents", "-n", "1", fromHash]); + const refs = parents.trim().split(/\s+/); + if (!refs[0]) throw new Error(`Could not resolve commit ${fromHash}`); + return refs[1] ?? EMPTY_TREE_HASH; +} + export async function resolveDiffCategoryRefs( git: SimpleGit, category: DiffCategory, - opts: { baseBranch?: string; commitHash?: string; fromHash?: string }, + opts: { + baseBranch?: string; + commitHash?: string; + fromHash?: string; + includeFrom?: boolean; + }, ): Promise { if (category === "against-base") { const base = await resolveBaseComparison(git, opts.baseBranch); @@ -578,7 +599,12 @@ export async function resolveDiffCategoryRefs( }); } return { - fromRef: opts.fromHash ?? `${opts.commitHash}^`, + fromRef: await resolveCommitBaseRef( + git, + opts.commitHash, + opts.fromHash, + opts.includeFrom, + ), toRef: opts.commitHash, }; } diff --git a/packages/host-service/src/workers/tasks/git.ts b/packages/host-service/src/workers/tasks/git.ts index 2c4673432a3..5df88e96b6e 100644 --- a/packages/host-service/src/workers/tasks/git.ts +++ b/packages/host-service/src/workers/tasks/git.ts @@ -102,15 +102,29 @@ export const gitCommitFilesTask = defineWorkerTask< worktreePath: string; commitHash: string; fromHash?: string; + includeFrom?: boolean; gitEnv: GitTaskEnv; }, ChangedFile[] >({ type: "git/getCommitFiles", - handler: async ({ worktreePath, commitHash, fromHash, gitEnv }) => { + handler: async ({ + worktreePath, + commitHash, + fromHash, + includeFrom, + gitEnv, + }) => { const git = createUserSimpleGit(worktreePath, { env: gitEnv }); - const from = fromHash ? fromHash : `${commitHash}^`; - return getChangedFilesForDiff(git, [from, commitHash]); + const refs = await resolveDiffCategoryRefs(git, "commit", { + commitHash, + fromHash, + includeFrom, + }); + if (!refs.fromRef || !refs.toRef) { + throw new Error("Could not resolve commit diff refs"); + } + return getChangedFilesForDiff(git, [refs.fromRef, refs.toRef]); }, }); @@ -126,6 +140,7 @@ export const gitDiffBulkTask = defineWorkerTask< baseBranch?: string; commitHash?: string; fromHash?: string; + includeFrom?: boolean; gitEnv: GitTaskEnv; }, { @@ -144,12 +159,13 @@ export const gitDiffBulkTask = defineWorkerTask< baseBranch, commitHash, fromHash, + includeFrom, gitEnv, }) => { const refs = await resolveDiffCategoryRefs( createUserSimpleGit(worktreePath, { env: gitEnv }), category, - { baseBranch, commitHash, fromHash }, + { baseBranch, commitHash, fromHash, includeFrom }, ); const diffs = await mapWithConcurrency( @@ -185,6 +201,7 @@ export const gitDiffPatchTask = defineWorkerTask< baseBranch?: string; commitHash?: string; fromHash?: string; + includeFrom?: boolean; gitEnv: GitTaskEnv; }, { patch: string } @@ -198,6 +215,7 @@ export const gitDiffPatchTask = defineWorkerTask< baseBranch, commitHash, fromHash, + includeFrom, gitEnv, }) => { const git = createUserSimpleGit(worktreePath, { env: gitEnv }); @@ -205,6 +223,7 @@ export const gitDiffPatchTask = defineWorkerTask< baseBranch, commitHash, fromHash, + includeFrom, }); const patch = await buildDiffPatch({ cwd: worktreePath, @@ -238,6 +257,7 @@ export const gitDiffSideBlobTask = defineWorkerTask< baseBranch?: string; commitHash?: string; fromHash?: string; + includeFrom?: boolean; gitEnv: GitTaskEnv; }, DiffSideBlobResult @@ -252,6 +272,7 @@ export const gitDiffSideBlobTask = defineWorkerTask< baseBranch, commitHash, fromHash, + includeFrom, gitEnv, }) => { const git = createUserSimpleGit(worktreePath, { env: gitEnv }); @@ -259,6 +280,7 @@ export const gitDiffSideBlobTask = defineWorkerTask< baseBranch, commitHash, fromHash, + includeFrom, }); const spec = diffSideObjectSpec(category, side, path, refs); if (!spec) return { kind: "missing" }; diff --git a/packages/host-service/test/integration/git-history.integration.test.ts b/packages/host-service/test/integration/git-history.integration.test.ts index 952f6e15eae..2658a7ebbd6 100644 --- a/packages/host-service/test/integration/git-history.integration.test.ts +++ b/packages/host-service/test/integration/git-history.integration.test.ts @@ -66,6 +66,66 @@ describe("git history + diff procedures", () => { expect(paths).toContain("y.txt"); }); + test("commit ranges include files from the oldest selected commit", async () => { + const fromHash = await scenario.repo.commit("range start", { + "range-start.txt": "first\n", + }); + await scenario.repo.commit("range middle", { + "range-middle.txt": "second\n", + }); + const toHash = await scenario.repo.commit("range end", { + "range-end.txt": "third\n", + }); + + const files = await scenario.host.trpc.git.getCommitFiles.query({ + workspaceId: scenario.workspaceId, + fromHash, + commitHash: toHash, + includeFrom: true, + }); + const paths = files.files.map((file) => file.path); + expect(paths).toContain("range-start.txt"); + expect(paths).toContain("range-middle.txt"); + expect(paths).toContain("range-end.txt"); + + const diff = await scenario.host.trpc.git.getDiff.query({ + workspaceId: scenario.workspaceId, + path: "range-start.txt", + category: "commit", + fromHash, + commitHash: toHash, + includeFrom: true, + }); + expect(diff.oldFile.contents).toBe(""); + expect(diff.newFile.contents).toBe("first\n"); + + const patch = await scenario.host.trpc.git.getDiffPatch.query({ + workspaceId: scenario.workspaceId, + paths: ["range-start.txt"], + category: "commit", + fromHash, + commitHash: toHash, + includeFrom: true, + }); + expect(patch.patch).toContain("range-start.txt"); + }); + + test("inclusive commit ranges use the empty tree for a root commit", async () => { + await scenario.repo.git.raw(["checkout", "--orphan", "root-range"]); + const rootHash = await scenario.repo.commit("root range commit", { + "root-range.txt": "root\n", + }); + + const result = await scenario.host.trpc.git.getCommitFiles.query({ + workspaceId: scenario.workspaceId, + fromHash: rootHash, + commitHash: rootHash, + includeFrom: true, + }); + + expect(result.files.map((file) => file.path)).toContain("root-range.txt"); + }); + test("getDiff returns staged content for a staged change", async () => { const filePath = join(scenario.repo.repoPath, "README.md"); writeFileSync(filePath, "modified line\n");