From 6254681588b173363ee624bcf948558d6f59b0c0 Mon Sep 17 00:00:00 2001 From: Adam Bowker Date: Wed, 1 Apr 2026 13:23:48 -0700 Subject: [PATCH] feat: add staged file tracking and selective commit support --- .../src/main/services/git/create-pr-saga.ts | 13 +- apps/code/src/main/services/git/schemas.ts | 15 +- apps/code/src/main/services/git/service.ts | 43 +++- apps/code/src/main/trpc/routers/git.ts | 49 ++++- .../code-review/components/ReviewPage.tsx | 204 +++++++++++------- .../code-review/components/ReviewShell.tsx | 3 + .../code-review/components/ReviewToolbar.tsx | 24 ++- .../code-review/hooks/useReviewDiffs.ts | 110 ++++++++++ .../components/CreatePrDialog.tsx | 27 ++- .../components/GitInteractionDialogs.tsx | 52 ++++- .../components/GitInteractionHeader.tsx | 12 ++ .../hooks/useGitInteraction.ts | 71 +++++- .../state/gitInteractionStore.ts | 4 + .../git-interaction/utils/diffStats.ts | 15 +- .../features/git-interaction/utils/fileKey.ts | 3 + .../git-interaction/utils/gitCacheKeys.ts | 2 + .../utils/partitionByStaged.ts | 14 ++ .../task-detail/components/ChangesPanel.tsx | 137 +++++++++--- apps/code/src/shared/types.ts | 1 + apps/code/src/shared/types/analytics.ts | 8 + packages/git/src/queries.ts | 146 ++++++------- packages/git/src/sagas/commit.ts | 54 +++-- 22 files changed, 770 insertions(+), 237 deletions(-) create mode 100644 apps/code/src/renderer/features/code-review/hooks/useReviewDiffs.ts create mode 100644 apps/code/src/renderer/features/git-interaction/utils/fileKey.ts create mode 100644 apps/code/src/renderer/features/git-interaction/utils/partitionByStaged.ts diff --git a/apps/code/src/main/services/git/create-pr-saga.ts b/apps/code/src/main/services/git/create-pr-saga.ts index 62bc059fd..3a2bb74ed 100644 --- a/apps/code/src/main/services/git/create-pr-saga.ts +++ b/apps/code/src/main/services/git/create-pr-saga.ts @@ -17,6 +17,7 @@ export interface CreatePrSagaInput { prTitle?: string; prBody?: string; draft?: boolean; + stagedOnly?: boolean; } export interface CreatePrSagaOutput { @@ -32,7 +33,11 @@ export interface CreatePrDeps { ): Promise<{ previousBranch: string; currentBranch: string }>; getChangedFilesHead(dir: string): Promise; generateCommitMessage(dir: string): Promise<{ message: string }>; - commit(dir: string, message: string): Promise; + commit( + dir: string, + message: string, + stagedOnly?: boolean, + ): Promise; getSyncStatus(dir: string): Promise; push(dir: string): Promise; publish(dir: string): Promise; @@ -120,7 +125,11 @@ export class CreatePrSaga extends Saga { await this.step({ name: "committing", execute: async () => { - const result = await this.deps.commit(directoryPath, commitMessage!); + const result = await this.deps.commit( + directoryPath, + commitMessage!, + input.stagedOnly, + ); if (!result.success) throw new Error(result.message); return result; }, diff --git a/apps/code/src/main/services/git/schemas.ts b/apps/code/src/main/services/git/schemas.ts index ddf9bba2c..c587582ef 100644 --- a/apps/code/src/main/services/git/schemas.ts +++ b/apps/code/src/main/services/git/schemas.ts @@ -21,6 +21,7 @@ export const changedFileSchema = z.object({ originalPath: z.string().optional(), linesAdded: z.number().optional(), linesRemoved: z.number().optional(), + staged: z.boolean().optional(), }); export type ChangedFile = z.infer; @@ -120,17 +121,23 @@ export const getFileAtHeadInput = z.object({ }); export const getFileAtHeadOutput = z.string().nullable(); -// getDiffHead schemas -export const getDiffHeadInput = z.object({ +// Shared diff schemas (getDiffHead, getDiffCached, getDiffUnstaged) +export const diffInput = z.object({ directoryPath: z.string(), ignoreWhitespace: z.boolean().optional(), }); -export const getDiffHeadOutput = z.string(); +export const diffOutput = z.string(); // getDiffStats schemas export const getDiffStatsInput = directoryPathInput; export const getDiffStatsOutput = diffStatsSchema; +// stageFiles / unstageFiles shared schema +export const stageFilesInput = z.object({ + directoryPath: z.string(), + paths: z.array(z.string()), +}); + // getCurrentBranch schemas export const getCurrentBranchInput = directoryPathInput; export const getCurrentBranchOutput = z.string().nullable(); @@ -198,6 +205,7 @@ export const commitInput = z.object({ message: z.string(), paths: z.array(z.string()).optional(), allowEmpty: z.boolean().optional(), + stagedOnly: z.boolean().optional(), }); export type CommitInput = z.infer; @@ -241,6 +249,7 @@ export const createPrInput = z.object({ prTitle: z.string().optional(), prBody: z.string().optional(), draft: z.boolean().optional(), + stagedOnly: z.boolean().optional(), }); export type CreatePrInput = z.infer; diff --git a/apps/code/src/main/services/git/service.ts b/apps/code/src/main/services/git/service.ts index 7483c6443..29609f5da 100644 --- a/apps/code/src/main/services/git/service.ts +++ b/apps/code/src/main/services/git/service.ts @@ -19,6 +19,8 @@ import { getUnstagedDiff, fetch as gitFetch, isGitRepository, + stageFiles, + unstageFiles, } from "@posthog/git/queries"; import { CreateBranchSaga, SwitchBranchSaga } from "@posthog/git/sagas/branch"; import { CloneSaga } from "@posthog/git/sagas/clone"; @@ -266,6 +268,7 @@ export class GitService extends TypedEventEmitter { originalPath: f.originalPath, linesAdded: f.linesAdded, linesRemoved: f.linesRemoved, + staged: f.staged, })); } @@ -283,6 +286,36 @@ export class GitService extends TypedEventEmitter { return getDiffHead(directoryPath, { ignoreWhitespace }); } + public async getDiffCached( + directoryPath: string, + ignoreWhitespace?: boolean, + ): Promise { + return getStagedDiff(directoryPath, { ignoreWhitespace }); + } + + public async getDiffUnstaged( + directoryPath: string, + ignoreWhitespace?: boolean, + ): Promise { + return getUnstagedDiff(directoryPath, { ignoreWhitespace }); + } + + public async stageFiles( + directoryPath: string, + paths: string[], + ): Promise { + await stageFiles(directoryPath, paths); + return this.getStateSnapshot(directoryPath); + } + + public async unstageFiles( + directoryPath: string, + paths: string[], + ): Promise { + await unstageFiles(directoryPath, paths); + return this.getStateSnapshot(directoryPath); + } + public async getDiffStats(directoryPath: string): Promise { const stats = await getDiffStats(directoryPath, { excludePatterns: [".claude", "CLAUDE.local.md"], @@ -479,6 +512,7 @@ export class GitService extends TypedEventEmitter { prTitle?: string; prBody?: string; draft?: boolean; + stagedOnly?: boolean; }): Promise { const { directoryPath, flowId } = input; @@ -502,7 +536,7 @@ export class GitService extends TypedEventEmitter { checkoutBranch: (dir, name) => this.checkoutBranch(dir, name), getChangedFilesHead: (dir) => this.getChangedFilesHead(dir), generateCommitMessage: (dir) => this.generateCommitMessage(dir), - commit: (dir, msg) => this.commit(dir, msg), + commit: (dir, msg, stagedOnly) => this.commit(dir, msg, { stagedOnly }), getSyncStatus: (dir) => this.getGitSyncStatus(dir), push: (dir) => this.push(dir), publish: (dir) => this.publish(dir), @@ -521,6 +555,7 @@ export class GitService extends TypedEventEmitter { prTitle: input.prTitle, prBody: input.prBody, draft: input.draft, + stagedOnly: input.stagedOnly, }); if (!result.success) { @@ -584,8 +619,7 @@ export class GitService extends TypedEventEmitter { public async commit( directoryPath: string, message: string, - paths?: string[], - allowEmpty?: boolean, + options?: { paths?: string[]; allowEmpty?: boolean; stagedOnly?: boolean }, ): Promise { const fail = (msg: string): CommitOutput => ({ success: false, @@ -600,8 +634,7 @@ export class GitService extends TypedEventEmitter { const result = await saga.run({ baseDir: directoryPath, message: message.trim(), - paths, - allowEmpty, + ...options, }); if (!result.success) return fail(result.error); diff --git a/apps/code/src/main/trpc/routers/git.ts b/apps/code/src/main/trpc/routers/git.ts index dfd3eab07..8fa869f07 100644 --- a/apps/code/src/main/trpc/routers/git.ts +++ b/apps/code/src/main/trpc/routers/git.ts @@ -13,6 +13,8 @@ import { createPrOutput, detectRepoInput, detectRepoOutput, + diffInput, + diffOutput, discardFileChangesInput, discardFileChangesOutput, generateCommitMessageInput, @@ -29,8 +31,6 @@ import { getCommitConventionsOutput, getCurrentBranchInput, getCurrentBranchOutput, - getDiffHeadInput, - getDiffHeadOutput, getDiffStatsInput, getDiffStatsOutput, getFileAtHeadInput, @@ -45,6 +45,7 @@ import { getPrTemplateInput, getPrTemplateOutput, ghStatusOutput, + gitStateSnapshotSchema, openPrInput, openPrOutput, prStatusInput, @@ -57,6 +58,7 @@ import { pushOutput, searchGithubIssuesInput, searchGithubIssuesOutput, + stageFilesInput, syncInput, syncOutput, validateRepoInput, @@ -139,17 +141,45 @@ export const gitRouter = router({ ), getDiffHead: publicProcedure - .input(getDiffHeadInput) - .output(getDiffHeadOutput) + .input(diffInput) + .output(diffOutput) .query(({ input }) => getService().getDiffHead(input.directoryPath, input.ignoreWhitespace), ), + getDiffCached: publicProcedure + .input(diffInput) + .output(diffOutput) + .query(({ input }) => + getService().getDiffCached(input.directoryPath, input.ignoreWhitespace), + ), + + getDiffUnstaged: publicProcedure + .input(diffInput) + .output(diffOutput) + .query(({ input }) => + getService().getDiffUnstaged(input.directoryPath, input.ignoreWhitespace), + ), + getDiffStats: publicProcedure .input(getDiffStatsInput) .output(getDiffStatsOutput) .query(({ input }) => getService().getDiffStats(input.directoryPath)), + stageFiles: publicProcedure + .input(stageFilesInput) + .output(gitStateSnapshotSchema) + .mutation(({ input }) => + getService().stageFiles(input.directoryPath, input.paths), + ), + + unstageFiles: publicProcedure + .input(stageFilesInput) + .output(gitStateSnapshotSchema) + .mutation(({ input }) => + getService().unstageFiles(input.directoryPath, input.paths), + ), + discardFileChanges: publicProcedure .input(discardFileChangesInput) .output(discardFileChangesOutput) @@ -189,12 +219,11 @@ export const gitRouter = router({ .input(commitInput) .output(commitOutput) .mutation(({ input }) => - getService().commit( - input.directoryPath, - input.message, - input.paths, - input.allowEmpty, - ), + getService().commit(input.directoryPath, input.message, { + paths: input.paths, + allowEmpty: input.allowEmpty, + stagedOnly: input.stagedOnly, + }), ), push: publicProcedure diff --git a/apps/code/src/renderer/features/code-review/components/ReviewPage.tsx b/apps/code/src/renderer/features/code-review/components/ReviewPage.tsx index 8c0c844fb..4c661182b 100644 --- a/apps/code/src/renderer/features/code-review/components/ReviewPage.tsx +++ b/apps/code/src/renderer/features/code-review/components/ReviewPage.tsx @@ -1,18 +1,19 @@ -import { useDiffViewerStore } from "@features/code-editor/stores/diffViewerStore"; -import { useGitQueries } from "@features/git-interaction/hooks/useGitQueries"; +import { makeFileKey } from "@features/git-interaction/utils/fileKey"; import { usePanelLayoutStore } from "@features/panels/store/panelLayoutStore"; import { useCwd } from "@features/sidebar/hooks/useCwd"; -import { parsePatchFiles } from "@pierre/diffs"; +import type { parsePatchFiles } from "@pierre/diffs"; import { Flex, Text } from "@radix-ui/themes"; import { useTRPC } from "@renderer/trpc/client"; import type { ChangedFile } from "@shared/types"; import { useQuery } from "@tanstack/react-query"; import { useMemo } from "react"; import { useReviewComment } from "../hooks/useReviewComment"; +import { useReviewDiffs } from "../hooks/useReviewDiffs"; import type { DiffOptions, OnCommentCallback } from "../types"; import { InteractiveFileDiff } from "./InteractiveFileDiff"; import { DeferredDiffPlaceholder, + type DeferredReason, DiffFileHeader, ReviewShell, sumHunkStats, @@ -24,40 +25,22 @@ interface ReviewPageProps { } export function ReviewPage({ taskId }: ReviewPageProps) { - const trpc = useTRPC(); const repoPath = useCwd(taskId); - const { changedFiles, changesLoading } = useGitQueries(repoPath); - const hideWhitespace = useDiffViewerStore((s) => s.hideWhitespaceChanges); const openFile = usePanelLayoutStore((s) => s.openFile); const onComment = useReviewComment(taskId); - const { data: rawDiff, isLoading: diffLoading } = useQuery( - trpc.git.getDiffHead.queryOptions( - { directoryPath: repoPath as string, ignoreWhitespace: hideWhitespace }, - { enabled: !!repoPath, staleTime: 30_000, refetchOnMount: "always" }, - ), - ); - - const parsedFiles = useMemo(() => { - if (!rawDiff) return []; - const patches = parsePatchFiles(rawDiff); - return patches.flatMap((p) => p.files); - }, [rawDiff]); - - const untrackedFiles = useMemo( - () => changedFiles.filter((f) => f.status === "untracked"), - [changedFiles], - ); - - const totalFileCount = parsedFiles.length + untrackedFiles.length; - - const allPaths = useMemo( - () => [ - ...parsedFiles.map((f) => f.name ?? f.prevName ?? ""), - ...untrackedFiles.map((f) => f.path), - ], - [parsedFiles, untrackedFiles], - ); + const { + changedFiles, + changesLoading, + hasStagedFiles, + stagedParsedFiles, + unstagedParsedFiles, + untrackedFiles, + totalFileCount, + allPaths, + diffLoading, + refetch, + } = useReviewDiffs(repoPath); const { diffOptions, @@ -82,6 +65,18 @@ export function ReviewPage({ taskId }: ReviewPageProps) { ); } + const sharedDiffProps = { + repoPath, + taskId, + diffOptions, + collapsedFiles, + toggleFile, + revealFile, + getDeferredReason, + openFile, + onComment, + }; + return ( - {parsedFiles.map((fileDiff) => { - const key = fileDiff.name ?? fileDiff.prevName ?? ""; + {hasStagedFiles && stagedParsedFiles.length > 0 && ( + <> + + + + )} + {hasStagedFiles && + (unstagedParsedFiles.length > 0 || untrackedFiles.length > 0) && ( + + )} + + {untrackedFiles.map((file) => { + const key = makeFileKey(file.staged, file.path); const isCollapsed = collapsedFiles.has(key); - const deferredReason = getDeferredReason(key); - - if (deferredReason) { - const { additions, deletions } = sumHunkStats(fileDiff.hunks); - return ( -
- toggleFile(key)} - onShow={() => revealFile(key)} - /> -
- ); - } - return (
- ( - toggleFile(key)} - onOpenFile={() => - openFile(taskId, `${repoPath}/${key}`, false) - } - /> - )} - /> -
- ); - })} - {untrackedFiles.map((file) => { - const isCollapsed = collapsedFiles.has(file.path); - return ( -
toggleFile(file.path)} + onToggle={() => toggleFile(key)} onComment={onComment} />
@@ -157,6 +122,89 @@ export function ReviewPage({ taskId }: ReviewPageProps) { ); } +function SectionLabel({ label }: { label: string }) { + return ( + + + {label} + + + ); +} + +interface FileDiffListProps { + files: ReturnType[number]["files"]; + staged?: boolean; + repoPath: string; + taskId: string; + diffOptions: DiffOptions; + collapsedFiles: Set; + toggleFile: (key: string) => void; + revealFile: (key: string) => void; + getDeferredReason: (key: string) => DeferredReason | null; + openFile: (taskId: string, path: string, preview: boolean) => void; + onComment: OnCommentCallback; +} + +function FileDiffList({ + files, + staged = false, + repoPath, + taskId, + diffOptions, + collapsedFiles, + toggleFile, + revealFile, + getDeferredReason, + openFile, + onComment, +}: FileDiffListProps) { + return files.map((fileDiff) => { + const filePath = fileDiff.name ?? fileDiff.prevName ?? ""; + const key = makeFileKey(staged, filePath); + const isCollapsed = collapsedFiles.has(key); + const deferredReason = getDeferredReason(key); + + if (deferredReason) { + const { additions, deletions } = sumHunkStats(fileDiff.hunks); + return ( +
+ toggleFile(key)} + onShow={() => revealFile(key)} + /> +
+ ); + } + + return ( +
+ ( + toggleFile(key)} + onOpenFile={() => + openFile(taskId, `${repoPath}/${filePath}`, false) + } + /> + )} + /> +
+ ); + }); +} + function UntrackedFileDiff({ file, repoPath, diff --git a/apps/code/src/renderer/features/code-review/components/ReviewShell.tsx b/apps/code/src/renderer/features/code-review/components/ReviewShell.tsx index 1c6b02150..fe068deed 100644 --- a/apps/code/src/renderer/features/code-review/components/ReviewShell.tsx +++ b/apps/code/src/renderer/features/code-review/components/ReviewShell.tsx @@ -220,6 +220,7 @@ export interface ReviewShellProps { allExpanded: boolean; onExpandAll: () => void; onCollapseAll: () => void; + onRefresh?: () => void; } export function ReviewShell({ @@ -234,6 +235,7 @@ export function ReviewShell({ allExpanded, onExpandAll, onCollapseAll, + onRefresh, }: ReviewShellProps) { const scrollContainerRef = useRef(null); @@ -349,6 +351,7 @@ export function ReviewShell({ allExpanded={allExpanded} onExpandAll={onExpandAll} onCollapseAll={onCollapseAll} + onRefresh={onRefresh} />
void; onCollapseAll: () => void; + onRefresh?: () => void; } export const ReviewToolbar = memo(function ReviewToolbar({ @@ -20,6 +28,7 @@ export const ReviewToolbar = memo(function ReviewToolbar({ allExpanded, onExpandAll, onCollapseAll, + onRefresh, }: ReviewToolbarProps) { const viewMode = useDiffViewerStore((s) => s.viewMode); const toggleViewMode = useDiffViewerStore((s) => s.toggleViewMode); @@ -56,6 +65,19 @@ export const ReviewToolbar = memo(function ReviewToolbar({ + {onRefresh && ( + + + + + + )} s.hideWhitespaceChanges); + + const hasStagedFiles = useMemo( + () => changedFiles.some((f) => f.staged), + [changedFiles], + ); + + const { + data: rawDiffCached, + isLoading: diffCachedLoading, + refetch: refetchDiffCached, + } = useQuery( + trpc.git.getDiffCached.queryOptions( + { directoryPath: repoPath as string, ignoreWhitespace: hideWhitespace }, + { + enabled: !!repoPath && hasStagedFiles, + staleTime: 30_000, + refetchOnMount: "always", + }, + ), + ); + + const { + data: rawDiffUnstaged, + isLoading: diffUnstagedLoading, + refetch: refetchDiffUnstaged, + } = useQuery( + trpc.git.getDiffUnstaged.queryOptions( + { directoryPath: repoPath as string, ignoreWhitespace: hideWhitespace }, + { + enabled: !!repoPath, + staleTime: 30_000, + refetchOnMount: "always", + }, + ), + ); + + const diffLoading = + diffUnstagedLoading || (hasStagedFiles && diffCachedLoading); + + const stagedParsedFiles = useMemo( + () => + rawDiffCached + ? parsePatchFiles(rawDiffCached).flatMap((p) => p.files) + : [], + [rawDiffCached], + ); + + const unstagedParsedFiles = useMemo( + () => + rawDiffUnstaged + ? parsePatchFiles(rawDiffUnstaged).flatMap((p) => p.files) + : [], + [rawDiffUnstaged], + ); + + const untrackedFiles = useMemo( + () => changedFiles.filter((f) => f.status === "untracked"), + [changedFiles], + ); + + const totalFileCount = + stagedParsedFiles.length + + unstagedParsedFiles.length + + untrackedFiles.length; + + const allPaths = useMemo( + () => [ + ...stagedParsedFiles.map((f) => + makeFileKey(true, f.name ?? f.prevName ?? ""), + ), + ...unstagedParsedFiles.map((f) => + makeFileKey(false, f.name ?? f.prevName ?? ""), + ), + ...untrackedFiles.map((f) => makeFileKey(f.staged, f.path)), + ], + [stagedParsedFiles, unstagedParsedFiles, untrackedFiles], + ); + + const refetch = useCallback(() => { + if (repoPath) invalidateGitWorkingTreeQueries(repoPath); + refetchDiffUnstaged(); + if (hasStagedFiles) refetchDiffCached(); + }, [repoPath, hasStagedFiles, refetchDiffCached, refetchDiffUnstaged]); + + return { + changedFiles, + changesLoading, + hasStagedFiles, + stagedParsedFiles, + unstagedParsedFiles, + untrackedFiles, + totalFileCount, + allPaths, + diffLoading, + refetch, + }; +} diff --git a/apps/code/src/renderer/features/git-interaction/components/CreatePrDialog.tsx b/apps/code/src/renderer/features/git-interaction/components/CreatePrDialog.tsx index ffc50c117..eb81a66cb 100644 --- a/apps/code/src/renderer/features/git-interaction/components/CreatePrDialog.tsx +++ b/apps/code/src/renderer/features/git-interaction/components/CreatePrDialog.tsx @@ -1,10 +1,14 @@ import { + CommitAllToggle, ErrorContainer, GenerateButton, } from "@features/git-interaction/components/GitInteractionDialogs"; import { useGitInteractionStore } from "@features/git-interaction/state/gitInteractionStore"; import type { CreatePrStep } from "@features/git-interaction/types"; -import type { DiffStats } from "@features/git-interaction/utils/diffStats"; +import { + type DiffStats, + formatFileCountLabel, +} from "@features/git-interaction/utils/diffStats"; import { CheckCircle, Circle, @@ -119,6 +123,10 @@ export interface CreatePrDialogProps { onSubmit: () => void; onGenerateCommitMessage: () => void; onGeneratePr: () => void; + showCommitAllToggle?: boolean; + commitAll?: boolean; + onCommitAllChange?: (value: boolean) => void; + stagedFileCount?: number; } export function CreatePrDialog({ @@ -130,6 +138,10 @@ export function CreatePrDialog({ onSubmit, onGenerateCommitMessage, onGeneratePr, + showCommitAllToggle, + commitAll, + onCommitAllChange, + stagedFileCount, }: CreatePrDialogProps) { const store = useGitInteractionStore(); const { actions } = store; @@ -192,8 +204,11 @@ export function CreatePrDialog({ - {diffStats.filesChanged} file - {diffStats.filesChanged === 1 ? "" : "s"} + {formatFileCountLabel( + !!(showCommitAllToggle && !commitAll), + stagedFileCount ?? 0, + diffStats.filesChanged, + )} +{diffStats.linesAdded} @@ -216,6 +231,12 @@ export function CreatePrDialog({ disabled={store.isGeneratingCommitMessage} autoFocus={!store.createPrNeedsBranch} /> + {showCommitAllToggle && onCommitAllChange && ( + + )} )} diff --git a/apps/code/src/renderer/features/git-interaction/components/GitInteractionDialogs.tsx b/apps/code/src/renderer/features/git-interaction/components/GitInteractionDialogs.tsx index 4dfe7610a..f9aadddd8 100644 --- a/apps/code/src/renderer/features/git-interaction/components/GitInteractionDialogs.tsx +++ b/apps/code/src/renderer/features/git-interaction/components/GitInteractionDialogs.tsx @@ -1,5 +1,8 @@ import { Tooltip } from "@components/ui/Tooltip"; -import type { DiffStats } from "@features/git-interaction/utils/diffStats"; +import { + type DiffStats, + formatFileCountLabel, +} from "@features/git-interaction/utils/diffStats"; import { CheckCircle, CloudArrowUp, @@ -13,6 +16,7 @@ import { CheckIcon } from "@radix-ui/react-icons"; import { Box, Button, + Checkbox, Dialog, Flex, IconButton, @@ -101,6 +105,34 @@ export function GenerateButton({ ); } +export function CommitAllToggle({ + checked, + onChange, +}: { + checked?: boolean; + onChange: (value: boolean) => void; +}) { + return ( + onChange(!checked)} + > + onChange(c === true)} + onClick={(e) => e.stopPropagation()} + /> + + Commit all changes + + + ); +} + interface GitDialogProps { open: boolean; onOpenChange: (open: boolean) => void; @@ -255,6 +287,10 @@ interface GitCommitDialogProps { error: string | null; onGenerateMessage: () => void; isGeneratingMessage: boolean; + showCommitAllToggle?: boolean; + commitAll?: boolean; + onCommitAllChange?: (value: boolean) => void; + stagedFileCount?: number; } export function GitCommitDialog({ @@ -272,6 +308,10 @@ export function GitCommitDialog({ error, onGenerateMessage, isGeneratingMessage, + showCommitAllToggle, + commitAll, + onCommitAllChange, + stagedFileCount, }: GitCommitDialogProps) { const options = [ { @@ -306,8 +346,11 @@ export function GitCommitDialog({ - {diffStats.filesChanged} file - {diffStats.filesChanged === 1 ? "" : "s"} + {formatFileCountLabel( + !!(showCommitAllToggle && !commitAll), + stagedFileCount ?? 0, + diffStats.filesChanged, + )} +{diffStats.linesAdded} @@ -317,6 +360,9 @@ export function GitCommitDialog({ + {showCommitAllToggle && onCommitAllChange && ( + + )} diff --git a/apps/code/src/renderer/features/git-interaction/components/GitInteractionHeader.tsx b/apps/code/src/renderer/features/git-interaction/components/GitInteractionHeader.tsx index 262dd3b5f..884ab5b2c 100644 --- a/apps/code/src/renderer/features/git-interaction/components/GitInteractionHeader.tsx +++ b/apps/code/src/renderer/features/git-interaction/components/GitInteractionHeader.tsx @@ -52,6 +52,12 @@ export function GitInteractionHeader({ taskId }: GitInteractionHeaderProps) { error={modals.commitError} onGenerateMessage={actions.generateCommitMessage} isGeneratingMessage={modals.isGeneratingCommitMessage} + showCommitAllToggle={ + state.stagedFiles.length > 0 && state.unstagedFiles.length > 0 + } + commitAll={modals.commitAll} + onCommitAllChange={actions.setCommitAll} + stagedFileCount={state.stagedFiles.length} /> 0 && state.unstagedFiles.length > 0 + } + commitAll={modals.commitAll} + onCommitAllChange={actions.setCommitAll} + stagedFileCount={state.stagedFiles.length} /> void; setCommitMessage: (value: string) => void; setCommitNextStep: (value: CommitNextStep) => void; + setCommitAll: (value: boolean) => void; setPrTitle: (value: string) => void; setPrBody: (value: string) => void; setBranchName: (value: string) => void; @@ -66,7 +71,35 @@ interface GitInteractionActions { setCreatePrDraft: (value: boolean) => void; } -function trackGitAction(taskId: string, actionType: string, success: boolean) { +function buildStagingContext( + stagedFiles: ChangedFile[], + unstagedFiles: ChangedFile[], + commitAll: boolean, +) { + const stagedOnly = + stagedFiles.length > 0 && unstagedFiles.length > 0 && !commitAll; + return { + stagedOnly, + analytics: { + staged_file_count: stagedFiles.length, + unstaged_file_count: unstagedFiles.length, + commit_all: commitAll, + staged_only: stagedOnly, + }, + }; +} + +function trackGitAction( + taskId: string, + actionType: string, + success: boolean, + stagingContext?: { + staged_file_count: number; + unstaged_file_count: number; + commit_all: boolean; + staged_only: boolean; + }, +) { track(ANALYTICS_EVENTS.GIT_ACTION_EXECUTED, { action_type: actionType as | "commit" @@ -78,6 +111,7 @@ function trackGitAction(taskId: string, actionType: string, success: boolean) { | "update-pr", success, task_id: taskId, + ...stagingContext, }); } @@ -131,10 +165,16 @@ export function useGitInteraction( ], ); + const { stagedFiles, unstagedFiles } = useMemo( + () => partitionByStaged(git.changedFiles), + [git.changedFiles], + ); + const openCreatePr = () => { const prExists = git.prStatus?.prExists ?? false; const needsBranch = !git.isFeatureBranch || prExists; const needsCommit = git.hasChanges; + modal.setCommitAll(!(stagedFiles.length > 0 && unstagedFiles.length > 0)); modal.openCreatePr({ needsBranch, needsCommit, @@ -173,6 +213,12 @@ export function useGitInteraction( ); try { + const { stagedOnly, analytics: prStagingContext } = buildStagingContext( + stagedFiles, + unstagedFiles, + store.commitAll, + ); + const result = await trpcClient.git.createPr.mutate({ directoryPath: repoPath, flowId, @@ -183,10 +229,11 @@ export function useGitInteraction( prTitle: store.prTitle.trim() || undefined, prBody: store.prBody.trim() || undefined, draft: store.createPrDraft || undefined, + stagedOnly: stagedOnly || undefined, }); if (!result.success) { - trackGitAction(taskId, "create-pr", false); + trackGitAction(taskId, "create-pr", false, prStagingContext); useGitInteractionStore.setState({ createPrError: result.message, createPrFailedStep: result.failedStep ?? null, @@ -195,7 +242,7 @@ export function useGitInteraction( return; } - trackGitAction(taskId, "create-pr", true); + trackGitAction(taskId, "create-pr", true, prStagingContext); track(ANALYTICS_EVENTS.PR_CREATED, { task_id: taskId, success: true }); if (result.state) { @@ -226,7 +273,12 @@ export function useGitInteraction( const openAction = (id: GitMenuActionId) => { const actionMap: Record void> = { - commit: () => modal.openCommit("commit"), + commit: () => { + modal.setCommitAll( + !(stagedFiles.length > 0 && unstagedFiles.length > 0), + ); + modal.openCommit("commit"); + }, push: () => modal.openPush("push"), sync: () => modal.openPush("sync"), publish: () => modal.openPush("publish"), @@ -290,18 +342,22 @@ export function useGitInteraction( } try { + const { stagedOnly, analytics: commitStagingContext } = + buildStagingContext(stagedFiles, unstagedFiles, store.commitAll); + const result = await trpcClient.git.commit.mutate({ directoryPath: repoPath, message, + stagedOnly: stagedOnly || undefined, }); if (!result.success) { - trackGitAction(taskId, "commit", false); + trackGitAction(taskId, "commit", false, commitStagingContext); modal.setCommitError(result.message || "Commit failed."); return; } - trackGitAction(taskId, "commit", true); + trackGitAction(taskId, "commit", true, commitStagingContext); if (result.state) { updateGitCacheFromSnapshot(queryClient, repoPath, result.state); @@ -469,6 +525,8 @@ export function useGitInteraction( prUrl: computed.prUrl, pushDisabledReason: computed.pushDisabledReason, isLoading: git.isLoading, + stagedFiles, + unstagedFiles, }, modals: store, actions: { @@ -478,6 +536,7 @@ export function useGitInteraction( closeBranch: modal.closeBranch, setCommitMessage: modal.setCommitMessage, setCommitNextStep: modal.setCommitNextStep, + setCommitAll: modal.setCommitAll, setPrTitle: modal.setPrTitle, setPrBody: modal.setPrBody, setBranchName: (value: string) => { diff --git a/apps/code/src/renderer/features/git-interaction/state/gitInteractionStore.ts b/apps/code/src/renderer/features/git-interaction/state/gitInteractionStore.ts index 457db5d95..f048ae0d1 100644 --- a/apps/code/src/renderer/features/git-interaction/state/gitInteractionStore.ts +++ b/apps/code/src/renderer/features/git-interaction/state/gitInteractionStore.ts @@ -34,6 +34,7 @@ interface GitInteractionState { isSubmitting: boolean; isGeneratingCommitMessage: boolean; isGeneratingPr: boolean; + commitAll: boolean; } interface GitInteractionActions { @@ -53,6 +54,7 @@ interface GitInteractionActions { setIsSubmitting: (value: boolean) => void; setIsGeneratingCommitMessage: (value: boolean) => void; setIsGeneratingPr: (value: boolean) => void; + setCommitAll: (value: boolean) => void; openCommit: (nextStep: CommitNextStep) => void; openPush: (mode: PushMode) => void; @@ -102,6 +104,7 @@ const initialState: GitInteractionState = { isSubmitting: false, isGeneratingCommitMessage: false, isGeneratingPr: false, + commitAll: true, }; export const useGitInteractionStore = create((set) => ({ @@ -124,6 +127,7 @@ export const useGitInteractionStore = create((set) => ({ setIsGeneratingCommitMessage: (value) => set({ isGeneratingCommitMessage: value }), setIsGeneratingPr: (value) => set({ isGeneratingPr: value }), + setCommitAll: (value) => set({ commitAll: value }), openCommit: (nextStep) => set({ commitNextStep: nextStep, commitError: null, commitOpen: true }), diff --git a/apps/code/src/renderer/features/git-interaction/utils/diffStats.ts b/apps/code/src/renderer/features/git-interaction/utils/diffStats.ts index d8d549715..ab0d88326 100644 --- a/apps/code/src/renderer/features/git-interaction/utils/diffStats.ts +++ b/apps/code/src/renderer/features/git-interaction/utils/diffStats.ts @@ -9,9 +9,22 @@ export interface DiffStats { export function computeDiffStats(files: ChangedFile[]): DiffStats { let linesAdded = 0; let linesRemoved = 0; + const uniquePaths = new Set(); for (const file of files) { linesAdded += file.linesAdded ?? 0; linesRemoved += file.linesRemoved ?? 0; + uniquePaths.add(file.path); } - return { filesChanged: files.length, linesAdded, linesRemoved }; + return { filesChanged: uniquePaths.size, linesAdded, linesRemoved }; +} + +export function formatFileCountLabel( + stagedOnly: boolean, + stagedFileCount: number, + totalFileCount: number, +): string { + if (stagedOnly) { + return `${stagedFileCount} staged file${stagedFileCount === 1 ? "" : "s"}`; + } + return `${totalFileCount} file${totalFileCount === 1 ? "" : "s"}`; } diff --git a/apps/code/src/renderer/features/git-interaction/utils/fileKey.ts b/apps/code/src/renderer/features/git-interaction/utils/fileKey.ts new file mode 100644 index 000000000..3e419bbc6 --- /dev/null +++ b/apps/code/src/renderer/features/git-interaction/utils/fileKey.ts @@ -0,0 +1,3 @@ +export function makeFileKey(staged: boolean | undefined, path: string): string { + return `${staged ? "staged:" : "unstaged:"}${path}`; +} diff --git a/apps/code/src/renderer/features/git-interaction/utils/gitCacheKeys.ts b/apps/code/src/renderer/features/git-interaction/utils/gitCacheKeys.ts index b6fcfd62a..d3662af76 100644 --- a/apps/code/src/renderer/features/git-interaction/utils/gitCacheKeys.ts +++ b/apps/code/src/renderer/features/git-interaction/utils/gitCacheKeys.ts @@ -7,6 +7,8 @@ export function invalidateGitWorkingTreeQueries(repoPath: string) { trpc.git.getChangedFilesHead.queryFilter(input), ); queryClient.invalidateQueries(trpc.git.getDiffStats.queryFilter(input)); + queryClient.invalidateQueries(trpc.git.getDiffCached.pathFilter()); + queryClient.invalidateQueries(trpc.git.getDiffUnstaged.pathFilter()); } export function invalidateGitBranchQueries(repoPath: string) { diff --git a/apps/code/src/renderer/features/git-interaction/utils/partitionByStaged.ts b/apps/code/src/renderer/features/git-interaction/utils/partitionByStaged.ts new file mode 100644 index 000000000..58e5f55a7 --- /dev/null +++ b/apps/code/src/renderer/features/git-interaction/utils/partitionByStaged.ts @@ -0,0 +1,14 @@ +import type { ChangedFile } from "@shared/types"; + +export function partitionByStaged(files: ChangedFile[]): { + stagedFiles: ChangedFile[]; + unstagedFiles: ChangedFile[]; +} { + const stagedFiles: ChangedFile[] = []; + const unstagedFiles: ChangedFile[] = []; + for (const f of files) { + if (f.staged) stagedFiles.push(f); + else unstagedFiles.push(f); + } + return { stagedFiles, unstagedFiles }; +} diff --git a/apps/code/src/renderer/features/task-detail/components/ChangesPanel.tsx b/apps/code/src/renderer/features/task-detail/components/ChangesPanel.tsx index 844954f3a..cda266ac3 100644 --- a/apps/code/src/renderer/features/task-detail/components/ChangesPanel.tsx +++ b/apps/code/src/renderer/features/task-detail/components/ChangesPanel.tsx @@ -3,6 +3,9 @@ import { PanelMessage } from "@components/ui/PanelMessage"; import { Tooltip } from "@components/ui/Tooltip"; import { useExternalApps } from "@features/external-apps/hooks/useExternalApps"; import { useGitQueries } from "@features/git-interaction/hooks/useGitQueries"; +import { makeFileKey } from "@features/git-interaction/utils/fileKey"; +import { invalidateGitWorkingTreeQueries } from "@features/git-interaction/utils/gitCacheKeys"; +import { partitionByStaged } from "@features/git-interaction/utils/partitionByStaged"; import { updateGitCacheFromSnapshot } from "@features/git-interaction/utils/updateGitCache"; import { usePanelLayoutStore } from "@features/panels/store/panelLayoutStore"; import { useCwd } from "@features/sidebar/hooks/useCwd"; @@ -12,6 +15,8 @@ import { CodeIcon, CopyIcon, FilePlus, + MinusIcon, + PlusIcon, } from "@phosphor-icons/react"; import { Badge, @@ -34,7 +39,10 @@ import { ANALYTICS_EVENTS, type FileChangeType } from "@shared/types/analytics"; import { useQueryClient } from "@tanstack/react-query"; import { showMessageBox } from "@utils/dialog"; import { handleExternalAppAction } from "@utils/handleExternalAppAction"; -import { useState } from "react"; +import { logger } from "@utils/logger"; +import { Fragment, useMemo, useState } from "react"; + +const log = logger.scope("changes-panel"); interface ChangesPanelProps { taskId: string; @@ -48,6 +56,7 @@ interface ChangedFileItemProps { /** When provided, enables the hover toolbar (discard, open-with, context menu) */ repoPath?: string; mainRepoPath?: string; + onStageToggle?: (file: ChangedFile) => void; } function getDiscardInfo( @@ -88,12 +97,37 @@ function getDiscardInfo( } } +function CompactIconButton({ + tooltip, + onClick, + children, +}: { + tooltip: string; + onClick: (e: React.MouseEvent) => void; + children: React.ReactNode; +}) { + return ( + + + {children} + + + ); +} + function ChangedFileItem({ file, taskId, isActive, repoPath, mainRepoPath, + onStageToggle, }: ChangedFileItemProps) { const openReview = usePanelLayoutStore((state) => state.openReview); const requestScrollToFile = useReviewNavigationStore( @@ -113,13 +147,15 @@ function ChangedFileItem({ const fullPath = repoPath ? `${repoPath}/${file.path}` : file.path; const indicator = getStatusIndicator(file.status); + const fileKey = makeFileKey(file.staged, file.path); + const handleClick = () => { track(ANALYTICS_EVENTS.FILE_DIFF_VIEWED, { change_type: file.status as FileChangeType, file_extension: getFileExtension(file.path), task_id: taskId, }); - requestScrollToFile(taskId, file.path); + requestScrollToFile(taskId, fileKey); openReview(taskId); }; @@ -279,26 +315,28 @@ function ChangedFileItem({ )} - {isToolbarVisible && handleDiscard && ( + {isToolbarVisible && (handleDiscard || onStageToggle) && ( - - { + e.preventDefault(); + e.stopPropagation(); + onStageToggle(file); }} + > + {file.staged ? : } + + )} + {handleDiscard && ( + - - + + )} s.activeFilePaths[taskId] ?? null, ); const { changedFiles, changesLoading: isLoading } = useGitQueries(repoPath); + const { stagedFiles, unstagedFiles } = useMemo( + () => partitionByStaged(changedFiles), + [changedFiles], + ); + + const hasStagedFiles = stagedFiles.length > 0; + + const handleStageToggle = async (file: ChangedFile) => { + if (!repoPath) return; + const paths = [file.originalPath ?? file.path]; + const endpoint = file.staged + ? trpcClient.git.unstageFiles + : trpcClient.git.stageFiles; + try { + const result = await endpoint.mutate({ directoryPath: repoPath, paths }); + updateGitCacheFromSnapshot(queryClient, repoPath, result); + invalidateGitWorkingTreeQueries(repoPath); + } catch (error) { + log.error("Failed to toggle staging", { file: file.path, error }); + } + }; + if (!repoPath) { return No repository path available; } @@ -500,18 +561,40 @@ function LocalChangesPanel({ taskId, task: _task }: ChangesPanelProps) { ); } + const fileGroups: { files: ChangedFile[]; header?: string }[] = hasStagedFiles + ? [ + { files: stagedFiles, header: "Staged Changes" }, + { files: unstagedFiles, header: "Changes" }, + ] + : [{ files: changedFiles }]; + return ( - {changedFiles.map((file) => ( - + {fileGroups.map(({ files, header }) => ( + + {header && ( + + + {header} ({files.length}) + + + )} + {files.map((file) => { + const key = makeFileKey(file.staged, file.path); + return ( + + ); + })} + ))} diff --git a/apps/code/src/shared/types.ts b/apps/code/src/shared/types.ts index e0bce5e0f..87a9f3619 100644 --- a/apps/code/src/shared/types.ts +++ b/apps/code/src/shared/types.ts @@ -138,6 +138,7 @@ export interface ChangedFile { originalPath?: string; // For renames: the old path linesAdded?: number; linesRemoved?: number; + staged?: boolean; } // External apps detection types diff --git a/apps/code/src/shared/types/analytics.ts b/apps/code/src/shared/types/analytics.ts index 20e3e15cd..c4aa8d2ad 100644 --- a/apps/code/src/shared/types/analytics.ts +++ b/apps/code/src/shared/types/analytics.ts @@ -96,6 +96,14 @@ export interface GitActionExecutedProperties { action_type: GitActionType; success: boolean; task_id?: string; + /** Number of staged files at time of action */ + staged_file_count?: number; + /** Number of unstaged files at time of action */ + unstaged_file_count?: number; + /** Whether user chose to commit all changes (vs staged only) */ + commit_all?: boolean; + /** Whether stagedOnly mode was used for the commit */ + staged_only?: boolean; } export interface PrCreatedProperties { diff --git a/packages/git/src/queries.ts b/packages/git/src/queries.ts index c66ae82e1..c7b4ac43f 100644 --- a/packages/git/src/queries.ts +++ b/packages/git/src/queries.ts @@ -389,6 +389,7 @@ export interface ChangedFileInfo { originalPath?: string; linesAdded?: number; linesRemoved?: number; + staged?: boolean; } export interface GetChangedFilesDetailedOptions extends CreateGitClientOptions { @@ -428,23 +429,27 @@ export async function getChangedFilesDetailed( baseDir, async (git) => { try { - const [diffSummary, status] = await Promise.all([ - git.diffSummary(["-M", "HEAD"]), + const [stagedSummary, unstagedSummary, status] = await Promise.all([ + git.diffSummary(["--cached", "-M", "HEAD"]), + git.diffSummary(["-M"]), git.status(["--untracked-files=all"]), ]); - const seenPaths = new Set(); + const diffSeenPaths = new Set(); + const excludedPaths = new Set(); const files: ChangedFileInfo[] = []; - for (const file of diffSummary.files) { + const pushDiffFile = ( + file: (typeof stagedSummary.files)[number], + staged: boolean, + ) => { if ( excludePatterns && matchesExcludePattern(file.file, excludePatterns) ) { - seenPaths.add(file.file); - continue; + excludedPaths.add(file.file); + return; } - const hasFrom = "from" in file && file.from; const isBinary = file.binary; files.push({ @@ -463,80 +468,35 @@ export async function getChangedFilesDetailed( linesRemoved: isBinary ? undefined : (file as { deletions: number }).deletions, + staged, }); - seenPaths.add(file.file); - if (hasFrom) seenPaths.add(file.from as string); + diffSeenPaths.add(file.file); + if (hasFrom) diffSeenPaths.add(file.from as string); + }; + + for (const file of stagedSummary.files) { + pushDiffFile(file, true); + } + for (const file of unstagedSummary.files) { + pushDiffFile(file, false); } const MAX_UNTRACKED_FILES = 10_000; let untrackedProcessed = 0; for (const file of status.not_added) { if (untrackedProcessed >= MAX_UNTRACKED_FILES) break; - if (!seenPaths.has(file)) { - if ( - excludePatterns && - matchesExcludePattern(file, excludePatterns) - ) { - continue; - } - const lineCount = await countFileLines(path.join(baseDir, file)); - files.push({ - path: file, - status: "untracked", - linesAdded: lineCount, - linesRemoved: 0, - }); - untrackedProcessed++; - } - } - - for (const file of status.modified) { - if (!seenPaths.has(file)) { - if ( - excludePatterns && - matchesExcludePattern(file, excludePatterns) - ) { - continue; - } - try { - const headDiff = await git.diff(["HEAD", "--", file]); - if (!headDiff.trim()) continue; - const lines = headDiff.split("\n"); - const linesAdded = lines.filter( - (l) => l.startsWith("+") && !l.startsWith("+++"), - ).length; - const linesRemoved = lines.filter( - (l) => l.startsWith("-") && !l.startsWith("---"), - ).length; - files.push({ - path: file, - status: "modified", - linesAdded, - linesRemoved, - }); - } catch {} - } - } - - for (const file of status.deleted) { - if (!seenPaths.has(file)) { - if ( - excludePatterns && - matchesExcludePattern(file, excludePatterns) - ) { - continue; - } - try { - const headDiff = await git.diff(["HEAD", "--", file]); - if (!headDiff.trim()) continue; - } catch {} - files.push({ - path: file, - status: "deleted", - linesAdded: 0, - linesRemoved: 0, - }); + if (diffSeenPaths.has(file) || excludedPaths.has(file)) continue; + if (excludePatterns && matchesExcludePattern(file, excludePatterns)) { + continue; } + const lineCount = await countFileLines(path.join(baseDir, file)); + files.push({ + path: file, + status: "untracked", + linesAdded: lineCount, + linesRemoved: 0, + }); + untrackedProcessed++; } return files; @@ -634,14 +594,16 @@ export interface GetDiffStatsOptions extends CreateGitClientOptions { export function computeDiffStatsFromFiles(files: ChangedFileInfo[]): DiffStats { let linesAdded = 0; let linesRemoved = 0; + const uniquePaths = new Set(); for (const file of files) { linesAdded += file.linesAdded ?? 0; linesRemoved += file.linesRemoved ?? 0; + uniquePaths.add(file.path); } return { - filesChanged: files.length, + filesChanged: uniquePaths.size, linesAdded, linesRemoved, }; @@ -922,20 +884,24 @@ export async function hasTrackedFiles( export async function getStagedDiff( baseDir: string, - options?: CreateGitClientOptions, + options?: CreateGitClientOptions & { ignoreWhitespace?: boolean }, ): Promise { const manager = getGitOperationManager(); - return manager.executeRead(baseDir, (git) => git.diff(["--cached", "HEAD"]), { + const args = ["--cached", "HEAD"]; + if (options?.ignoreWhitespace) args.push("-w"); + return manager.executeRead(baseDir, (git) => git.diff(args), { signal: options?.abortSignal, }); } export async function getUnstagedDiff( baseDir: string, - options?: CreateGitClientOptions, + options?: CreateGitClientOptions & { ignoreWhitespace?: boolean }, ): Promise { const manager = getGitOperationManager(); - return manager.executeRead(baseDir, (git) => git.diff(), { + const args: string[] = []; + if (options?.ignoreWhitespace) args.push("-w"); + return manager.executeRead(baseDir, (git) => git.diff(args), { signal: options?.abortSignal, }); } @@ -952,6 +918,30 @@ export async function getDiffHead( }); } +export async function stageFiles( + baseDir: string, + paths: string[], + options?: CreateGitClientOptions, +): Promise { + const manager = getGitOperationManager(); + await manager.executeWrite(baseDir, (git) => git.add(paths), { + signal: options?.abortSignal, + }); +} + +export async function unstageFiles( + baseDir: string, + paths: string[], + options?: CreateGitClientOptions, +): Promise { + const manager = getGitOperationManager(); + await manager.executeWrite( + baseDir, + (git) => git.reset(["HEAD", "--", ...paths]), + { signal: options?.abortSignal }, + ); +} + export async function getDiffAgainstRemote( baseDir: string, baseBranch: string, diff --git a/packages/git/src/sagas/commit.ts b/packages/git/src/sagas/commit.ts index 944fb300a..7dac9c573 100644 --- a/packages/git/src/sagas/commit.ts +++ b/packages/git/src/sagas/commit.ts @@ -4,6 +4,7 @@ export interface CommitInput extends GitSagaInput { message: string; paths?: string[]; allowEmpty?: boolean; + stagedOnly?: boolean; } export interface CommitOutput { @@ -18,7 +19,7 @@ export class CommitSaga extends GitSaga { protected async executeGitOperations( input: CommitInput, ): Promise { - const { message, paths, allowEmpty } = input; + const { message, paths, allowEmpty, stagedOnly } = input; const originalHead = await this.readOnlyStep("get-original-head", () => this.git.revparse(["HEAD"]), @@ -28,25 +29,38 @@ export class CommitSaga extends GitSaga { this.git.revparse(["--abbrev-ref", "HEAD"]), ); - this.previouslyStagedFiles = await this.readOnlyStep( - "get-staged-files", - async () => { - const status = await this.git.status(); - return status.staged; - }, - ); - - await this.step({ - name: "stage-files", - execute: () => - paths && paths.length > 0 ? this.git.add(paths) : this.git.add("-A"), - rollback: async () => { - await this.git.reset(); - if (this.previouslyStagedFiles.length > 0) { - await this.git.add(this.previouslyStagedFiles); - } - }, - }); + if (stagedOnly) { + const stagedCheck = await this.readOnlyStep( + "verify-staged-files", + async () => { + const status = await this.git.status(); + return status.staged; + }, + ); + if (stagedCheck.length === 0) { + throw new Error("No staged changes to commit."); + } + } else { + this.previouslyStagedFiles = await this.readOnlyStep( + "get-staged-files", + async () => { + const status = await this.git.status(); + return status.staged; + }, + ); + + await this.step({ + name: "stage-files", + execute: () => + paths && paths.length > 0 ? this.git.add(paths) : this.git.add("-A"), + rollback: async () => { + await this.git.reset(); + if (this.previouslyStagedFiles.length > 0) { + await this.git.add(this.previouslyStagedFiles); + } + }, + }); + } const commitResult = await this.step({ name: "commit",