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) =>...@@ -97,7 +97,7 @@ const makeIsolatedGitCore = (gitService: GitServiceShape) =>
97 return {97 return {
98 status: (input) => core.status(input),98 status: (input) => core.status(input),
99 statusDetails: (cwd) => core.statusDetails(cwd),99 statusDetails: (cwd) => core.statusDetails(cwd),
100 prepareCommitContext: (cwd) => core.prepareCommitContext(cwd),100 prepareCommitContext: (cwd, filePaths?) => core.prepareCommitContext(cwd, filePaths),
101 commit: (cwd, subject, body) => core.commit(cwd, subject, body),101 commit: (cwd, subject, body) => core.commit(cwd, subject, body),
102 pushCurrentBranch: (cwd, fallbackBranch) => core.pushCurrentBranch(cwd, fallbackBranch),102 pushCurrentBranch: (cwd, fallbackBranch) => core.pushCurrentBranch(cwd, fallbackBranch),
103 pullCurrentBranch: (cwd) => core.pullCurrentBranch(cwd),103 pullCurrentBranch: (cwd) => core.pullCurrentBranch(cwd),
...@@ -1711,6 +1711,45 @@ it.layer(TestLayer)("git integration", (it) => {...@@ -1711,6 +1711,45 @@ it.layer(TestLayer)("git integration", (it) => {
1711 }),1711 }),
1712 );1712 );
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
1714 it.effect("pushes with upstream setup and then skips when up to date", () =>1753 it.effect("pushes with upstream setup and then skips when up to date", () =>
1715 Effect.gen(function* () {1754 Effect.gen(function* () {
1716 const tmp = yield* makeTmpDir();1755 const tmp = yield* makeTmpDir();
apps/server/src/git/Layers/GitCore.ts+14-2
...@@ -766,9 +766,21 @@ const makeGitCore = Effect.gen(function* () {...@@ -766,9 +766,21 @@ const makeGitCore = Effect.gen(function* () {
766 })),766 })),
767 );767 );
768768
769 const prepareCommitContext: GitCoreShape["prepareCommitContext"] = (cwd) =>769 const prepareCommitContext: GitCoreShape["prepareCommitContext"] = (cwd, filePaths) =>
770 Effect.gen(function* () {770 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
773 const stagedSummary = yield* runGitStdout("GitCore.prepareCommitContext.stagedSummary", cwd, [785 const stagedSummary = yield* runGitStdout("GitCore.prepareCommitContext.stagedSummary", cwd, [
774 "diff",786 "diff",
apps/server/src/git/Layers/GitManager.test.ts+26
...@@ -451,6 +451,7 @@ function runStackedAction(...@@ -451,6 +451,7 @@ function runStackedAction(
451 action: "commit" | "commit_push" | "commit_push_pr";451 action: "commit" | "commit_push" | "commit_push_pr";
452 commitMessage?: string;452 commitMessage?: string;
453 featureBranch?: boolean;453 featureBranch?: boolean;
454 filePaths?: readonly string[];
454 },455 },
455) {456) {
456 return manager.runStackedAction(input);457 return manager.runStackedAction(input);
...@@ -767,6 +768,31 @@ it.layer(GitManagerTestLayer)("GitManager", (it) => {...@@ -767,6 +768,31 @@ it.layer(GitManagerTestLayer)("GitManager", (it) => {
767 }),768 }),
768 );769 );
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
770 it.effect("creates feature branch, commits, and pushes with featureBranch option", () =>796 it.effect("creates feature branch, commits, and pushes with featureBranch option", () =>
771 Effect.gen(function* () {797 Effect.gen(function* () {
772 const repoDir = yield* makeTempDir("t3code-git-manager-");798 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* () {...@@ -638,9 +638,10 @@ export const makeGitManager = Effect.gen(function* () {
638 commitMessage?: string;638 commitMessage?: string;
639 /** When true, also produce a semantic feature branch name. */639 /** When true, also produce a semantic feature branch name. */
640 includeBranch?: boolean;640 includeBranch?: boolean;
641 filePaths?: readonly string[];
641 }) =>642 }) =>
642 Effect.gen(function* () {643 Effect.gen(function* () {
643 const context = yield* gitCore.prepareCommitContext(input.cwd);644 const context = yield* gitCore.prepareCommitContext(input.cwd, input.filePaths);
644 if (!context) {645 if (!context) {
645 return null;646 return null;
646 }647 }
...@@ -680,6 +681,7 @@ export const makeGitManager = Effect.gen(function* () {...@@ -680,6 +681,7 @@ export const makeGitManager = Effect.gen(function* () {
680 branch: string | null,681 branch: string | null,
681 commitMessage?: string,682 commitMessage?: string,
682 preResolvedSuggestion?: CommitAndBranchSuggestion,683 preResolvedSuggestion?: CommitAndBranchSuggestion,
684 filePaths?: readonly string[],
683 ) =>685 ) =>
684 Effect.gen(function* () {686 Effect.gen(function* () {
685 const suggestion =687 const suggestion =
...@@ -688,6 +690,7 @@ export const makeGitManager = Effect.gen(function* () {...@@ -688,6 +690,7 @@ export const makeGitManager = Effect.gen(function* () {
688 cwd,690 cwd,
689 branch,691 branch,
690 ...(commitMessage ? { commitMessage } : {}),692 ...(commitMessage ? { commitMessage } : {}),
693 ...(filePaths ? { filePaths } : {}),
691 }));694 }));
692 if (!suggestion) {695 if (!suggestion) {
693 return { status: "skipped_no_changes" as const };696 return { status: "skipped_no_changes" as const };
...@@ -964,12 +967,18 @@ export const makeGitManager = Effect.gen(function* () {...@@ -964,12 +967,18 @@ export const makeGitManager = Effect.gen(function* () {
964 },967 },
965 );968 );
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 ) =>
968 Effect.gen(function* () {976 Effect.gen(function* () {
969 const suggestion = yield* resolveCommitAndBranchSuggestion({977 const suggestion = yield* resolveCommitAndBranchSuggestion({
970 cwd,978 cwd,
971 branch,979 branch,
972 ...(commitMessage ? { commitMessage } : {}),980 ...(commitMessage ? { commitMessage } : {}),
981 ...(filePaths ? { filePaths } : {}),
973 includeBranch: true,982 includeBranch: true,
974 });983 });
975 if (!suggestion) {984 if (!suggestion) {
...@@ -1018,6 +1027,7 @@ export const makeGitManager = Effect.gen(function* () {...@@ -1018,6 +1027,7 @@ export const makeGitManager = Effect.gen(function* () {
1018 input.cwd,1027 input.cwd,
1019 initialStatus.branch,1028 initialStatus.branch,
1020 input.commitMessage,1029 input.commitMessage,
1030 input.filePaths,
1021 );1031 );
1022 branchStep = result.branchStep;1032 branchStep = result.branchStep;
1023 commitMessageForStep = result.resolvedCommitMessage;1033 commitMessageForStep = result.resolvedCommitMessage;
...@@ -1033,6 +1043,7 @@ export const makeGitManager = Effect.gen(function* () {...@@ -1033,6 +1043,7 @@ export const makeGitManager = Effect.gen(function* () {
1033 currentBranch,1043 currentBranch,
1034 commitMessageForStep,1044 commitMessageForStep,
1035 preResolvedCommitSuggestion,1045 preResolvedCommitSuggestion,
1046 input.filePaths,
1036 );1047 );
10371048
1038 const push = wantsPush1049 const push = wantsPush
apps/server/src/git/Services/GitCore.ts+1
...@@ -101,6 +101,7 @@ export interface GitCoreShape {...@@ -101,6 +101,7 @@ export interface GitCoreShape {
101 */101 */
102 readonly prepareCommitContext: (102 readonly prepareCommitContext: (
103 cwd: string,103 cwd: string,
104 filePaths?: readonly string[],
104 ) => Effect.Effect<GitPreparedCommitContext | null, GitCommandError>;105 ) => Effect.Effect<GitPreparedCommitContext | null, GitCommandError>;
105106
106 /**107 /**
apps/web/src/components/GitActionsControl.tsx+127-24
...@@ -16,6 +16,7 @@ import {...@@ -16,6 +16,7 @@ import {
16 summarizeGitResult,16 summarizeGitResult,
17} from "./GitActionsControl.logic";17} from "./GitActionsControl.logic";
18import { Button } from "~/components/ui/button";18import { Button } from "~/components/ui/button";
19import { Checkbox } from "~/components/ui/checkbox";
19import {20import {
20 Dialog,21 Dialog,
21 DialogDescription,22 DialogDescription,
...@@ -56,6 +57,7 @@ interface PendingDefaultBranchAction {...@@ -56,6 +57,7 @@ interface PendingDefaultBranchAction {
56 commitMessage?: string;57 commitMessage?: string;
57 forcePushOnlyProgress: boolean;58 forcePushOnlyProgress: boolean;
58 onConfirmed?: () => void;59 onConfirmed?: () => void;
60 filePaths?: string[];
59}61}
6062
61type GitActionToastId = ReturnType<typeof toastManager.add>;63type GitActionToastId = ReturnType<typeof toastManager.add>;
...@@ -159,6 +161,8 @@ export default function GitActionsControl({ gitCwd, activeThreadId }: GitActions...@@ -159,6 +161,8 @@ export default function GitActionsControl({ gitCwd, activeThreadId }: GitActions
159 const queryClient = useQueryClient();161 const queryClient = useQueryClient();
160 const [isCommitDialogOpen, setIsCommitDialogOpen] = useState(false);162 const [isCommitDialogOpen, setIsCommitDialogOpen] = useState(false);
161 const [dialogCommitMessage, setDialogCommitMessage] = useState("");163 const [dialogCommitMessage, setDialogCommitMessage] = useState("");
164 const [excludedFiles, setExcludedFiles] = useState<ReadonlySet<string>>(new Set());
165 const [isEditingFiles, setIsEditingFiles] = useState(false);
162 const [pendingDefaultBranchAction, setPendingDefaultBranchAction] =166 const [pendingDefaultBranchAction, setPendingDefaultBranchAction] =
163 useState<PendingDefaultBranchAction | null>(null);167 useState<PendingDefaultBranchAction | null>(null);
164168
...@@ -179,6 +183,11 @@ export default function GitActionsControl({ gitCwd, activeThreadId }: GitActions...@@ -179,6 +183,11 @@ export default function GitActionsControl({ gitCwd, activeThreadId }: GitActions
179183
180 const gitStatusForActions = isGitStatusOutOfSync ? null : gitStatus;184 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
182 const initMutation = useMutation(gitInitMutationOptions({ cwd: gitCwd, queryClient }));191 const initMutation = useMutation(gitInitMutationOptions({ cwd: gitCwd, queryClient }));
183192
184 const runImmediateGitActionMutation = useMutation(193 const runImmediateGitActionMutation = useMutation(
...@@ -257,6 +266,7 @@ export default function GitActionsControl({ gitCwd, activeThreadId }: GitActions...@@ -257,6 +266,7 @@ export default function GitActionsControl({ gitCwd, activeThreadId }: GitActions
257 featureBranch = false,266 featureBranch = false,
258 isDefaultBranchOverride,267 isDefaultBranchOverride,
259 progressToastId,268 progressToastId,
269 filePaths,
260 }: {270 }: {
261 action: GitStackedAction;271 action: GitStackedAction;
262 commitMessage?: string;272 commitMessage?: string;
...@@ -267,6 +277,7 @@ export default function GitActionsControl({ gitCwd, activeThreadId }: GitActions...@@ -267,6 +277,7 @@ export default function GitActionsControl({ gitCwd, activeThreadId }: GitActions
267 featureBranch?: boolean;277 featureBranch?: boolean;
268 isDefaultBranchOverride?: boolean;278 isDefaultBranchOverride?: boolean;
269 progressToastId?: GitActionToastId;279 progressToastId?: GitActionToastId;
280 filePaths?: string[];
270 }) => {281 }) => {
271 const actionStatus = statusOverride ?? gitStatusForActions;282 const actionStatus = statusOverride ?? gitStatusForActions;
272 const actionBranch = actionStatus?.branch ?? null;283 const actionBranch = actionStatus?.branch ?? null;
...@@ -289,6 +300,7 @@ export default function GitActionsControl({ gitCwd, activeThreadId }: GitActions...@@ -289,6 +300,7 @@ export default function GitActionsControl({ gitCwd, activeThreadId }: GitActions
289 ...(commitMessage ? { commitMessage } : {}),300 ...(commitMessage ? { commitMessage } : {}),
290 forcePushOnlyProgress,301 forcePushOnlyProgress,
291 ...(onConfirmed ? { onConfirmed } : {}),302 ...(onConfirmed ? { onConfirmed } : {}),
303 ...(filePaths ? { filePaths } : {}),
292 });304 });
293 return;305 return;
294 }306 }
...@@ -338,6 +350,7 @@ export default function GitActionsControl({ gitCwd, activeThreadId }: GitActions...@@ -338,6 +350,7 @@ export default function GitActionsControl({ gitCwd, activeThreadId }: GitActions
338 action,350 action,
339 ...(commitMessage ? { commitMessage } : {}),351 ...(commitMessage ? { commitMessage } : {}),
340 ...(featureBranch ? { featureBranch } : {}),352 ...(featureBranch ? { featureBranch } : {}),
353 ...(filePaths ? { filePaths } : {}),
341 });354 });
342355
343 try {356 try {
...@@ -439,7 +452,7 @@ export default function GitActionsControl({ gitCwd, activeThreadId }: GitActions...@@ -439,7 +452,7 @@ export default function GitActionsControl({ gitCwd, activeThreadId }: GitActions
439452
440 const continuePendingDefaultBranchAction = useCallback(() => {453 const continuePendingDefaultBranchAction = useCallback(() => {
441 if (!pendingDefaultBranchAction) return;454 if (!pendingDefaultBranchAction) return;
442 const { action, commitMessage, forcePushOnlyProgress, onConfirmed } =455 const { action, commitMessage, forcePushOnlyProgress, onConfirmed, filePaths } =
443 pendingDefaultBranchAction;456 pendingDefaultBranchAction;
444 setPendingDefaultBranchAction(null);457 setPendingDefaultBranchAction(null);
445 void runGitActionWithToast({458 void runGitActionWithToast({
...@@ -447,6 +460,7 @@ export default function GitActionsControl({ gitCwd, activeThreadId }: GitActions...@@ -447,6 +460,7 @@ export default function GitActionsControl({ gitCwd, activeThreadId }: GitActions
447 ...(commitMessage ? { commitMessage } : {}),460 ...(commitMessage ? { commitMessage } : {}),
448 forcePushOnlyProgress,461 forcePushOnlyProgress,
449 ...(onConfirmed ? { onConfirmed } : {}),462 ...(onConfirmed ? { onConfirmed } : {}),
463 ...(filePaths ? { filePaths } : {}),
450 skipDefaultBranchPrompt: true,464 skipDefaultBranchPrompt: true,
451 });465 });
452 }, [pendingDefaultBranchAction, runGitActionWithToast]);466 }, [pendingDefaultBranchAction, runGitActionWithToast]);
...@@ -457,6 +471,7 @@ export default function GitActionsControl({ gitCwd, activeThreadId }: GitActions...@@ -457,6 +471,7 @@ export default function GitActionsControl({ gitCwd, activeThreadId }: GitActions
457 commitMessage?: string;471 commitMessage?: string;
458 forcePushOnlyProgress?: boolean;472 forcePushOnlyProgress?: boolean;
459 onConfirmed?: () => void;473 onConfirmed?: () => void;
474 filePaths?: string[];
460 }) => {475 }) => {
461 void runGitActionWithToast({476 void runGitActionWithToast({
462 ...actionParams,477 ...actionParams,
...@@ -469,7 +484,7 @@ export default function GitActionsControl({ gitCwd, activeThreadId }: GitActions...@@ -469,7 +484,7 @@ export default function GitActionsControl({ gitCwd, activeThreadId }: GitActions
469484
470 const checkoutFeatureBranchAndContinuePendingAction = useCallback(() => {485 const checkoutFeatureBranchAndContinuePendingAction = useCallback(() => {
471 if (!pendingDefaultBranchAction) return;486 if (!pendingDefaultBranchAction) return;
472 const { action, commitMessage, forcePushOnlyProgress, onConfirmed } =487 const { action, commitMessage, forcePushOnlyProgress, onConfirmed, filePaths } =
473 pendingDefaultBranchAction;488 pendingDefaultBranchAction;
474 setPendingDefaultBranchAction(null);489 setPendingDefaultBranchAction(null);
475 checkoutNewBranchAndRunAction({490 checkoutNewBranchAndRunAction({
...@@ -477,6 +492,7 @@ export default function GitActionsControl({ gitCwd, activeThreadId }: GitActions...@@ -477,6 +492,7 @@ export default function GitActionsControl({ gitCwd, activeThreadId }: GitActions
477 ...(commitMessage ? { commitMessage } : {}),492 ...(commitMessage ? { commitMessage } : {}),
478 forcePushOnlyProgress,493 forcePushOnlyProgress,
479 ...(onConfirmed ? { onConfirmed } : {}),494 ...(onConfirmed ? { onConfirmed } : {}),
495 ...(filePaths ? { filePaths } : {}),
480 });496 });
481 }, [pendingDefaultBranchAction, checkoutNewBranchAndRunAction]);497 }, [pendingDefaultBranchAction, checkoutNewBranchAndRunAction]);
482498
...@@ -486,12 +502,21 @@ export default function GitActionsControl({ gitCwd, activeThreadId }: GitActions...@@ -486,12 +502,21 @@ export default function GitActionsControl({ gitCwd, activeThreadId }: GitActions
486502
487 setIsCommitDialogOpen(false);503 setIsCommitDialogOpen(false);
488 setDialogCommitMessage("");504 setDialogCommitMessage("");
505 setExcludedFiles(new Set());
506 setIsEditingFiles(false);
489507
490 checkoutNewBranchAndRunAction({508 checkoutNewBranchAndRunAction({
491 action: "commit",509 action: "commit",
492 ...(commitMessage ? { commitMessage } : {}),510 ...(commitMessage ? { commitMessage } : {}),
511 ...(!allSelected ? { filePaths: selectedFiles.map((f) => f.path) } : {}),
493 });512 });
494 }, [isCommitDialogOpen, dialogCommitMessage, checkoutNewBranchAndRunAction]);513 }, [
514 allSelected,
515 isCommitDialogOpen,
516 dialogCommitMessage,
517 checkoutNewBranchAndRunAction,
518 selectedFiles,
519 ]);
495520
496 const runQuickAction = useCallback(() => {521 const runQuickAction = useCallback(() => {
497 if (quickAction.kind === "open_pr") {522 if (quickAction.kind === "open_pr") {
...@@ -548,6 +573,8 @@ export default function GitActionsControl({ gitCwd, activeThreadId }: GitActions...@@ -548,6 +573,8 @@ export default function GitActionsControl({ gitCwd, activeThreadId }: GitActions
548 void runGitActionWithToast({ action: "commit_push_pr" });573 void runGitActionWithToast({ action: "commit_push_pr" });
549 return;574 return;
550 }575 }
576 setExcludedFiles(new Set());
577 setIsEditingFiles(false);
551 setIsCommitDialogOpen(true);578 setIsCommitDialogOpen(true);
552 },579 },
553 [openExistingPr, runGitActionWithToast, setIsCommitDialogOpen],580 [openExistingPr, runGitActionWithToast, setIsCommitDialogOpen],
...@@ -558,14 +585,19 @@ export default function GitActionsControl({ gitCwd, activeThreadId }: GitActions...@@ -558,14 +585,19 @@ export default function GitActionsControl({ gitCwd, activeThreadId }: GitActions
558 const commitMessage = dialogCommitMessage.trim();585 const commitMessage = dialogCommitMessage.trim();
559 setIsCommitDialogOpen(false);586 setIsCommitDialogOpen(false);
560 setDialogCommitMessage("");587 setDialogCommitMessage("");
588 setExcludedFiles(new Set());
589 setIsEditingFiles(false);
561 void runGitActionWithToast({590 void runGitActionWithToast({
562 action: "commit",591 action: "commit",
563 ...(commitMessage ? { commitMessage } : {}),592 ...(commitMessage ? { commitMessage } : {}),
593 ...(!allSelected ? { filePaths: selectedFiles.map((f) => f.path) } : {}),
564 });594 });
565 }, [595 }, [
596 allSelected,
566 dialogCommitMessage,597 dialogCommitMessage,
567 isCommitDialogOpen,598 isCommitDialogOpen,
568 runGitActionWithToast,599 runGitActionWithToast,
600 selectedFiles,
569 setDialogCommitMessage,601 setDialogCommitMessage,
570 setIsCommitDialogOpen,602 setIsCommitDialogOpen,
571 ]);603 ]);
...@@ -730,6 +762,8 @@ export default function GitActionsControl({ gitCwd, activeThreadId }: GitActions...@@ -730,6 +762,8 @@ export default function GitActionsControl({ gitCwd, activeThreadId }: GitActions
730 if (!open) {762 if (!open) {
731 setIsCommitDialogOpen(false);763 setIsCommitDialogOpen(false);
732 setDialogCommitMessage("");764 setDialogCommitMessage("");
765 setExcludedFiles(new Set());
766 setIsEditingFiles(false);
733 }767 }
734 }}768 }}
735 >769 >
...@@ -752,37 +786,99 @@ export default function GitActionsControl({ gitCwd, activeThreadId }: GitActions...@@ -752,37 +786,99 @@ export default function GitActionsControl({ gitCwd, activeThreadId }: GitActions
752 </span>786 </span>
753 </div>787 </div>
754 <div className="space-y-1">788 <div className="space-y-1">
755 <p className="text-muted-foreground">Files</p>789 <div className="flex items-center justify-between">
756 {!gitStatusForActions || gitStatusForActions.workingTree.files.length === 0 ? (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 ? (
757 <p className="font-medium">none</p>820 <p className="font-medium">none</p>
758 ) : (821 ) : (
759 <div className="space-y-2">822 <div className="space-y-2">
760 <ScrollArea className="h-44 rounded-md border border-input bg-background">823 <ScrollArea className="h-44 rounded-md border border-input bg-background">
761 <div className="space-y-1 p-1">824 <div className="space-y-1 p-1">
762 {gitStatusForActions.workingTree.files.map((file) => (825 {allFiles.map((file) => {
763 <button826 const isExcluded = excludedFiles.has(file.path);
764 type="button"827 return (
765 key={file.path}828 <div
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"829 key={file.path}
767 onClick={() => openChangedFileInEditor(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"
768 >831 >
769 <span className="truncate">{file.path}</span>832 {isEditingFiles && (
770 <span className="shrink-0">833 <Checkbox
771 <span className="text-success">+{file.insertions}</span>834 checked={!excludedFiles.has(file.path)}
772 <span className="text-muted-foreground"> / </span>835 onCheckedChange={() => {
773 <span className="text-destructive">-{file.deletions}</span>836 setExcludedFiles((prev) => {
774 </span>837 const next = new Set(prev);
775 </button>838 if (next.has(file.path)) {
776 ))}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 })}
777 </div>873 </div>
778 </ScrollArea>874 </ScrollArea>
779 <div className="flex justify-end font-mono">875 <div className="flex justify-end font-mono">
780 <span className="text-success">876 <span className="text-success">
781 +{gitStatusForActions.workingTree.insertions}877 +{selectedFiles.reduce((sum, f) => sum + f.insertions, 0)}
782 </span>878 </span>
783 <span className="text-muted-foreground"> / </span>879 <span className="text-muted-foreground"> / </span>
784 <span className="text-destructive">880 <span className="text-destructive">
785 -{gitStatusForActions.workingTree.deletions}881 -{selectedFiles.reduce((sum, f) => sum + f.deletions, 0)}
786 </span>882 </span>
787 </div>883 </div>
788 </div>884 </div>
...@@ -806,14 +902,21 @@ export default function GitActionsControl({ gitCwd, activeThreadId }: GitActions...@@ -806,14 +902,21 @@ export default function GitActionsControl({ gitCwd, activeThreadId }: GitActions
806 onClick={() => {902 onClick={() => {
807 setIsCommitDialogOpen(false);903 setIsCommitDialogOpen(false);
808 setDialogCommitMessage("");904 setDialogCommitMessage("");
905 setExcludedFiles(new Set());
906 setIsEditingFiles(false);
809 }}907 }}
810 >908 >
811 Cancel909 Cancel
812 </Button>910 </Button>
813 <Button variant="outline" size="sm" onClick={runDialogActionOnNewBranch}>911 <Button
912 variant="outline"
913 size="sm"
914 disabled={noneSelected}
915 onClick={runDialogActionOnNewBranch}
916 >
814 Commit on new branch917 Commit on new branch
815 </Button>918 </Button>
816 <Button size="sm" onClick={runDialogAction}>919 <Button size="sm" disabled={noneSelected} onClick={runDialogAction}>
817 Commit920 Commit
818 </Button>921 </Button>
819 </DialogFooter>922 </DialogFooter>
apps/web/src/lib/gitReactQuery.ts+3
...@@ -119,10 +119,12 @@ export function gitRunStackedActionMutationOptions(input: {...@@ -119,10 +119,12 @@ export function gitRunStackedActionMutationOptions(input: {
119 action,119 action,
120 commitMessage,120 commitMessage,
121 featureBranch,121 featureBranch,
122 filePaths,
122 }: {123 }: {
123 action: GitStackedAction;124 action: GitStackedAction;
124 commitMessage?: string;125 commitMessage?: string;
125 featureBranch?: boolean;126 featureBranch?: boolean;
127 filePaths?: string[];
126 }) => {128 }) => {
127 const api = ensureNativeApi();129 const api = ensureNativeApi();
128 if (!input.cwd) throw new Error("Git action is unavailable.");130 if (!input.cwd) throw new Error("Git action is unavailable.");
...@@ -131,6 +133,7 @@ export function gitRunStackedActionMutationOptions(input: {...@@ -131,6 +133,7 @@ export function gitRunStackedActionMutationOptions(input: {
131 action,133 action,
132 ...(commitMessage ? { commitMessage } : {}),134 ...(commitMessage ? { commitMessage } : {}),
133 ...(featureBranch ? { featureBranch } : {}),135 ...(featureBranch ? { featureBranch } : {}),
136 ...(filePaths ? { filePaths } : {}),
134 });137 });
135 },138 },
136 onSettled: async () => {139 onSettled: async () => {
packages/contracts/src/git.ts+3
...@@ -61,6 +61,9 @@ export const GitRunStackedActionInput = Schema.Struct({...@@ -61,6 +61,9 @@ export const GitRunStackedActionInput = Schema.Struct({
61 action: GitStackedAction,61 action: GitStackedAction,
62 commitMessage: Schema.optional(TrimmedNonEmptyStringSchema.check(Schema.isMaxLength(10_000))),62 commitMessage: Schema.optional(TrimmedNonEmptyStringSchema.check(Schema.isMaxLength(10_000))),
63 featureBranch: Schema.optional(Schema.Boolean),63 featureBranch: Schema.optional(Schema.Boolean),
64 filePaths: Schema.optional(
65 Schema.Array(TrimmedNonEmptyStringSchema).check(Schema.isMinLength(1)),
66 ),
64});67});
65export type GitRunStackedActionInput = typeof GitRunStackedActionInput.Type;68export type GitRunStackedActionInput = typeof GitRunStackedActionInput.Type;
6669