authorgravatar for 46866023+GuilhermeVieiraDev@users.noreply.github.comGuilherme Vieira <46866023+GuilhermeVieiraDev@users.noreply.github.com> 2026-03-12 17:50:49+00:00
committergravatar for noreply@github.comGitHub <noreply@github.com> 2026-03-12 10:50:49-07:00
logc3bfcc36b870adbdb01552a7c6b9313a836d7d75
tree1c7afc32f83162599d1d32e8c58d832a39e027d8
parenta33cc8c75874bf89050ae90c58c5b6227debd4e2
signature Signed by PGP key B5690EEEBB952194

feat: add selective file staging to commit dialog (#872)

Co-authored-by: Julius Marminge <julius0216@outlook.com>

8 files changed, 227 insertions(+), 29 deletions(-)

apps/server/src/git/Layers/GitCore.test.ts+40-1
......@@ -97,7 +97,7 @@ const makeIsolatedGitCore = (gitService: GitServiceShape) =>
9797 return {
9898 status: (input) => core.status(input),
9999 statusDetails: (cwd) => core.statusDetails(cwd),
100 prepareCommitContext: (cwd) => core.prepareCommitContext(cwd),
100 prepareCommitContext: (cwd, filePaths?) => core.prepareCommitContext(cwd, filePaths),
101101 commit: (cwd, subject, body) => core.commit(cwd, subject, body),
102102 pushCurrentBranch: (cwd, fallbackBranch) => core.pushCurrentBranch(cwd, fallbackBranch),
103103 pullCurrentBranch: (cwd) => core.pullCurrentBranch(cwd),
......@@ -1711,6 +1711,45 @@ it.layer(TestLayer)("git integration", (it) => {
17111711 }),
17121712 );
17131713
1714 it.effect("prepareCommitContext stages only selected files when filePaths provided", () =>
1715 Effect.gen(function* () {
1716 const tmp = yield* makeTmpDir();
1717 yield* initRepoWithCommit(tmp);
1718 const core = yield* GitCore;
1719
1720 yield* writeTextFile(path.join(tmp, "a.txt"), "file a\n");
1721 yield* writeTextFile(path.join(tmp, "b.txt"), "file b\n");
1722
1723 const context = yield* core.prepareCommitContext(tmp, ["a.txt"]);
1724 expect(context).not.toBeNull();
1725 expect(context!.stagedSummary).toContain("a.txt");
1726 expect(context!.stagedSummary).not.toContain("b.txt");
1727
1728 yield* core.commit(tmp, "Add only a.txt", "");
1729
1730 // b.txt should still be untracked after commit
1731 const statusAfter = yield* git(tmp, ["status", "--porcelain"]);
1732 expect(statusAfter).toContain("b.txt");
1733 expect(statusAfter).not.toContain("a.txt");
1734 }),
1735 );
1736
1737 it.effect("prepareCommitContext stages everything when filePaths is undefined", () =>
1738 Effect.gen(function* () {
1739 const tmp = yield* makeTmpDir();
1740 yield* initRepoWithCommit(tmp);
1741 const core = yield* GitCore;
1742
1743 yield* writeTextFile(path.join(tmp, "a.txt"), "file a\n");
1744 yield* writeTextFile(path.join(tmp, "b.txt"), "file b\n");
1745
1746 const context = yield* core.prepareCommitContext(tmp);
1747 expect(context).not.toBeNull();
1748 expect(context!.stagedSummary).toContain("a.txt");
1749 expect(context!.stagedSummary).toContain("b.txt");
1750 }),
1751 );
1752
17141753 it.effect("pushes with upstream setup and then skips when up to date", () =>
17151754 Effect.gen(function* () {
17161755 const tmp = yield* makeTmpDir();
apps/server/src/git/Layers/GitCore.ts+14-2
......@@ -766,9 +766,21 @@ const makeGitCore = Effect.gen(function* () {
766766 })),
767767 );
768768
769 const prepareCommitContext: GitCoreShape["prepareCommitContext"] = (cwd) =>
769 const prepareCommitContext: GitCoreShape["prepareCommitContext"] = (cwd, filePaths) =>
770770 Effect.gen(function* () {
771 yield* runGit("GitCore.prepareCommitContext.addAll", cwd, ["add", "-A"]);
771 if (filePaths && filePaths.length > 0) {
772 yield* runGit("GitCore.prepareCommitContext.reset", cwd, ["reset"]).pipe(
773 Effect.catch(() => Effect.void),
774 );
775 yield* runGit("GitCore.prepareCommitContext.addSelected", cwd, [
776 "add",
777 "-A",
778 "--",
779 ...filePaths,
780 ]);
781 } else {
782 yield* runGit("GitCore.prepareCommitContext.addAll", cwd, ["add", "-A"]);
783 }
772784
773785 const stagedSummary = yield* runGitStdout("GitCore.prepareCommitContext.stagedSummary", cwd, [
774786 "diff",
apps/server/src/git/Layers/GitManager.test.ts+26
......@@ -451,6 +451,7 @@ function runStackedAction(
451451 action: "commit" | "commit_push" | "commit_push_pr";
452452 commitMessage?: string;
453453 featureBranch?: boolean;
454 filePaths?: readonly string[];
454455 },
455456) {
456457 return manager.runStackedAction(input);
......@@ -767,6 +768,31 @@ it.layer(GitManagerTestLayer)("GitManager", (it) => {
767768 }),
768769 );
769770
771 it.effect("commits only selected files when filePaths is provided", () =>
772 Effect.gen(function* () {
773 const repoDir = yield* makeTempDir("t3code-git-manager-");
774 yield* initRepo(repoDir);
775 fs.writeFileSync(path.join(repoDir, "a.txt"), "file a\n");
776 fs.writeFileSync(path.join(repoDir, "b.txt"), "file b\n");
777
778 const { manager } = yield* makeManager();
779 const result = yield* runStackedAction(manager, {
780 cwd: repoDir,
781 action: "commit",
782 filePaths: ["a.txt"],
783 });
784
785 expect(result.commit.status).toBe("created");
786
787 // b.txt should remain in the working tree
788 const statusStdout = yield* runGit(repoDir, ["status", "--porcelain"]).pipe(
789 Effect.map((r) => r.stdout),
790 );
791 expect(statusStdout).toContain("b.txt");
792 expect(statusStdout).not.toContain("a.txt");
793 }),
794 );
795
770796 it.effect("creates feature branch, commits, and pushes with featureBranch option", () =>
771797 Effect.gen(function* () {
772798 const repoDir = yield* makeTempDir("t3code-git-manager-");
apps/server/src/git/Layers/GitManager.ts+13-2
......@@ -638,9 +638,10 @@ export const makeGitManager = Effect.gen(function* () {
638638 commitMessage?: string;
639639 /** When true, also produce a semantic feature branch name. */
640640 includeBranch?: boolean;
641 filePaths?: readonly string[];
641642 }) =>
642643 Effect.gen(function* () {
643 const context = yield* gitCore.prepareCommitContext(input.cwd);
644 const context = yield* gitCore.prepareCommitContext(input.cwd, input.filePaths);
644645 if (!context) {
645646 return null;
646647 }
......@@ -680,6 +681,7 @@ export const makeGitManager = Effect.gen(function* () {
680681 branch: string | null,
681682 commitMessage?: string,
682683 preResolvedSuggestion?: CommitAndBranchSuggestion,
684 filePaths?: readonly string[],
683685 ) =>
684686 Effect.gen(function* () {
685687 const suggestion =
......@@ -688,6 +690,7 @@ export const makeGitManager = Effect.gen(function* () {
688690 cwd,
689691 branch,
690692 ...(commitMessage ? { commitMessage } : {}),
693 ...(filePaths ? { filePaths } : {}),
691694 }));
692695 if (!suggestion) {
693696 return { status: "skipped_no_changes" as const };
......@@ -964,12 +967,18 @@ export const makeGitManager = Effect.gen(function* () {
964967 },
965968 );
966969
967 const runFeatureBranchStep = (cwd: string, branch: string | null, commitMessage?: string) =>
970 const runFeatureBranchStep = (
971 cwd: string,
972 branch: string | null,
973 commitMessage?: string,
974 filePaths?: readonly string[],
975 ) =>
968976 Effect.gen(function* () {
969977 const suggestion = yield* resolveCommitAndBranchSuggestion({
970978 cwd,
971979 branch,
972980 ...(commitMessage ? { commitMessage } : {}),
981 ...(filePaths ? { filePaths } : {}),
973982 includeBranch: true,
974983 });
975984 if (!suggestion) {
......@@ -1018,6 +1027,7 @@ export const makeGitManager = Effect.gen(function* () {
10181027 input.cwd,
10191028 initialStatus.branch,
10201029 input.commitMessage,
1030 input.filePaths,
10211031 );
10221032 branchStep = result.branchStep;
10231033 commitMessageForStep = result.resolvedCommitMessage;
......@@ -1033,6 +1043,7 @@ export const makeGitManager = Effect.gen(function* () {
10331043 currentBranch,
10341044 commitMessageForStep,
10351045 preResolvedCommitSuggestion,
1046 input.filePaths,
10361047 );
10371048
10381049 const push = wantsPush
apps/server/src/git/Services/GitCore.ts+1
......@@ -101,6 +101,7 @@ export interface GitCoreShape {
101101 */
102102 readonly prepareCommitContext: (
103103 cwd: string,
104 filePaths?: readonly string[],
104105 ) => Effect.Effect<GitPreparedCommitContext | null, GitCommandError>;
105106
106107 /**
apps/web/src/components/GitActionsControl.tsx+127-24
......@@ -16,6 +16,7 @@ import {
1616 summarizeGitResult,
1717} from "./GitActionsControl.logic";
1818import { Button } from "~/components/ui/button";
19import { Checkbox } from "~/components/ui/checkbox";
1920import {
2021 Dialog,
2122 DialogDescription,
......@@ -56,6 +57,7 @@ interface PendingDefaultBranchAction {
5657 commitMessage?: string;
5758 forcePushOnlyProgress: boolean;
5859 onConfirmed?: () => void;
60 filePaths?: string[];
5961}
6062
6163type GitActionToastId = ReturnType<typeof toastManager.add>;
......@@ -159,6 +161,8 @@ export default function GitActionsControl({ gitCwd, activeThreadId }: GitActions
159161 const queryClient = useQueryClient();
160162 const [isCommitDialogOpen, setIsCommitDialogOpen] = useState(false);
161163 const [dialogCommitMessage, setDialogCommitMessage] = useState("");
164 const [excludedFiles, setExcludedFiles] = useState<ReadonlySet<string>>(new Set());
165 const [isEditingFiles, setIsEditingFiles] = useState(false);
162166 const [pendingDefaultBranchAction, setPendingDefaultBranchAction] =
163167 useState<PendingDefaultBranchAction | null>(null);
164168
......@@ -179,6 +183,11 @@ export default function GitActionsControl({ gitCwd, activeThreadId }: GitActions
179183
180184 const gitStatusForActions = isGitStatusOutOfSync ? null : gitStatus;
181185
186 const allFiles = gitStatusForActions?.workingTree.files ?? [];
187 const selectedFiles = allFiles.filter((f) => !excludedFiles.has(f.path));
188 const allSelected = excludedFiles.size === 0;
189 const noneSelected = selectedFiles.length === 0;
190
182191 const initMutation = useMutation(gitInitMutationOptions({ cwd: gitCwd, queryClient }));
183192
184193 const runImmediateGitActionMutation = useMutation(
......@@ -257,6 +266,7 @@ export default function GitActionsControl({ gitCwd, activeThreadId }: GitActions
257266 featureBranch = false,
258267 isDefaultBranchOverride,
259268 progressToastId,
269 filePaths,
260270 }: {
261271 action: GitStackedAction;
262272 commitMessage?: string;
......@@ -267,6 +277,7 @@ export default function GitActionsControl({ gitCwd, activeThreadId }: GitActions
267277 featureBranch?: boolean;
268278 isDefaultBranchOverride?: boolean;
269279 progressToastId?: GitActionToastId;
280 filePaths?: string[];
270281 }) => {
271282 const actionStatus = statusOverride ?? gitStatusForActions;
272283 const actionBranch = actionStatus?.branch ?? null;
......@@ -289,6 +300,7 @@ export default function GitActionsControl({ gitCwd, activeThreadId }: GitActions
289300 ...(commitMessage ? { commitMessage } : {}),
290301 forcePushOnlyProgress,
291302 ...(onConfirmed ? { onConfirmed } : {}),
303 ...(filePaths ? { filePaths } : {}),
292304 });
293305 return;
294306 }
......@@ -338,6 +350,7 @@ export default function GitActionsControl({ gitCwd, activeThreadId }: GitActions
338350 action,
339351 ...(commitMessage ? { commitMessage } : {}),
340352 ...(featureBranch ? { featureBranch } : {}),
353 ...(filePaths ? { filePaths } : {}),
341354 });
342355
343356 try {
......@@ -439,7 +452,7 @@ export default function GitActionsControl({ gitCwd, activeThreadId }: GitActions
439452
440453 const continuePendingDefaultBranchAction = useCallback(() => {
441454 if (!pendingDefaultBranchAction) return;
442 const { action, commitMessage, forcePushOnlyProgress, onConfirmed } =
455 const { action, commitMessage, forcePushOnlyProgress, onConfirmed, filePaths } =
443456 pendingDefaultBranchAction;
444457 setPendingDefaultBranchAction(null);
445458 void runGitActionWithToast({
......@@ -447,6 +460,7 @@ export default function GitActionsControl({ gitCwd, activeThreadId }: GitActions
447460 ...(commitMessage ? { commitMessage } : {}),
448461 forcePushOnlyProgress,
449462 ...(onConfirmed ? { onConfirmed } : {}),
463 ...(filePaths ? { filePaths } : {}),
450464 skipDefaultBranchPrompt: true,
451465 });
452466 }, [pendingDefaultBranchAction, runGitActionWithToast]);
......@@ -457,6 +471,7 @@ export default function GitActionsControl({ gitCwd, activeThreadId }: GitActions
457471 commitMessage?: string;
458472 forcePushOnlyProgress?: boolean;
459473 onConfirmed?: () => void;
474 filePaths?: string[];
460475 }) => {
461476 void runGitActionWithToast({
462477 ...actionParams,
......@@ -469,7 +484,7 @@ export default function GitActionsControl({ gitCwd, activeThreadId }: GitActions
469484
470485 const checkoutFeatureBranchAndContinuePendingAction = useCallback(() => {
471486 if (!pendingDefaultBranchAction) return;
472 const { action, commitMessage, forcePushOnlyProgress, onConfirmed } =
487 const { action, commitMessage, forcePushOnlyProgress, onConfirmed, filePaths } =
473488 pendingDefaultBranchAction;
474489 setPendingDefaultBranchAction(null);
475490 checkoutNewBranchAndRunAction({
......@@ -477,6 +492,7 @@ export default function GitActionsControl({ gitCwd, activeThreadId }: GitActions
477492 ...(commitMessage ? { commitMessage } : {}),
478493 forcePushOnlyProgress,
479494 ...(onConfirmed ? { onConfirmed } : {}),
495 ...(filePaths ? { filePaths } : {}),
480496 });
481497 }, [pendingDefaultBranchAction, checkoutNewBranchAndRunAction]);
482498
......@@ -486,12 +502,21 @@ export default function GitActionsControl({ gitCwd, activeThreadId }: GitActions
486502
487503 setIsCommitDialogOpen(false);
488504 setDialogCommitMessage("");
505 setExcludedFiles(new Set());
506 setIsEditingFiles(false);
489507
490508 checkoutNewBranchAndRunAction({
491509 action: "commit",
492510 ...(commitMessage ? { commitMessage } : {}),
511 ...(!allSelected ? { filePaths: selectedFiles.map((f) => f.path) } : {}),
493512 });
494 }, [isCommitDialogOpen, dialogCommitMessage, checkoutNewBranchAndRunAction]);
513 }, [
514 allSelected,
515 isCommitDialogOpen,
516 dialogCommitMessage,
517 checkoutNewBranchAndRunAction,
518 selectedFiles,
519 ]);
495520
496521 const runQuickAction = useCallback(() => {
497522 if (quickAction.kind === "open_pr") {
......@@ -548,6 +573,8 @@ export default function GitActionsControl({ gitCwd, activeThreadId }: GitActions
548573 void runGitActionWithToast({ action: "commit_push_pr" });
549574 return;
550575 }
576 setExcludedFiles(new Set());
577 setIsEditingFiles(false);
551578 setIsCommitDialogOpen(true);
552579 },
553580 [openExistingPr, runGitActionWithToast, setIsCommitDialogOpen],
......@@ -558,14 +585,19 @@ export default function GitActionsControl({ gitCwd, activeThreadId }: GitActions
558585 const commitMessage = dialogCommitMessage.trim();
559586 setIsCommitDialogOpen(false);
560587 setDialogCommitMessage("");
588 setExcludedFiles(new Set());
589 setIsEditingFiles(false);
561590 void runGitActionWithToast({
562591 action: "commit",
563592 ...(commitMessage ? { commitMessage } : {}),
593 ...(!allSelected ? { filePaths: selectedFiles.map((f) => f.path) } : {}),
564594 });
565595 }, [
596 allSelected,
566597 dialogCommitMessage,
567598 isCommitDialogOpen,
568599 runGitActionWithToast,
600 selectedFiles,
569601 setDialogCommitMessage,
570602 setIsCommitDialogOpen,
571603 ]);
......@@ -730,6 +762,8 @@ export default function GitActionsControl({ gitCwd, activeThreadId }: GitActions
730762 if (!open) {
731763 setIsCommitDialogOpen(false);
732764 setDialogCommitMessage("");
765 setExcludedFiles(new Set());
766 setIsEditingFiles(false);
733767 }
734768 }}
735769 >
......@@ -752,37 +786,99 @@ export default function GitActionsControl({ gitCwd, activeThreadId }: GitActions
752786 </span>
753787 </div>
754788 <div className="space-y-1">
755 <p className="text-muted-foreground">Files</p>
756 {!gitStatusForActions || gitStatusForActions.workingTree.files.length === 0 ? (
789 <div className="flex items-center justify-between">
790 <div className="flex items-center gap-2">
791 {isEditingFiles && allFiles.length > 0 && (
792 <Checkbox
793 checked={allSelected}
794 indeterminate={!allSelected && !noneSelected}
795 onCheckedChange={() => {
796 setExcludedFiles(
797 allSelected ? new Set(allFiles.map((f) => f.path)) : new Set(),
798 );
799 }}
800 />
801 )}
802 <span className="text-muted-foreground">Files</span>
803 {!allSelected && !isEditingFiles && (
804 <span className="text-muted-foreground">
805 ({selectedFiles.length} of {allFiles.length})
806 </span>
807 )}
808 </div>
809 {allFiles.length > 0 && (
810 <Button
811 variant="ghost"
812 size="xs"
813 onClick={() => setIsEditingFiles((prev) => !prev)}
814 >
815 {isEditingFiles ? "Done" : "Edit"}
816 </Button>
817 )}
818 </div>
819 {!gitStatusForActions || allFiles.length === 0 ? (
757820 <p className="font-medium">none</p>
758821 ) : (
759822 <div className="space-y-2">
760823 <ScrollArea className="h-44 rounded-md border border-input bg-background">
761824 <div className="space-y-1 p-1">
762 {gitStatusForActions.workingTree.files.map((file) => (
763 <button
764 type="button"
765 key={file.path}
766 className="flex w-full items-center justify-between gap-3 rounded-md px-2 py-1 font-mono text-left transition-colors hover:bg-accent/50"
767 onClick={() => openChangedFileInEditor(file.path)}
768 >
769 <span className="truncate">{file.path}</span>
770 <span className="shrink-0">
771 <span className="text-success">+{file.insertions}</span>
772 <span className="text-muted-foreground"> / </span>
773 <span className="text-destructive">-{file.deletions}</span>
774 </span>
775 </button>
776 ))}
825 {allFiles.map((file) => {
826 const isExcluded = excludedFiles.has(file.path);
827 return (
828 <div
829 key={file.path}
830 className="flex w-full items-center gap-2 rounded-md px-2 py-1 font-mono text-xs transition-colors hover:bg-accent/50"
831 >
832 {isEditingFiles && (
833 <Checkbox
834 checked={!excludedFiles.has(file.path)}
835 onCheckedChange={() => {
836 setExcludedFiles((prev) => {
837 const next = new Set(prev);
838 if (next.has(file.path)) {
839 next.delete(file.path);
840 } else {
841 next.add(file.path);
842 }
843 return next;
844 });
845 }}
846 />
847 )}
848 <button
849 type="button"
850 className="flex flex-1 items-center justify-between gap-3 text-left truncate"
851 onClick={() => openChangedFileInEditor(file.path)}
852 >
853 <span
854 className={`truncate${isExcluded ? " text-muted-foreground" : ""}`}
855 >
856 {file.path}
857 </span>
858 <span className="shrink-0">
859 {isExcluded ? (
860 <span className="text-muted-foreground">Excluded</span>
861 ) : (
862 <>
863 <span className="text-success">+{file.insertions}</span>
864 <span className="text-muted-foreground"> / </span>
865 <span className="text-destructive">-{file.deletions}</span>
866 </>
867 )}
868 </span>
869 </button>
870 </div>
871 );
872 })}
777873 </div>
778874 </ScrollArea>
779875 <div className="flex justify-end font-mono">
780876 <span className="text-success">
781 +{gitStatusForActions.workingTree.insertions}
877 +{selectedFiles.reduce((sum, f) => sum + f.insertions, 0)}
782878 </span>
783879 <span className="text-muted-foreground"> / </span>
784880 <span className="text-destructive">
785 -{gitStatusForActions.workingTree.deletions}
881 -{selectedFiles.reduce((sum, f) => sum + f.deletions, 0)}
786882 </span>
787883 </div>
788884 </div>
......@@ -806,14 +902,21 @@ export default function GitActionsControl({ gitCwd, activeThreadId }: GitActions
806902 onClick={() => {
807903 setIsCommitDialogOpen(false);
808904 setDialogCommitMessage("");
905 setExcludedFiles(new Set());
906 setIsEditingFiles(false);
809907 }}
810908 >
811909 Cancel
812910 </Button>
813 <Button variant="outline" size="sm" onClick={runDialogActionOnNewBranch}>
911 <Button
912 variant="outline"
913 size="sm"
914 disabled={noneSelected}
915 onClick={runDialogActionOnNewBranch}
916 >
814917 Commit on new branch
815918 </Button>
816 <Button size="sm" onClick={runDialogAction}>
919 <Button size="sm" disabled={noneSelected} onClick={runDialogAction}>
817920 Commit
818921 </Button>
819922 </DialogFooter>
apps/web/src/lib/gitReactQuery.ts+3
......@@ -119,10 +119,12 @@ export function gitRunStackedActionMutationOptions(input: {
119119 action,
120120 commitMessage,
121121 featureBranch,
122 filePaths,
122123 }: {
123124 action: GitStackedAction;
124125 commitMessage?: string;
125126 featureBranch?: boolean;
127 filePaths?: string[];
126128 }) => {
127129 const api = ensureNativeApi();
128130 if (!input.cwd) throw new Error("Git action is unavailable.");
......@@ -131,6 +133,7 @@ export function gitRunStackedActionMutationOptions(input: {
131133 action,
132134 ...(commitMessage ? { commitMessage } : {}),
133135 ...(featureBranch ? { featureBranch } : {}),
136 ...(filePaths ? { filePaths } : {}),
134137 });
135138 },
136139 onSettled: async () => {
packages/contracts/src/git.ts+3
......@@ -61,6 +61,9 @@ export const GitRunStackedActionInput = Schema.Struct({
6161 action: GitStackedAction,
6262 commitMessage: Schema.optional(TrimmedNonEmptyStringSchema.check(Schema.isMaxLength(10_000))),
6363 featureBranch: Schema.optional(Schema.Boolean),
64 filePaths: Schema.optional(
65 Schema.Array(TrimmedNonEmptyStringSchema).check(Schema.isMinLength(1)),
66 ),
6467});
6568export type GitRunStackedActionInput = typeof GitRunStackedActionInput.Type;
6669