Skip to content

Commit 6877f39

Browse files
authored
Add branch sync action to git controls (#56)
- Show a dedicated sync button when a branch can pull or push - Add sync state logic and coverage for busy, diverged, and unavailable cases - Keep sidebar formatting aligned with lint
1 parent e4779a5 commit 6877f39

3 files changed

Lines changed: 205 additions & 11 deletions

File tree

apps/web/src/components/GitActionsControl.logic.test.ts

Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ import {
88
resolveDefaultBranchActionDialogCopy,
99
resolveGitFailureRetryLabel,
1010
resolveQuickAction,
11+
resolveSyncAction,
1112
summarizeGitFailure,
1213
summarizeGitResult,
1314
} from "./GitActionsControl.logic";
@@ -410,6 +411,55 @@ describe("when: branch has diverged from upstream", () => {
410411
});
411412
});
412413

414+
describe("sync button state", () => {
415+
it("returns pull sync action when the branch is behind upstream", () => {
416+
const syncAction = resolveSyncAction(status({ behindCount: 2 }), false);
417+
assert.deepEqual(syncAction, {
418+
label: "Sync branch",
419+
disabled: false,
420+
kind: "run_pull",
421+
});
422+
});
423+
424+
it("returns push sync action when the branch is ahead of upstream", () => {
425+
const syncAction = resolveSyncAction(status({ aheadCount: 3 }), false);
426+
assert.deepEqual(syncAction, {
427+
label: "Sync branch",
428+
disabled: false,
429+
kind: "run_action",
430+
action: "commit_push",
431+
});
432+
});
433+
434+
it("returns a disabled sync hint when the branch has diverged from upstream", () => {
435+
const syncAction = resolveSyncAction(status({ aheadCount: 2, behindCount: 1 }), false);
436+
assert.deepEqual(syncAction, {
437+
label: "Sync branch",
438+
disabled: true,
439+
kind: "show_hint",
440+
hint: "Branch has diverged from upstream. Rebase/merge first.",
441+
});
442+
});
443+
444+
it("returns a disabled sync hint while git actions are busy", () => {
445+
const syncAction = resolveSyncAction(status({ aheadCount: 1 }), true);
446+
assert.deepEqual(syncAction, {
447+
label: "Sync branch",
448+
disabled: true,
449+
kind: "show_hint",
450+
hint: "Git action in progress.",
451+
});
452+
});
453+
454+
it("does not show when the branch has no upstream", () => {
455+
assert.isNull(resolveSyncAction(status({ hasUpstream: false, aheadCount: 1 }), false));
456+
});
457+
458+
it("does not show when the working tree has local changes", () => {
459+
assert.isNull(resolveSyncAction(status({ hasWorkingTreeChanges: true, aheadCount: 1 }), false));
460+
});
461+
});
462+
413463
describe("when: working tree has local changes", () => {
414464
it("resolveQuickAction returns commit, push, and create PR", () => {
415465
const quick = resolveQuickAction(status({ hasWorkingTreeChanges: true }), false);

apps/web/src/components/GitActionsControl.logic.ts

Lines changed: 63 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,14 @@ export interface GitQuickAction {
2626
hint?: string;
2727
}
2828

29+
export interface GitSyncAction {
30+
label: string;
31+
disabled: boolean;
32+
kind: "run_action" | "run_pull" | "show_hint";
33+
action?: "commit_push";
34+
hint?: string;
35+
}
36+
2937
export interface DefaultBranchActionDialogCopy {
3038
title: string;
3139
description: string;
@@ -338,6 +346,61 @@ export function resolveQuickAction(
338346
};
339347
}
340348

349+
export function resolveSyncAction(
350+
gitStatus: GitStatusResult | null,
351+
isBusy: boolean,
352+
): GitSyncAction | null {
353+
if (!gitStatus) return null;
354+
if (
355+
gitStatus.branch === null ||
356+
gitStatus.hasWorkingTreeChanges ||
357+
gitStatus.hasConflicts ||
358+
!gitStatus.hasUpstream
359+
) {
360+
return null;
361+
}
362+
363+
const isAhead = gitStatus.aheadCount > 0;
364+
const isBehind = gitStatus.behindCount > 0;
365+
366+
if (!isAhead && !isBehind) {
367+
return null;
368+
}
369+
370+
if (isBusy) {
371+
return {
372+
label: "Sync branch",
373+
disabled: true,
374+
kind: "show_hint",
375+
hint: "Git action in progress.",
376+
};
377+
}
378+
379+
if (isAhead && isBehind) {
380+
return {
381+
label: "Sync branch",
382+
disabled: true,
383+
kind: "show_hint",
384+
hint: "Branch has diverged from upstream. Rebase/merge first.",
385+
};
386+
}
387+
388+
if (isBehind) {
389+
return {
390+
label: "Sync branch",
391+
disabled: false,
392+
kind: "run_pull",
393+
};
394+
}
395+
396+
return {
397+
label: "Sync branch",
398+
disabled: false,
399+
kind: "run_action",
400+
action: "commit_push",
401+
};
402+
}
403+
341404
export function requiresDefaultBranchConfirmation(
342405
action: GitStackedAction,
343406
isDefaultBranch: boolean,

apps/web/src/components/GitActionsControl.tsx

Lines changed: 92 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ import { useIsMutating, useMutation, useQuery, useQueryClient } from "@tanstack/
1111
import { Schema } from "effect";
1212
import { useCallback, useEffect, useEffectEvent, useMemo, useRef, useState } from "react";
1313
import {
14+
ArrowUpDownIcon,
1415
ChevronDownIcon,
1516
CircleAlertIcon,
1617
CloudUploadIcon,
@@ -28,8 +29,9 @@ import {
2829
type DefaultBranchConfirmableAction,
2930
requiresDefaultBranchConfirmation,
3031
resolveDefaultBranchActionDialogCopy,
31-
resolveQuickAction,
3232
resolveGitFailureRetryLabel,
33+
resolveQuickAction,
34+
resolveSyncAction,
3335
summarizeGitFailure,
3436
summarizeGitResult,
3537
} from "./GitActionsControl.logic";
@@ -279,6 +281,10 @@ function GitQuickActionIcon({ quickAction }: { quickAction: GitQuickAction }) {
279281
return <InfoIcon className={iconClassName} />;
280282
}
281283

284+
function GitSyncActionIcon() {
285+
return <ArrowUpDownIcon className="size-3.5" />;
286+
}
287+
282288
export default function GitActionsControl({ gitCwd, activeThreadId }: GitActionsControlProps) {
283289
const { settings } = useAppSettings();
284290
const threadToastData = useMemo(
@@ -363,9 +369,16 @@ export default function GitActionsControl({ gitCwd, activeThreadId }: GitActions
363369
resolveQuickAction(gitStatusForActions, isGitActionRunning, isDefaultBranch, hasOriginRemote),
364370
[gitStatusForActions, hasOriginRemote, isDefaultBranch, isGitActionRunning],
365371
);
372+
const syncAction = useMemo(
373+
() => resolveSyncAction(gitStatusForActions, isGitActionRunning),
374+
[gitStatusForActions, isGitActionRunning],
375+
);
366376
const quickActionDisabledReason = quickAction.disabled
367377
? (quickAction.hint ?? "This action is currently unavailable.")
368378
: null;
379+
const syncActionDisabledReason = syncAction?.disabled
380+
? (syncAction.hint ?? "This action is currently unavailable.")
381+
: null;
369382
const pendingDefaultBranchActionCopy = pendingDefaultBranchAction
370383
? resolveDefaultBranchActionDialogCopy({
371384
action: pendingDefaultBranchAction.action,
@@ -838,30 +851,63 @@ export default function GitActionsControl({ gitCwd, activeThreadId }: GitActions
838851
void openPromise.catch(() => undefined);
839852
}, [conflictedFiles, gitCwd, threadToastData]);
840853

841-
const runQuickAction = useCallback(() => {
842-
if (quickAction.kind === "open_pr") {
843-
void openExistingPr();
844-
return;
845-
}
846-
if (quickAction.kind === "run_pull") {
854+
const runPullWithToast = useCallback(
855+
(messages?: {
856+
loadingTitle?: string;
857+
pulledTitle?: string;
858+
skippedTitle?: string;
859+
errorTitle?: string;
860+
}) => {
847861
const promise = pullMutation.mutateAsync();
848862
toastManager.promise(promise, {
849-
loading: { title: "Pulling...", data: threadToastData },
863+
loading: { title: messages?.loadingTitle ?? "Pulling...", data: threadToastData },
850864
success: (result) => ({
851-
title: result.status === "pulled" ? "Pulled" : "Already up to date",
865+
title:
866+
result.status === "pulled"
867+
? (messages?.pulledTitle ?? "Pulled")
868+
: (messages?.skippedTitle ?? "Already up to date"),
852869
description:
853870
result.status === "pulled"
854871
? `Updated ${result.branch} from ${result.upstreamBranch ?? "upstream"}`
855872
: `${result.branch} is already synchronized.`,
856873
data: threadToastData,
857874
}),
858875
error: (err) => ({
859-
title: "Pull failed",
876+
title: messages?.errorTitle ?? "Pull failed",
860877
description: err instanceof Error ? err.message : "An error occurred.",
861878
data: threadToastData,
862879
}),
863880
});
864881
void promise.catch(() => undefined);
882+
},
883+
[pullMutation, threadToastData],
884+
);
885+
886+
const runSyncAction = useCallback(() => {
887+
if (!syncAction || syncAction.disabled) {
888+
return;
889+
}
890+
if (syncAction.kind === "run_pull") {
891+
runPullWithToast({
892+
loadingTitle: "Syncing...",
893+
pulledTitle: "Synced branch",
894+
skippedTitle: "Already up to date",
895+
errorTitle: "Sync failed",
896+
});
897+
return;
898+
}
899+
if (syncAction.kind === "run_action" && syncAction.action === "commit_push") {
900+
void runGitActionWithToast({ action: "commit_push", forcePushOnlyProgress: true });
901+
}
902+
}, [runPullWithToast, syncAction]);
903+
904+
const runQuickAction = useCallback(() => {
905+
if (quickAction.kind === "open_pr") {
906+
void openExistingPr();
907+
return;
908+
}
909+
if (quickAction.kind === "run_pull") {
910+
runPullWithToast();
865911
return;
866912
}
867913
if (quickAction.kind === "resolve_conflicts") {
@@ -880,7 +926,7 @@ export default function GitActionsControl({ gitCwd, activeThreadId }: GitActions
880926
if (quickAction.action) {
881927
void runGitActionWithToast({ action: quickAction.action });
882928
}
883-
}, [openConflictedFilesInEditor, openExistingPr, pullMutation, quickAction, threadToastData]);
929+
}, [openConflictedFilesInEditor, openExistingPr, quickAction, runPullWithToast, threadToastData]);
884930

885931
const openDialogForMenuItem = useCallback(
886932
(item: GitActionMenuItem) => {
@@ -964,6 +1010,41 @@ export default function GitActionsControl({ gitCwd, activeThreadId }: GitActions
9641010
</Button>
9651011
) : (
9661012
<Group aria-label="Git actions">
1013+
{syncAction ? (
1014+
<>
1015+
{syncActionDisabledReason ? (
1016+
<Popover>
1017+
<PopoverTrigger
1018+
openOnHover
1019+
render={
1020+
<Button
1021+
aria-disabled="true"
1022+
className="cursor-not-allowed opacity-64"
1023+
size="icon-xs"
1024+
variant="outline"
1025+
/>
1026+
}
1027+
>
1028+
<GitSyncActionIcon />
1029+
</PopoverTrigger>
1030+
<PopoverPopup tooltipStyle side="bottom" align="start">
1031+
{syncActionDisabledReason}
1032+
</PopoverPopup>
1033+
</Popover>
1034+
) : (
1035+
<Button
1036+
aria-label={syncAction.label}
1037+
title={syncAction.label}
1038+
size="icon-xs"
1039+
variant="outline"
1040+
onClick={runSyncAction}
1041+
>
1042+
<GitSyncActionIcon />
1043+
</Button>
1044+
)}
1045+
<GroupSeparator className="hidden @sm/header-actions:block" />
1046+
</>
1047+
) : null}
9671048
{quickActionDisabledReason ? (
9681049
<Popover>
9691050
<PopoverTrigger

0 commit comments

Comments
 (0)