From 1174bc124f6e2f766d2a622f03d5f2f3cb9d9303 Mon Sep 17 00:00:00 2001 From: Guilherme Vieira Date: Wed, 11 Mar 2026 00:04:28 +0000 Subject: [PATCH] feat: add selective file commit to commit dialog Add checkboxes to the commit dialog file list so users can include/exclude specific files from a commit. When some files are unchecked, only the selected paths are staged via `git add -A -- ` instead of `git add -A`. All files are checked by default to preserve existing behavior. - Add optional `filePaths` field to `GitRunStackedActionInput` contract - Update `prepareCommitContext` to accept and use selective file paths - Thread `filePaths` through manager, mutation, and UI action handlers - Add select-all/none checkbox header and per-file checkboxes in dialog - Disable commit buttons when no files are selected - Update totals to reflect selected files only - Add integration tests for selective staging in GitCore and GitManager --- apps/server/src/git/Layers/GitCore.test.ts | 41 ++++++- apps/server/src/git/Layers/GitCore.ts | 16 ++- apps/server/src/git/Layers/GitManager.test.ts | 26 +++++ apps/server/src/git/Layers/GitManager.ts | 15 ++- apps/server/src/git/Services/GitCore.ts | 1 + apps/web/src/components/GitActionsControl.tsx | 108 ++++++++++++++---- apps/web/src/lib/gitReactQuery.ts | 3 + packages/contracts/src/git.ts | 3 + 8 files changed, 187 insertions(+), 26 deletions(-) diff --git a/apps/server/src/git/Layers/GitCore.test.ts b/apps/server/src/git/Layers/GitCore.test.ts index 379c44472..6c98229e8 100644 --- a/apps/server/src/git/Layers/GitCore.test.ts +++ b/apps/server/src/git/Layers/GitCore.test.ts @@ -97,7 +97,7 @@ const makeIsolatedGitCore = (gitService: GitServiceShape) => return { status: (input) => core.status(input), statusDetails: (cwd) => core.statusDetails(cwd), - prepareCommitContext: (cwd) => core.prepareCommitContext(cwd), + prepareCommitContext: (cwd, filePaths?) => core.prepareCommitContext(cwd, filePaths), commit: (cwd, subject, body) => core.commit(cwd, subject, body), pushCurrentBranch: (cwd, fallbackBranch) => core.pushCurrentBranch(cwd, fallbackBranch), pullCurrentBranch: (cwd) => core.pullCurrentBranch(cwd), @@ -1711,6 +1711,45 @@ it.layer(TestLayer)("git integration", (it) => { }), ); + it.effect("prepareCommitContext stages only selected files when filePaths provided", () => + Effect.gen(function* () { + const tmp = yield* makeTmpDir(); + yield* initRepoWithCommit(tmp); + const core = yield* GitCore; + + yield* writeTextFile(path.join(tmp, "a.txt"), "file a\n"); + yield* writeTextFile(path.join(tmp, "b.txt"), "file b\n"); + + const context = yield* core.prepareCommitContext(tmp, ["a.txt"]); + expect(context).not.toBeNull(); + expect(context!.stagedSummary).toContain("a.txt"); + expect(context!.stagedSummary).not.toContain("b.txt"); + + yield* core.commit(tmp, "Add only a.txt", ""); + + // b.txt should still be untracked after commit + const statusAfter = yield* git(tmp, ["status", "--porcelain"]); + expect(statusAfter).toContain("b.txt"); + expect(statusAfter).not.toContain("a.txt"); + }), + ); + + it.effect("prepareCommitContext stages everything when filePaths is undefined", () => + Effect.gen(function* () { + const tmp = yield* makeTmpDir(); + yield* initRepoWithCommit(tmp); + const core = yield* GitCore; + + yield* writeTextFile(path.join(tmp, "a.txt"), "file a\n"); + yield* writeTextFile(path.join(tmp, "b.txt"), "file b\n"); + + const context = yield* core.prepareCommitContext(tmp); + expect(context).not.toBeNull(); + expect(context!.stagedSummary).toContain("a.txt"); + expect(context!.stagedSummary).toContain("b.txt"); + }), + ); + it.effect("pushes with upstream setup and then skips when up to date", () => Effect.gen(function* () { const tmp = yield* makeTmpDir(); diff --git a/apps/server/src/git/Layers/GitCore.ts b/apps/server/src/git/Layers/GitCore.ts index 3fa0ef1f0..f5b9168ab 100644 --- a/apps/server/src/git/Layers/GitCore.ts +++ b/apps/server/src/git/Layers/GitCore.ts @@ -766,9 +766,21 @@ const makeGitCore = Effect.gen(function* () { })), ); - const prepareCommitContext: GitCoreShape["prepareCommitContext"] = (cwd) => + const prepareCommitContext: GitCoreShape["prepareCommitContext"] = (cwd, filePaths) => Effect.gen(function* () { - yield* runGit("GitCore.prepareCommitContext.addAll", cwd, ["add", "-A"]); + if (filePaths && filePaths.length > 0) { + yield* runGit("GitCore.prepareCommitContext.reset", cwd, ["reset"]).pipe( + Effect.catch(() => Effect.void), + ); + yield* runGit("GitCore.prepareCommitContext.addSelected", cwd, [ + "add", + "-A", + "--", + ...filePaths, + ]); + } else { + yield* runGit("GitCore.prepareCommitContext.addAll", cwd, ["add", "-A"]); + } const stagedSummary = yield* runGitStdout("GitCore.prepareCommitContext.stagedSummary", cwd, [ "diff", diff --git a/apps/server/src/git/Layers/GitManager.test.ts b/apps/server/src/git/Layers/GitManager.test.ts index cc80eda23..8c72941cd 100644 --- a/apps/server/src/git/Layers/GitManager.test.ts +++ b/apps/server/src/git/Layers/GitManager.test.ts @@ -451,6 +451,7 @@ function runStackedAction( action: "commit" | "commit_push" | "commit_push_pr"; commitMessage?: string; featureBranch?: boolean; + filePaths?: readonly string[]; }, ) { return manager.runStackedAction(input); @@ -767,6 +768,31 @@ it.layer(GitManagerTestLayer)("GitManager", (it) => { }), ); + it.effect("commits only selected files when filePaths is provided", () => + Effect.gen(function* () { + const repoDir = yield* makeTempDir("t3code-git-manager-"); + yield* initRepo(repoDir); + fs.writeFileSync(path.join(repoDir, "a.txt"), "file a\n"); + fs.writeFileSync(path.join(repoDir, "b.txt"), "file b\n"); + + const { manager } = yield* makeManager(); + const result = yield* runStackedAction(manager, { + cwd: repoDir, + action: "commit", + filePaths: ["a.txt"], + }); + + expect(result.commit.status).toBe("created"); + + // b.txt should remain in the working tree + const statusStdout = yield* runGit(repoDir, ["status", "--porcelain"]).pipe( + Effect.map((r) => r.stdout), + ); + expect(statusStdout).toContain("b.txt"); + expect(statusStdout).not.toContain("a.txt"); + }), + ); + it.effect("creates feature branch, commits, and pushes with featureBranch option", () => Effect.gen(function* () { const repoDir = yield* makeTempDir("t3code-git-manager-"); diff --git a/apps/server/src/git/Layers/GitManager.ts b/apps/server/src/git/Layers/GitManager.ts index 97760e2d3..835779517 100644 --- a/apps/server/src/git/Layers/GitManager.ts +++ b/apps/server/src/git/Layers/GitManager.ts @@ -638,9 +638,10 @@ export const makeGitManager = Effect.gen(function* () { commitMessage?: string; /** When true, also produce a semantic feature branch name. */ includeBranch?: boolean; + filePaths?: readonly string[]; }) => Effect.gen(function* () { - const context = yield* gitCore.prepareCommitContext(input.cwd); + const context = yield* gitCore.prepareCommitContext(input.cwd, input.filePaths); if (!context) { return null; } @@ -680,6 +681,7 @@ export const makeGitManager = Effect.gen(function* () { branch: string | null, commitMessage?: string, preResolvedSuggestion?: CommitAndBranchSuggestion, + filePaths?: readonly string[], ) => Effect.gen(function* () { const suggestion = @@ -688,6 +690,7 @@ export const makeGitManager = Effect.gen(function* () { cwd, branch, ...(commitMessage ? { commitMessage } : {}), + ...(filePaths ? { filePaths } : {}), })); if (!suggestion) { return { status: "skipped_no_changes" as const }; @@ -964,12 +967,18 @@ export const makeGitManager = Effect.gen(function* () { }, ); - const runFeatureBranchStep = (cwd: string, branch: string | null, commitMessage?: string) => + const runFeatureBranchStep = ( + cwd: string, + branch: string | null, + commitMessage?: string, + filePaths?: readonly string[], + ) => Effect.gen(function* () { const suggestion = yield* resolveCommitAndBranchSuggestion({ cwd, branch, ...(commitMessage ? { commitMessage } : {}), + ...(filePaths ? { filePaths } : {}), includeBranch: true, }); if (!suggestion) { @@ -1018,6 +1027,7 @@ export const makeGitManager = Effect.gen(function* () { input.cwd, initialStatus.branch, input.commitMessage, + input.filePaths, ); branchStep = result.branchStep; commitMessageForStep = result.resolvedCommitMessage; @@ -1033,6 +1043,7 @@ export const makeGitManager = Effect.gen(function* () { currentBranch, commitMessageForStep, preResolvedCommitSuggestion, + input.filePaths, ); const push = wantsPush diff --git a/apps/server/src/git/Services/GitCore.ts b/apps/server/src/git/Services/GitCore.ts index 502ac349d..879927934 100644 --- a/apps/server/src/git/Services/GitCore.ts +++ b/apps/server/src/git/Services/GitCore.ts @@ -101,6 +101,7 @@ export interface GitCoreShape { */ readonly prepareCommitContext: ( cwd: string, + filePaths?: readonly string[], ) => Effect.Effect; /** diff --git a/apps/web/src/components/GitActionsControl.tsx b/apps/web/src/components/GitActionsControl.tsx index 379fb5a84..7bdc34157 100644 --- a/apps/web/src/components/GitActionsControl.tsx +++ b/apps/web/src/components/GitActionsControl.tsx @@ -16,6 +16,7 @@ import { summarizeGitResult, } from "./GitActionsControl.logic"; import { Button } from "~/components/ui/button"; +import { Checkbox } from "~/components/ui/checkbox"; import { Dialog, DialogDescription, @@ -55,6 +56,7 @@ interface PendingDefaultBranchAction { commitMessage?: string; forcePushOnlyProgress: boolean; onConfirmed?: () => void; + filePaths?: string[]; } type GitActionToastId = ReturnType; @@ -158,6 +160,7 @@ export default function GitActionsControl({ gitCwd, activeThreadId }: GitActions const queryClient = useQueryClient(); const [isCommitDialogOpen, setIsCommitDialogOpen] = useState(false); const [dialogCommitMessage, setDialogCommitMessage] = useState(""); + const [excludedFiles, setExcludedFiles] = useState>(new Set()); const [pendingDefaultBranchAction, setPendingDefaultBranchAction] = useState(null); @@ -178,6 +181,11 @@ export default function GitActionsControl({ gitCwd, activeThreadId }: GitActions const gitStatusForActions = isGitStatusOutOfSync ? null : gitStatus; + const allFiles = gitStatusForActions?.workingTree.files ?? []; + const selectedFiles = allFiles.filter((f) => !excludedFiles.has(f.path)); + const allSelected = excludedFiles.size === 0; + const noneSelected = selectedFiles.length === 0; + const initMutation = useMutation(gitInitMutationOptions({ cwd: gitCwd, queryClient })); const runImmediateGitActionMutation = useMutation( @@ -256,6 +264,7 @@ export default function GitActionsControl({ gitCwd, activeThreadId }: GitActions featureBranch = false, isDefaultBranchOverride, progressToastId, + filePaths, }: { action: GitStackedAction; commitMessage?: string; @@ -266,6 +275,7 @@ export default function GitActionsControl({ gitCwd, activeThreadId }: GitActions featureBranch?: boolean; isDefaultBranchOverride?: boolean; progressToastId?: GitActionToastId; + filePaths?: string[]; }) => { const actionStatus = statusOverride ?? gitStatusForActions; const actionBranch = actionStatus?.branch ?? null; @@ -288,6 +298,7 @@ export default function GitActionsControl({ gitCwd, activeThreadId }: GitActions ...(commitMessage ? { commitMessage } : {}), forcePushOnlyProgress, ...(onConfirmed ? { onConfirmed } : {}), + ...(filePaths ? { filePaths } : {}), }); return; } @@ -337,6 +348,7 @@ export default function GitActionsControl({ gitCwd, activeThreadId }: GitActions action, ...(commitMessage ? { commitMessage } : {}), ...(featureBranch ? { featureBranch } : {}), + ...(filePaths ? { filePaths } : {}), }); try { @@ -438,7 +450,7 @@ export default function GitActionsControl({ gitCwd, activeThreadId }: GitActions const continuePendingDefaultBranchAction = useCallback(() => { if (!pendingDefaultBranchAction) return; - const { action, commitMessage, forcePushOnlyProgress, onConfirmed } = + const { action, commitMessage, forcePushOnlyProgress, onConfirmed, filePaths } = pendingDefaultBranchAction; setPendingDefaultBranchAction(null); void runGitActionWithToast({ @@ -446,6 +458,7 @@ export default function GitActionsControl({ gitCwd, activeThreadId }: GitActions ...(commitMessage ? { commitMessage } : {}), forcePushOnlyProgress, ...(onConfirmed ? { onConfirmed } : {}), + ...(filePaths ? { filePaths } : {}), skipDefaultBranchPrompt: true, }); }, [pendingDefaultBranchAction, runGitActionWithToast]); @@ -456,6 +469,7 @@ export default function GitActionsControl({ gitCwd, activeThreadId }: GitActions commitMessage?: string; forcePushOnlyProgress?: boolean; onConfirmed?: () => void; + filePaths?: string[]; }) => { void runGitActionWithToast({ ...actionParams, @@ -468,7 +482,7 @@ export default function GitActionsControl({ gitCwd, activeThreadId }: GitActions const checkoutFeatureBranchAndContinuePendingAction = useCallback(() => { if (!pendingDefaultBranchAction) return; - const { action, commitMessage, forcePushOnlyProgress, onConfirmed } = + const { action, commitMessage, forcePushOnlyProgress, onConfirmed, filePaths } = pendingDefaultBranchAction; setPendingDefaultBranchAction(null); checkoutNewBranchAndRunAction({ @@ -476,6 +490,7 @@ export default function GitActionsControl({ gitCwd, activeThreadId }: GitActions ...(commitMessage ? { commitMessage } : {}), forcePushOnlyProgress, ...(onConfirmed ? { onConfirmed } : {}), + ...(filePaths ? { filePaths } : {}), }); }, [pendingDefaultBranchAction, checkoutNewBranchAndRunAction]); @@ -485,12 +500,20 @@ export default function GitActionsControl({ gitCwd, activeThreadId }: GitActions setIsCommitDialogOpen(false); setDialogCommitMessage(""); + setExcludedFiles(new Set()); checkoutNewBranchAndRunAction({ action: "commit", ...(commitMessage ? { commitMessage } : {}), + ...(!allSelected ? { filePaths: selectedFiles.map((f) => f.path) } : {}), }); - }, [isCommitDialogOpen, dialogCommitMessage, checkoutNewBranchAndRunAction]); + }, [ + allSelected, + isCommitDialogOpen, + dialogCommitMessage, + checkoutNewBranchAndRunAction, + selectedFiles, + ]); const runQuickAction = useCallback(() => { if (quickAction.kind === "open_pr") { @@ -547,6 +570,7 @@ export default function GitActionsControl({ gitCwd, activeThreadId }: GitActions void runGitActionWithToast({ action: "commit_push_pr" }); return; } + setExcludedFiles(new Set()); setIsCommitDialogOpen(true); }, [openExistingPr, runGitActionWithToast, setIsCommitDialogOpen], @@ -557,14 +581,18 @@ export default function GitActionsControl({ gitCwd, activeThreadId }: GitActions const commitMessage = dialogCommitMessage.trim(); setIsCommitDialogOpen(false); setDialogCommitMessage(""); + setExcludedFiles(new Set()); void runGitActionWithToast({ action: "commit", ...(commitMessage ? { commitMessage } : {}), + ...(!allSelected ? { filePaths: selectedFiles.map((f) => f.path) } : {}), }); }, [ + allSelected, dialogCommitMessage, isCommitDialogOpen, runGitActionWithToast, + selectedFiles, setDialogCommitMessage, setIsCommitDialogOpen, ]); @@ -729,6 +757,7 @@ export default function GitActionsControl({ gitCwd, activeThreadId }: GitActions if (!open) { setIsCommitDialogOpen(false); setDialogCommitMessage(""); + setExcludedFiles(new Set()); } }} > @@ -751,37 +780,68 @@ export default function GitActionsControl({ gitCwd, activeThreadId }: GitActions
-

Files

- {!gitStatusForActions || gitStatusForActions.workingTree.files.length === 0 ? ( +
+ {allFiles.length > 0 && ( + { + setExcludedFiles( + allSelected ? new Set(allFiles.map((f) => f.path)) : new Set(), + ); + }} + /> + )} + Files +
+ {!gitStatusForActions || allFiles.length === 0 ? (

none

) : (
- {gitStatusForActions.workingTree.files.map((file) => ( - + { + setExcludedFiles((prev) => { + const next = new Set(prev); + if (next.has(file.path)) { + next.delete(file.path); + } else { + next.add(file.path); + } + return next; + }); + }} + /> + +
))}
- +{gitStatusForActions.workingTree.insertions} + +{selectedFiles.reduce((sum, f) => sum + f.insertions, 0)} / - -{gitStatusForActions.workingTree.deletions} + -{selectedFiles.reduce((sum, f) => sum + f.deletions, 0)}
@@ -805,14 +865,20 @@ export default function GitActionsControl({ gitCwd, activeThreadId }: GitActions onClick={() => { setIsCommitDialogOpen(false); setDialogCommitMessage(""); + setExcludedFiles(new Set()); }} > Cancel - - diff --git a/apps/web/src/lib/gitReactQuery.ts b/apps/web/src/lib/gitReactQuery.ts index fe0c4705b..9b5fe7731 100644 --- a/apps/web/src/lib/gitReactQuery.ts +++ b/apps/web/src/lib/gitReactQuery.ts @@ -119,10 +119,12 @@ export function gitRunStackedActionMutationOptions(input: { action, commitMessage, featureBranch, + filePaths, }: { action: GitStackedAction; commitMessage?: string; featureBranch?: boolean; + filePaths?: string[]; }) => { const api = ensureNativeApi(); if (!input.cwd) throw new Error("Git action is unavailable."); @@ -131,6 +133,7 @@ export function gitRunStackedActionMutationOptions(input: { action, ...(commitMessage ? { commitMessage } : {}), ...(featureBranch ? { featureBranch } : {}), + ...(filePaths ? { filePaths } : {}), }); }, onSettled: async () => { diff --git a/packages/contracts/src/git.ts b/packages/contracts/src/git.ts index 34ab11b16..081b4d0d8 100644 --- a/packages/contracts/src/git.ts +++ b/packages/contracts/src/git.ts @@ -61,6 +61,9 @@ export const GitRunStackedActionInput = Schema.Struct({ action: GitStackedAction, commitMessage: Schema.optional(TrimmedNonEmptyStringSchema.check(Schema.isMaxLength(10_000))), featureBranch: Schema.optional(Schema.Boolean), + filePaths: Schema.optional( + Schema.Array(TrimmedNonEmptyStringSchema).check(Schema.isMinLength(1)), + ), }); export type GitRunStackedActionInput = typeof GitRunStackedActionInput.Type;