feat: add one-click git sync button
Combine fetch, pull with rebase, and push into one sync action Keep remote dropdown focused on safe fetch actions Block sync when uncommitted changes would conflict with rebase
This commit is contained in:
@@ -70,7 +70,7 @@ import { generateCommitMessage as generateSessionCommitMessage, getGitWorktreeBo
|
||||
import { sessionEvents } from '@/lib/sessionEvents';
|
||||
import { useI18n } from '@/lib/i18n';
|
||||
|
||||
type SyncAction = 'fetch' | 'pull' | 'push' | null;
|
||||
type SyncAction = 'fetch' | 'pull' | 'push' | 'sync' | null;
|
||||
type CommitAction = 'commit' | 'commitAndPush' | null;
|
||||
type BranchOperation = 'merge' | 'rebase' | null;
|
||||
type ActionTab = 'commit' | 'branch' | 'pr' | 'worktree';
|
||||
@@ -911,6 +911,18 @@ export const GitView: React.FC = () => {
|
||||
setSyncAction(action);
|
||||
|
||||
try {
|
||||
const getPullOptions = (pullRemote: GitRemote) => {
|
||||
const trackingPrefix = `${pullRemote.name}/`;
|
||||
const trackedBranch = status?.tracking?.startsWith(trackingPrefix)
|
||||
? status.tracking.slice(trackingPrefix.length)
|
||||
: undefined;
|
||||
return {
|
||||
remote: pullRemote.name,
|
||||
branch: trackedBranch,
|
||||
rebase: true,
|
||||
};
|
||||
};
|
||||
|
||||
if (action === 'fetch') {
|
||||
if (!remote) {
|
||||
throw new Error('No remote available for fetch');
|
||||
@@ -921,7 +933,7 @@ export const GitView: React.FC = () => {
|
||||
if (!remote) {
|
||||
throw new Error('No remote available for pull');
|
||||
}
|
||||
const result = await git.gitPull(currentDirectory, { remote: remote.name });
|
||||
const result = await git.gitPull(currentDirectory, getPullOptions(remote));
|
||||
toast.success(
|
||||
result.files.length === 1
|
||||
? t('gitView.toast.pulledFilesSingle', { count: result.files.length, name: remote.name })
|
||||
@@ -930,6 +942,26 @@ export const GitView: React.FC = () => {
|
||||
} else if (action === 'push') {
|
||||
await git.gitPush(currentDirectory);
|
||||
toast.success(t('gitView.toast.pushedToUpstream'));
|
||||
} else if (action === 'sync') {
|
||||
if (!remote) {
|
||||
throw new Error('No remote available for sync');
|
||||
}
|
||||
await git.gitFetch(currentDirectory, { remote: remote.name });
|
||||
const afterFetch = await git.getGitStatus(currentDirectory);
|
||||
|
||||
if ((afterFetch.behind ?? 0) > 0) {
|
||||
if ((afterFetch.files?.length ?? 0) > 0) {
|
||||
toast.error(t('gitView.toast.commitOrStashBeforeSync'));
|
||||
return;
|
||||
}
|
||||
await git.gitPull(currentDirectory, getPullOptions(remote));
|
||||
}
|
||||
|
||||
const afterPull = await git.getGitStatus(currentDirectory);
|
||||
if ((afterPull.ahead ?? 0) > 0) {
|
||||
await git.gitPush(currentDirectory);
|
||||
}
|
||||
toast.success(t('gitView.toast.syncedChanges'));
|
||||
}
|
||||
|
||||
await refreshStatusAndBranches(false);
|
||||
@@ -938,7 +970,7 @@ export const GitView: React.FC = () => {
|
||||
const message =
|
||||
err instanceof Error
|
||||
? err.message
|
||||
: t('gitView.toast.syncActionFailed', { action: action === 'pull' ? t('gitView.sync.pull') : action });
|
||||
: t('gitView.toast.syncActionFailed', { action: action === 'sync' ? t('gitView.sync.syncChanges') : action === 'pull' ? t('gitView.sync.pull') : action });
|
||||
toast.error(message);
|
||||
} finally {
|
||||
setSyncAction(null);
|
||||
@@ -2025,8 +2057,7 @@ export const GitView: React.FC = () => {
|
||||
syncAction={syncAction}
|
||||
remotes={effectiveRemotes}
|
||||
onFetch={(remote) => handleSyncAction('fetch', remote)}
|
||||
onPull={(remote) => handleSyncAction('pull', remote)}
|
||||
onPush={() => handleSyncAction('push')}
|
||||
onSync={(remote) => handleSyncAction('sync', remote)}
|
||||
onRemoveRemote={handleRemoveRemote}
|
||||
removingRemoteName={removingRemoteName}
|
||||
onCheckoutBranch={handleCheckoutBranch}
|
||||
@@ -2124,17 +2155,18 @@ export const GitView: React.FC = () => {
|
||||
/>
|
||||
</>
|
||||
) : (
|
||||
<GitEmptyState
|
||||
behind={effectiveRemotes.length > 0 ? (status?.behind ?? 0) : 0}
|
||||
isPulling={syncAction === 'pull'}
|
||||
onPull={() => {
|
||||
const remote = effectiveRemotes[0];
|
||||
if (!remote) {
|
||||
return;
|
||||
}
|
||||
void handleSyncAction('pull', remote);
|
||||
}}
|
||||
/>
|
||||
<GitEmptyState
|
||||
ahead={effectiveRemotes.length > 0 ? (status?.ahead ?? 0) : 0}
|
||||
behind={effectiveRemotes.length > 0 ? (status?.behind ?? 0) : 0}
|
||||
isSyncing={syncAction === 'sync'}
|
||||
onSync={() => {
|
||||
const remote = effectiveRemotes[0];
|
||||
if (!remote) {
|
||||
return;
|
||||
}
|
||||
void handleSyncAction('sync', remote);
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
@@ -1,20 +1,23 @@
|
||||
import React from 'react';
|
||||
import { RiGitCommitLine, RiArrowDownLine, RiLoader4Line } from '@remixicon/react';
|
||||
import { RiGitCommitLine, RiRefreshLine, RiLoader4Line } from '@remixicon/react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { useI18n } from '@/lib/i18n';
|
||||
|
||||
interface GitEmptyStateProps {
|
||||
ahead: number;
|
||||
behind: number;
|
||||
onPull: () => void;
|
||||
isPulling: boolean;
|
||||
onSync: () => void;
|
||||
isSyncing: boolean;
|
||||
}
|
||||
|
||||
export const GitEmptyState: React.FC<GitEmptyStateProps> = ({
|
||||
ahead,
|
||||
behind,
|
||||
onPull,
|
||||
isPulling,
|
||||
onSync,
|
||||
isSyncing,
|
||||
}) => {
|
||||
const { t } = useI18n();
|
||||
const hasSyncChanges = ahead > 0 || behind > 0;
|
||||
return (
|
||||
<div className="flex flex-col items-center justify-center py-10 px-4 text-center">
|
||||
<RiGitCommitLine className="size-10 text-muted-foreground/70 mb-4" />
|
||||
@@ -25,20 +28,18 @@ export const GitEmptyState: React.FC<GitEmptyStateProps> = ({
|
||||
{t('gitView.empty.cleanDescription')}
|
||||
</p>
|
||||
|
||||
{behind > 0 && (
|
||||
{hasSyncChanges && (
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={onPull}
|
||||
disabled={isPulling}
|
||||
variant="default"
|
||||
onClick={onSync}
|
||||
disabled={isSyncing}
|
||||
>
|
||||
{isPulling ? (
|
||||
{isSyncing ? (
|
||||
<RiLoader4Line className="size-4 animate-spin" />
|
||||
) : (
|
||||
<RiArrowDownLine className="size-4" />
|
||||
<RiRefreshLine className="size-4" />
|
||||
)}
|
||||
{behind === 1
|
||||
? t('gitView.empty.pullBehindSingle', { count: behind })
|
||||
: t('gitView.empty.pullBehindPlural', { count: behind })}
|
||||
{t('gitView.sync.syncCounts', { ahead, behind })}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -26,7 +26,7 @@ import { SyncActions } from './SyncActions';
|
||||
import type { GitStatus, GitIdentityProfile, GitRemote } from '@/lib/api/types';
|
||||
import { useI18n } from '@/lib/i18n';
|
||||
|
||||
type SyncAction = 'fetch' | 'pull' | 'push' | null;
|
||||
type SyncAction = 'fetch' | 'pull' | 'push' | 'sync' | null;
|
||||
|
||||
interface GitHeaderProps {
|
||||
status: GitStatus | null;
|
||||
@@ -36,8 +36,7 @@ interface GitHeaderProps {
|
||||
syncAction: SyncAction;
|
||||
remotes: GitRemote[];
|
||||
onFetch: (remote: GitRemote) => void;
|
||||
onPull: (remote: GitRemote) => void;
|
||||
onPush: () => void;
|
||||
onSync: (remote: GitRemote) => void;
|
||||
onRemoveRemote: (remote: GitRemote) => void;
|
||||
removingRemoteName: string | null;
|
||||
onCheckoutBranch: (branch: string) => void;
|
||||
@@ -195,8 +194,7 @@ export const GitHeader: React.FC<GitHeaderProps> = ({
|
||||
syncAction,
|
||||
remotes,
|
||||
onFetch,
|
||||
onPull,
|
||||
onPush,
|
||||
onSync,
|
||||
onRemoveRemote,
|
||||
removingRemoteName,
|
||||
onCheckoutBranch,
|
||||
@@ -239,8 +237,7 @@ export const GitHeader: React.FC<GitHeaderProps> = ({
|
||||
syncAction={syncAction}
|
||||
remotes={remotes}
|
||||
onFetch={onFetch}
|
||||
onPull={onPull}
|
||||
onPush={onPush}
|
||||
onSync={onSync}
|
||||
onRemoveRemote={onRemoveRemote}
|
||||
removingRemoteName={removingRemoteName}
|
||||
disabled={!status}
|
||||
@@ -248,6 +245,8 @@ export const GitHeader: React.FC<GitHeaderProps> = ({
|
||||
|
||||
aheadCount={status.ahead}
|
||||
behindCount={status.behind}
|
||||
trackingRemoteName={status.tracking?.split('/')[0]}
|
||||
hasUncommittedChanges={(status.files?.length ?? 0) > 0}
|
||||
/>
|
||||
);
|
||||
|
||||
|
||||
@@ -1,10 +1,9 @@
|
||||
import React from 'react';
|
||||
import {
|
||||
RiRefreshLine,
|
||||
RiArrowDownLine,
|
||||
RiArrowUpLine,
|
||||
RiArrowDownSLine,
|
||||
RiCloseLine,
|
||||
RiLoader4Line,
|
||||
RiRefreshLine,
|
||||
} from '@remixicon/react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip';
|
||||
@@ -16,140 +15,98 @@ import {
|
||||
} from '@/components/ui/dropdown-menu';
|
||||
import type { GitRemote } from '@/lib/gitApi';
|
||||
import { useI18n } from '@/lib/i18n';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
type SyncAction = 'fetch' | 'pull' | 'push' | null;
|
||||
type SyncAction = 'fetch' | 'pull' | 'push' | 'sync' | null;
|
||||
|
||||
interface SyncActionsProps {
|
||||
syncAction: SyncAction;
|
||||
remotes: GitRemote[];
|
||||
onFetch: (remote: GitRemote) => void;
|
||||
onPull: (remote: GitRemote) => void;
|
||||
onPush: () => void;
|
||||
onSync: (remote: GitRemote) => void;
|
||||
onRemoveRemote?: (remote: GitRemote) => void;
|
||||
disabled: boolean;
|
||||
removingRemoteName?: string | null;
|
||||
iconOnly?: boolean;
|
||||
aheadCount?: number;
|
||||
behindCount?: number;
|
||||
trackingRemoteName?: string;
|
||||
hasUncommittedChanges?: boolean;
|
||||
}
|
||||
|
||||
export const SyncActions: React.FC<SyncActionsProps> = ({
|
||||
syncAction,
|
||||
remotes = [],
|
||||
onFetch,
|
||||
onPull,
|
||||
onPush,
|
||||
onSync,
|
||||
onRemoveRemote,
|
||||
disabled,
|
||||
removingRemoteName = null,
|
||||
iconOnly = false,
|
||||
aheadCount = 0,
|
||||
behindCount = 0,
|
||||
trackingRemoteName,
|
||||
hasUncommittedChanges = false,
|
||||
}) => {
|
||||
const { t } = useI18n();
|
||||
const skipRemoteSelectRef = React.useRef(false);
|
||||
const hasNoRemotes = remotes.length === 0;
|
||||
const isRemovingRemote = Boolean(removingRemoteName);
|
||||
const isDisabled = disabled || syncAction !== null || isRemovingRemote || hasNoRemotes;
|
||||
const hasMultipleRemotes = remotes.length > 1;
|
||||
const trackingRemote = remotes.find((remote) => remote.name === trackingRemoteName) ?? remotes[0];
|
||||
const blocksRebaseSync = behindCount > 0 && hasUncommittedChanges;
|
||||
const isPrimaryDisabled = disabled || syncAction !== null || isRemovingRemote || !trackingRemote || blocksRebaseSync;
|
||||
const isDropdownDisabled = disabled || syncAction !== null || isRemovingRemote || remotes.length === 0;
|
||||
const countsLabel = t('gitView.sync.syncCounts', { ahead: aheadCount, behind: behindCount });
|
||||
const tooltipLabel = blocksRebaseSync
|
||||
? t('gitView.sync.commitOrStashTooltip')
|
||||
: trackingRemote
|
||||
? t('gitView.sync.syncChangesTooltip', { ahead: aheadCount, behind: behindCount })
|
||||
: t('gitView.sync.noRemoteTooltip');
|
||||
|
||||
const handleFetch = () => {
|
||||
const remote = remotes[0];
|
||||
if (remotes.length === 1 && remote) {
|
||||
onFetch(remote);
|
||||
const handleSync = () => {
|
||||
if (!trackingRemote) {
|
||||
return;
|
||||
}
|
||||
onSync(trackingRemote);
|
||||
};
|
||||
|
||||
const handlePull = () => {
|
||||
const remote = remotes[0];
|
||||
if (remotes.length === 1 && remote) {
|
||||
onPull(remote);
|
||||
}
|
||||
};
|
||||
|
||||
const handlePush = () => {
|
||||
if (remotes.length >= 1) {
|
||||
onPush();
|
||||
}
|
||||
};
|
||||
|
||||
const renderButton = (
|
||||
action: SyncAction,
|
||||
icon: React.ReactNode,
|
||||
loadingIcon: React.ReactNode,
|
||||
label: string,
|
||||
onClick: () => void,
|
||||
tooltipText: string,
|
||||
counter?: number
|
||||
) => {
|
||||
const button = (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className={iconOnly ? 'relative h-8 w-8 px-0' : 'h-8 px-2'}
|
||||
onClick={onClick}
|
||||
disabled={isDisabled}
|
||||
>
|
||||
{syncAction === action ? loadingIcon : icon}
|
||||
{!iconOnly && <span className="git-header-label">{label}</span>}
|
||||
{!iconOnly && typeof counter === 'number' && counter > 0 ? (
|
||||
<span className="rounded-sm bg-interactive-selection/40 px-1 text-[10px] leading-4 text-foreground tabular-nums">
|
||||
{counter}
|
||||
</span>
|
||||
) : null}
|
||||
{iconOnly && typeof counter === 'number' && counter > 0 ? (
|
||||
<span className="absolute -right-1 -top-1 min-w-[1rem] rounded-full bg-interactive-selection px-1 text-[10px] leading-4 text-interactive-selection-foreground tabular-nums">
|
||||
{counter}
|
||||
</span>
|
||||
) : null}
|
||||
</Button>
|
||||
);
|
||||
|
||||
return (
|
||||
return (
|
||||
<div className="inline-flex items-center rounded-[9px] [corner-shape:squircle] supports-[corner-shape:squircle]:rounded-[50px] border border-border/60 bg-[var(--surface-elevated)] overflow-hidden">
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>{button}</TooltipTrigger>
|
||||
<TooltipContent sideOffset={8}>{tooltipText}</TooltipContent>
|
||||
<TooltipTrigger asChild>
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleSync}
|
||||
disabled={isPrimaryDisabled}
|
||||
className={cn(
|
||||
'inline-flex h-7 items-center gap-1.5 px-2 typography-ui-label font-medium text-foreground',
|
||||
'transition-colors hover:bg-interactive-hover focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary disabled:cursor-not-allowed disabled:opacity-50'
|
||||
)}
|
||||
aria-label={t('gitView.sync.syncChanges')}
|
||||
>
|
||||
{syncAction === 'sync' ? (
|
||||
<RiLoader4Line className="size-4 animate-spin" />
|
||||
) : (
|
||||
<RiRefreshLine className="size-4" />
|
||||
)}
|
||||
<span className="whitespace-nowrap tabular-nums">{countsLabel}</span>
|
||||
</button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent sideOffset={8}>{tooltipLabel}</TooltipContent>
|
||||
</Tooltip>
|
||||
);
|
||||
};
|
||||
|
||||
const renderDropdownButton = (
|
||||
action: SyncAction,
|
||||
icon: React.ReactNode,
|
||||
loadingIcon: React.ReactNode,
|
||||
label: string,
|
||||
onSelect: (remote: GitRemote) => void,
|
||||
tooltipText: string,
|
||||
counter?: number
|
||||
) => {
|
||||
return (
|
||||
<DropdownMenu>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className={iconOnly ? 'relative h-8 w-8 px-0' : 'h-8 px-2'}
|
||||
disabled={isDisabled}
|
||||
>
|
||||
{syncAction === action ? loadingIcon : icon}
|
||||
{!iconOnly && <span className="git-header-label">{label}</span>}
|
||||
{!iconOnly && typeof counter === 'number' && counter > 0 ? (
|
||||
<span className="rounded-sm bg-interactive-selection/40 px-1 text-[10px] leading-4 text-foreground tabular-nums">
|
||||
{counter}
|
||||
</span>
|
||||
) : null}
|
||||
{iconOnly && typeof counter === 'number' && counter > 0 ? (
|
||||
<span className="absolute -right-1 -top-1 min-w-[1rem] rounded-full bg-interactive-selection px-1 text-[10px] leading-4 text-interactive-selection-foreground tabular-nums">
|
||||
{counter}
|
||||
</span>
|
||||
) : null}
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent sideOffset={8}>{tooltipText}</TooltipContent>
|
||||
</Tooltip>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<button
|
||||
type="button"
|
||||
className={cn(
|
||||
'inline-flex h-7 w-6 items-center justify-center border-l border-[var(--interactive-border)] text-muted-foreground',
|
||||
'transition-colors hover:bg-interactive-hover hover:text-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary disabled:cursor-not-allowed disabled:opacity-50'
|
||||
)}
|
||||
disabled={isDropdownDisabled}
|
||||
aria-label={t('gitView.sync.moreActionsAria')}
|
||||
>
|
||||
<RiArrowDownSLine className="size-4" />
|
||||
</button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="start" alignOffset={-40} className="w-[min(360px,calc(100vw-2rem))] max-h-[320px] overflow-y-auto">
|
||||
{remotes.map((remote) => (
|
||||
<DropdownMenuItem
|
||||
@@ -160,21 +117,22 @@ export const SyncActions: React.FC<SyncActionsProps> = ({
|
||||
skipRemoteSelectRef.current = false;
|
||||
return;
|
||||
}
|
||||
onSelect(remote);
|
||||
onFetch(remote);
|
||||
}}
|
||||
>
|
||||
<div className="flex w-full items-center gap-2">
|
||||
<RiRefreshLine className="size-4 text-muted-foreground" />
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="flex flex-col">
|
||||
<span className="typography-ui-label text-foreground">
|
||||
{remote.name}
|
||||
{t('gitView.sync.fetchFromRemote', { name: remote.name })}
|
||||
</span>
|
||||
<span className="typography-meta text-muted-foreground truncate">
|
||||
{remote.fetchUrl}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
{onRemoveRemote ? (
|
||||
{onRemoveRemote && remote.name !== trackingRemoteName ? (
|
||||
<Button
|
||||
type="button"
|
||||
variant="destructive"
|
||||
@@ -207,64 +165,6 @@ export const SyncActions: React.FC<SyncActionsProps> = ({
|
||||
))}
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex items-center gap-0.5">
|
||||
{hasMultipleRemotes
|
||||
? renderDropdownButton(
|
||||
'fetch',
|
||||
<RiRefreshLine className="size-4" />,
|
||||
<RiLoader4Line className="size-4 animate-spin" />,
|
||||
t('gitView.sync.fetch'),
|
||||
onFetch,
|
||||
t('gitView.sync.fetchTooltip')
|
||||
)
|
||||
: renderButton(
|
||||
'fetch',
|
||||
<RiRefreshLine className="size-4" />,
|
||||
<RiLoader4Line className="size-4 animate-spin" />,
|
||||
t('gitView.sync.fetch'),
|
||||
handleFetch,
|
||||
t('gitView.sync.fetchTooltip')
|
||||
)}
|
||||
|
||||
{hasMultipleRemotes
|
||||
? renderDropdownButton(
|
||||
'pull',
|
||||
<RiArrowDownLine className="size-4" />,
|
||||
<RiLoader4Line className="size-4 animate-spin" />,
|
||||
t('gitView.sync.pull'),
|
||||
onPull,
|
||||
behindCount > 0
|
||||
? t('gitView.sync.pullTooltipBehind', { count: behindCount })
|
||||
: t('gitView.sync.pullTooltip'),
|
||||
behindCount
|
||||
)
|
||||
: renderButton(
|
||||
'pull',
|
||||
<RiArrowDownLine className="size-4" />,
|
||||
<RiLoader4Line className="size-4 animate-spin" />,
|
||||
t('gitView.sync.pull'),
|
||||
handlePull,
|
||||
behindCount > 0
|
||||
? t('gitView.sync.pullTooltipBehind', { count: behindCount })
|
||||
: t('gitView.sync.pullTooltip'),
|
||||
behindCount
|
||||
)}
|
||||
|
||||
{renderButton(
|
||||
'push',
|
||||
<RiArrowUpLine className="size-4" />,
|
||||
<RiLoader4Line className="size-4 animate-spin" />,
|
||||
t('gitView.sync.push'),
|
||||
handlePush,
|
||||
aheadCount > 0
|
||||
? t('gitView.sync.pushTooltipAhead', { count: aheadCount })
|
||||
: t('gitView.sync.pushTooltip'),
|
||||
aheadCount
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -203,6 +203,12 @@ export interface GitPullResult {
|
||||
deletions: number;
|
||||
}
|
||||
|
||||
export interface GitPullOptions {
|
||||
remote?: string;
|
||||
branch?: string;
|
||||
rebase?: boolean;
|
||||
}
|
||||
|
||||
export interface GitRemote {
|
||||
name: string;
|
||||
fetchUrl: string;
|
||||
@@ -421,7 +427,7 @@ export interface GitAPI {
|
||||
deleteGitWorktree?(directory: string, payload: RemoveGitWorktreePayload): Promise<{ success: boolean }>;
|
||||
createGitCommit(directory: string, message: string, options?: CreateGitCommitOptions): Promise<GitCommitResult>;
|
||||
gitPush(directory: string, options?: { remote?: string; branch?: string; options?: string[] | Record<string, unknown> }): Promise<GitPushResult>;
|
||||
gitPull(directory: string, options?: { remote?: string; branch?: string }): Promise<GitPullResult>;
|
||||
gitPull(directory: string, options?: GitPullOptions): Promise<GitPullResult>;
|
||||
gitFetch(directory: string, options?: { remote?: string; branch?: string }): Promise<{ success: boolean }>;
|
||||
checkoutBranch(directory: string, branch: string): Promise<{ success: boolean; branch: string }>;
|
||||
createBranch(directory: string, name: string, startPoint?: string): Promise<{ success: boolean; branch: string }>;
|
||||
|
||||
@@ -530,7 +530,7 @@ export async function gitPush(
|
||||
|
||||
export async function gitPull(
|
||||
directory: string,
|
||||
options: { remote?: string; branch?: string } = {}
|
||||
options: import('./api/types').GitPullOptions = {}
|
||||
): Promise<import('./api/types').GitPullResult> {
|
||||
const runtime = getRuntimeGit();
|
||||
if (runtime) return runtime.gitPull(directory, options);
|
||||
|
||||
@@ -20,6 +20,7 @@ import type {
|
||||
GitCommitResult,
|
||||
GitPushResult,
|
||||
GitPullResult,
|
||||
GitPullOptions,
|
||||
GitLogOptions,
|
||||
GitLogResponse,
|
||||
GitCommitFilesResponse,
|
||||
@@ -518,7 +519,7 @@ export async function gitPush(
|
||||
|
||||
export async function gitPull(
|
||||
directory: string,
|
||||
options: { remote?: string; branch?: string } = {}
|
||||
options: GitPullOptions = {}
|
||||
): Promise<GitPullResult> {
|
||||
const response = await fetch(buildUrl(`${API_BASE}/pull`, directory), {
|
||||
method: 'POST',
|
||||
|
||||
@@ -518,13 +518,21 @@ export const dict = {
|
||||
'gitView.stash.thisWill': 'This will:',
|
||||
'gitView.stash.title': 'Uncommitted Changes',
|
||||
'gitView.sync.fetch': 'Fetch',
|
||||
'gitView.sync.fetchFromRemote': 'Fetch from {name}',
|
||||
'gitView.sync.fetchTooltip': 'Fetch from remote',
|
||||
'gitView.sync.commitOrStashTooltip': 'Commit or stash your changes before syncing',
|
||||
'gitView.sync.moreActionsAria': 'More sync actions',
|
||||
'gitView.sync.noRemoteTooltip': 'No remotes configured',
|
||||
'gitView.sync.pull': 'Pull',
|
||||
'gitView.sync.pullTooltip': 'Pull changes',
|
||||
'gitView.sync.pullTooltipBehind': 'Pull changes ({count} behind)',
|
||||
'gitView.sync.push': 'Push',
|
||||
'gitView.sync.pushTooltip': 'Push changes',
|
||||
'gitView.sync.pushTooltipAhead': 'Push changes ({count} ahead)',
|
||||
'gitView.sync.syncChanges': 'Sync Changes',
|
||||
'gitView.sync.syncChangesTooltip': 'Sync Changes ({behind} down, {ahead} up)',
|
||||
'gitView.sync.syncChangesWithCounts': 'Sync Changes {behind}↓ {ahead}↑',
|
||||
'gitView.sync.syncCounts': '{behind}↓ {ahead}↑',
|
||||
'gitView.branch.actionsUnavailable': 'Branch actions unavailable in this repository state',
|
||||
'gitView.conflict.noDetailsAvailable': 'No conflict details available',
|
||||
'gitView.empty.notGitRepository': 'This directory is not a Git repository',
|
||||
@@ -668,6 +676,8 @@ export const dict = {
|
||||
'gitView.toast.pulledFilesPlural': 'Pulled {count} files from {name}',
|
||||
'gitView.toast.pulledFilesSingle': 'Pulled {count} file from {name}',
|
||||
'gitView.toast.pushedToUpstream': 'Pushed to upstream',
|
||||
'gitView.toast.commitOrStashBeforeSync': 'Commit or stash your changes before syncing',
|
||||
'gitView.toast.syncedChanges': 'Synced changes',
|
||||
'gitView.toast.rebaseAborted': 'Rebase aborted',
|
||||
'gitView.toast.rebaseConflictsDetected': 'Rebase conflicts detected',
|
||||
'gitView.toast.rebaseStepCompleted': 'Rebase step completed',
|
||||
|
||||
@@ -519,13 +519,21 @@ export const dict: Record<I18nKey, string> = {
|
||||
"gitView.stash.thisWill": "Esto hará:",
|
||||
"gitView.stash.title": "Cambios sin commit",
|
||||
"gitView.sync.fetch": "Fetch",
|
||||
"gitView.sync.fetchFromRemote": "Fetch de {name}",
|
||||
"gitView.sync.fetchTooltip": "Fetch del remoto",
|
||||
"gitView.sync.commitOrStashTooltip": "Haz commit o stash de tus cambios antes de sincronizar",
|
||||
"gitView.sync.moreActionsAria": "Más acciones de sincronización",
|
||||
"gitView.sync.noRemoteTooltip": "No hay remotos configurados",
|
||||
"gitView.sync.pull": "Pull",
|
||||
"gitView.sync.pullTooltip": "Hacer pull",
|
||||
"gitView.sync.pullTooltipBehind": "Hacer pull ({count} detrás)",
|
||||
"gitView.sync.push": "Push",
|
||||
"gitView.sync.pushTooltip": "Hacer push",
|
||||
"gitView.sync.pushTooltipAhead": "Hacer push ({count} por delante)",
|
||||
"gitView.sync.syncChanges": "Sync Changes",
|
||||
"gitView.sync.syncChangesTooltip": "Sync Changes ({behind} abajo, {ahead} arriba)",
|
||||
"gitView.sync.syncChangesWithCounts": "Sync Changes {behind}↓ {ahead}↑",
|
||||
"gitView.sync.syncCounts": "{behind}↓ {ahead}↑",
|
||||
"gitView.branch.actionsUnavailable": "Acciones de rama no disponibles en este estado del repositorio",
|
||||
"gitView.conflict.noDetailsAvailable": "No hay detalles de conflicto disponibles",
|
||||
"gitView.empty.notGitRepository": "Este directorio no es un repositorio Git",
|
||||
@@ -669,6 +677,8 @@ export const dict: Record<I18nKey, string> = {
|
||||
"gitView.toast.pulledFilesPlural": "Se trajeron {count} archivos de {name}",
|
||||
"gitView.toast.pulledFilesSingle": "Se trajo {count} archivo de {name}",
|
||||
"gitView.toast.pushedToUpstream": "Enviado al upstream",
|
||||
"gitView.toast.commitOrStashBeforeSync": "Haz commit o stash de tus cambios antes de sincronizar",
|
||||
"gitView.toast.syncedChanges": "Cambios sincronizados",
|
||||
"gitView.toast.rebaseAborted": "Rebase abortado",
|
||||
"gitView.toast.rebaseConflictsDetected": "Se detectaron conflictos al hacer rebase",
|
||||
"gitView.toast.rebaseStepCompleted": "Paso de rebase completado",
|
||||
|
||||
@@ -519,13 +519,21 @@ export const dict: Record<I18nKey, string> = {
|
||||
'gitView.stash.thisWill': '다음 작업을 수행합니다:',
|
||||
'gitView.stash.title': '커밋하지 않은 변경 사항',
|
||||
'gitView.sync.fetch': '가져오기',
|
||||
'gitView.sync.fetchFromRemote': '{name}에서 가져오기',
|
||||
'gitView.sync.fetchTooltip': '리모트에서 가져오기',
|
||||
'gitView.sync.commitOrStashTooltip': '동기화하기 전에 변경 사항을 커밋하거나 stash하세요',
|
||||
'gitView.sync.moreActionsAria': '더 많은 동기화 작업',
|
||||
'gitView.sync.noRemoteTooltip': '설정된 리모트가 없습니다',
|
||||
'gitView.sync.pull': '풀',
|
||||
'gitView.sync.pullTooltip': '변경 사항 풀',
|
||||
'gitView.sync.pullTooltipBehind': '변경 사항 풀({count}개 뒤처짐)',
|
||||
'gitView.sync.push': '푸시',
|
||||
'gitView.sync.pushTooltip': '변경 사항 푸시',
|
||||
'gitView.sync.pushTooltipAhead': '변경 사항 푸시({count}개 앞섬)',
|
||||
'gitView.sync.syncChanges': 'Sync Changes',
|
||||
'gitView.sync.syncChangesTooltip': 'Sync Changes({behind}개 내려받기, {ahead}개 올리기)',
|
||||
'gitView.sync.syncChangesWithCounts': 'Sync Changes {behind}↓ {ahead}↑',
|
||||
'gitView.sync.syncCounts': '{behind}↓ {ahead}↑',
|
||||
'gitView.branch.actionsUnavailable': '현재 레포지토리 상태에서는 브랜치 작업을 사용할 수 없음',
|
||||
'gitView.conflict.noDetailsAvailable': '충돌 상세 정보 없음',
|
||||
'gitView.empty.notGitRepository': '이 디렉터리는 Git 레포지토리가 아닙니다',
|
||||
@@ -669,6 +677,8 @@ export const dict: Record<I18nKey, string> = {
|
||||
'gitView.toast.pulledFilesPlural': '{name}에서 파일 {count}개를 풀했습니다',
|
||||
'gitView.toast.pulledFilesSingle': '{name}에서 파일 {count}개를 풀했습니다',
|
||||
'gitView.toast.pushedToUpstream': '업스트림에 푸시했습니다',
|
||||
'gitView.toast.commitOrStashBeforeSync': '동기화하기 전에 변경 사항을 커밋하거나 stash하세요',
|
||||
'gitView.toast.syncedChanges': '변경 사항을 동기화했습니다',
|
||||
'gitView.toast.rebaseAborted': 'rebase 중단됨',
|
||||
'gitView.toast.rebaseConflictsDetected': '리베이스 충돌이 감지되었습니다',
|
||||
'gitView.toast.rebaseStepCompleted': '리베이스 단계 완료됨',
|
||||
|
||||
@@ -519,13 +519,21 @@ export const dict: Record<I18nKey, string> = {
|
||||
"gitView.stash.thisWill": "Isso fará:",
|
||||
"gitView.stash.title": "Alterações sem commit",
|
||||
"gitView.sync.fetch": "Fetch",
|
||||
"gitView.sync.fetchFromRemote": "Fetch de {name}",
|
||||
"gitView.sync.fetchTooltip": "Fetch do remoto",
|
||||
"gitView.sync.commitOrStashTooltip": "Faça commit ou stash das alterações antes de sincronizar",
|
||||
"gitView.sync.moreActionsAria": "Mais ações de sincronização",
|
||||
"gitView.sync.noRemoteTooltip": "Nenhum remoto configurado",
|
||||
"gitView.sync.pull": "Pull",
|
||||
"gitView.sync.pullTooltip": "Fazer pull",
|
||||
"gitView.sync.pullTooltipBehind": "Fazer pull ({count} detrás)",
|
||||
"gitView.sync.push": "Push",
|
||||
"gitView.sync.pushTooltip": "Fazer push",
|
||||
"gitView.sync.pushTooltipAhead": "Fazer push ({count} por delante)",
|
||||
"gitView.sync.syncChanges": "Sync Changes",
|
||||
"gitView.sync.syncChangesTooltip": "Sync Changes ({behind} abaixo, {ahead} acima)",
|
||||
"gitView.sync.syncChangesWithCounts": "Sync Changes {behind}↓ {ahead}↑",
|
||||
"gitView.sync.syncCounts": "{behind}↓ {ahead}↑",
|
||||
"gitView.branch.actionsUnavailable": "Ações de branch não disponíveis neste estado do repositório",
|
||||
"gitView.conflict.noDetailsAvailable": "Não há detalhes de conflito disponíveis",
|
||||
"gitView.empty.notGitRepository": "Este diretório não é um repositório Git",
|
||||
@@ -669,6 +677,8 @@ export const dict: Record<I18nKey, string> = {
|
||||
"gitView.toast.pulledFilesPlural": "Se trajeron {count} arquivos de {name}",
|
||||
"gitView.toast.pulledFilesSingle": "Se trajo {count} arquivo de {name}",
|
||||
"gitView.toast.pushedToUpstream": "Enviado ao upstream",
|
||||
"gitView.toast.commitOrStashBeforeSync": "Faça commit ou stash das alterações antes de sincronizar",
|
||||
"gitView.toast.syncedChanges": "Alterações sincronizadas",
|
||||
"gitView.toast.rebaseAborted": "Rebase abortado",
|
||||
"gitView.toast.rebaseConflictsDetected": "Se detectaron conflitos ao hacer rebase",
|
||||
"gitView.toast.rebaseStepCompleted": "Paso de rebase concluído",
|
||||
|
||||
@@ -519,13 +519,21 @@ export const dict: Record<I18nKey, string> = {
|
||||
"gitView.stash.thisWill": "Це:",
|
||||
"gitView.stash.title": "Незакомічені зміни",
|
||||
"gitView.sync.fetch": "Fetch",
|
||||
"gitView.sync.fetchFromRemote": "Fetch з {name}",
|
||||
"gitView.sync.fetchTooltip": "Отримати з віддаленого",
|
||||
"gitView.sync.commitOrStashTooltip": "Закомітьте або сховайте зміни перед синхронізацією",
|
||||
"gitView.sync.moreActionsAria": "Більше дій синхронізації",
|
||||
"gitView.sync.noRemoteTooltip": "Віддалені репозиторії не налаштовані",
|
||||
"gitView.sync.pull": "Pull",
|
||||
"gitView.sync.pullTooltip": "Отримати зміни",
|
||||
"gitView.sync.pullTooltipBehind": "Pull змін ({count} позаду)",
|
||||
"gitView.sync.push": "Push",
|
||||
"gitView.sync.pushTooltip": "Push змін",
|
||||
"gitView.sync.pushTooltipAhead": "Push змін ({count} попереду)",
|
||||
"gitView.sync.syncChanges": "Sync Changes",
|
||||
"gitView.sync.syncChangesTooltip": "Sync Changes ({behind} вниз, {ahead} вгору)",
|
||||
"gitView.sync.syncChangesWithCounts": "Sync Changes {behind}↓ {ahead}↑",
|
||||
"gitView.sync.syncCounts": "{behind}↓ {ahead}↑",
|
||||
"gitView.branch.actionsUnavailable": "Дії з гілками недоступні в цьому стані сховища",
|
||||
"gitView.conflict.noDetailsAvailable": "Деталі конфлікту відсутні",
|
||||
"gitView.empty.notGitRepository": "Цей каталог не є репозиторієм Git",
|
||||
@@ -669,6 +677,8 @@ export const dict: Record<I18nKey, string> = {
|
||||
"gitView.toast.pulledFilesPlural": "Отримано файлів: {count} з {name}",
|
||||
"gitView.toast.pulledFilesSingle": "Отримано файл: {count} з {name}",
|
||||
"gitView.toast.pushedToUpstream": "Надіслано в upstream",
|
||||
"gitView.toast.commitOrStashBeforeSync": "Закомітьте або сховайте зміни перед синхронізацією",
|
||||
"gitView.toast.syncedChanges": "Зміни синхронізовано",
|
||||
"gitView.toast.rebaseAborted": "Перебазування перервано",
|
||||
"gitView.toast.rebaseConflictsDetected": "Виявлено конфлікти перебазування",
|
||||
"gitView.toast.rebaseStepCompleted": "Етап перебазування завершено",
|
||||
|
||||
@@ -519,13 +519,21 @@ export const dict: Record<I18nKey, string> = {
|
||||
'gitView.stash.thisWill': '这将会:',
|
||||
'gitView.stash.title': '未提交的更改',
|
||||
'gitView.sync.fetch': '获取',
|
||||
'gitView.sync.fetchFromRemote': '从 {name} 获取',
|
||||
'gitView.sync.fetchTooltip': '从远程获取',
|
||||
'gitView.sync.commitOrStashTooltip': '同步前请先提交或储藏你的更改',
|
||||
'gitView.sync.moreActionsAria': '更多同步操作',
|
||||
'gitView.sync.noRemoteTooltip': '未配置远程',
|
||||
'gitView.sync.pull': '拉取',
|
||||
'gitView.sync.pullTooltip': '拉取更改',
|
||||
'gitView.sync.pullTooltipBehind': '拉取更改(落后 {count})',
|
||||
'gitView.sync.push': '推送',
|
||||
'gitView.sync.pushTooltip': '推送更改',
|
||||
'gitView.sync.pushTooltipAhead': '推送更改(领先 {count})',
|
||||
'gitView.sync.syncChanges': 'Sync Changes',
|
||||
'gitView.sync.syncChangesTooltip': 'Sync Changes({behind} 下,{ahead} 上)',
|
||||
'gitView.sync.syncChangesWithCounts': 'Sync Changes {behind}↓ {ahead}↑',
|
||||
'gitView.sync.syncCounts': '{behind}↓ {ahead}↑',
|
||||
'gitView.branch.actionsUnavailable': '当前仓库状态下分支操作不可用',
|
||||
'gitView.conflict.noDetailsAvailable': '没有可用的冲突详情',
|
||||
'gitView.empty.notGitRepository': '当前目录不是 Git 仓库',
|
||||
@@ -669,6 +677,8 @@ export const dict: Record<I18nKey, string> = {
|
||||
'gitView.toast.pulledFilesPlural': '已从 {name} 拉取 {count} 个文件',
|
||||
'gitView.toast.pulledFilesSingle': '已从 {name} 拉取 {count} 个文件',
|
||||
'gitView.toast.pushedToUpstream': '已推送到上游',
|
||||
'gitView.toast.commitOrStashBeforeSync': '同步前请先提交或储藏你的更改',
|
||||
'gitView.toast.syncedChanges': '已同步更改',
|
||||
'gitView.toast.rebaseAborted': '变基已中止',
|
||||
'gitView.toast.rebaseConflictsDetected': '检测到变基冲突',
|
||||
'gitView.toast.rebaseStepCompleted': '变基步骤已完成',
|
||||
|
||||
@@ -253,14 +253,15 @@ export async function handleStandardGitBridgeMessage(message: BridgeMessageInput
|
||||
}
|
||||
|
||||
case 'api:git/pull': {
|
||||
const { directory, remote, branch } = (payload || {}) as {
|
||||
const { directory, remote, branch, rebase } = (payload || {}) as {
|
||||
directory?: string;
|
||||
remote?: string;
|
||||
branch?: string;
|
||||
rebase?: boolean;
|
||||
};
|
||||
const dirError = requireDirectory(id, type, directory);
|
||||
if (dirError) return dirError;
|
||||
const result = await gitService.gitPull(directory!, { remote, branch });
|
||||
const result = await gitService.gitPull(directory!, { remote, branch, rebase });
|
||||
return { id, type, success: true, data: result };
|
||||
}
|
||||
|
||||
|
||||
@@ -2360,11 +2360,11 @@ export async function gitPush(
|
||||
*/
|
||||
export async function gitPull(
|
||||
directory: string,
|
||||
options?: { remote?: string; branch?: string }
|
||||
options?: { remote?: string; branch?: string; rebase?: boolean }
|
||||
): Promise<{ success: boolean; summary: { changes: number; insertions: number; deletions: number }; files: string[]; insertions: number; deletions: number }> {
|
||||
const repo = await getRepository(directory);
|
||||
|
||||
if (repo) {
|
||||
if (repo && options?.rebase !== true) {
|
||||
try {
|
||||
await repo.pull();
|
||||
return {
|
||||
@@ -2381,10 +2381,14 @@ export async function gitPull(
|
||||
|
||||
// Fallback to raw git
|
||||
const args = ['pull'];
|
||||
if (options?.rebase === true) args.push('--rebase');
|
||||
if (options?.remote) args.push(options.remote);
|
||||
if (options?.branch) args.push(options.branch);
|
||||
|
||||
const result = await execGit(args, directory);
|
||||
if (result.exitCode !== 0) {
|
||||
throw new Error(result.stderr.trim() || result.stdout.trim() || 'Failed to pull from remote');
|
||||
}
|
||||
|
||||
return {
|
||||
success: result.exitCode === 0,
|
||||
|
||||
@@ -193,11 +193,12 @@ export const createVSCodeGitAPI = (): GitAPI => ({
|
||||
});
|
||||
},
|
||||
|
||||
gitPull: async (directory: string, options?: { remote?: string; branch?: string }): Promise<GitPullResult> => {
|
||||
gitPull: async (directory: string, options?: { remote?: string; branch?: string; rebase?: boolean }): Promise<GitPullResult> => {
|
||||
return sendBridgeMessage<GitPullResult>('api:git/pull', {
|
||||
directory,
|
||||
remote: options?.remote,
|
||||
branch: options?.branch,
|
||||
rebase: options?.rebase,
|
||||
});
|
||||
},
|
||||
|
||||
|
||||
@@ -1804,12 +1804,15 @@ export async function collectDiffs(directory, files = []) {
|
||||
|
||||
export async function pull(directory, options = {}) {
|
||||
const git = await createGit(directory);
|
||||
const pullOptions = options.rebase === true
|
||||
? { ...(options.options && typeof options.options === 'object' && !Array.isArray(options.options) ? options.options : {}), '--rebase': null }
|
||||
: options.options || {};
|
||||
|
||||
try {
|
||||
const result = await git.pull(
|
||||
options.remote || 'origin',
|
||||
options.branch,
|
||||
options.options || {}
|
||||
pullOptions
|
||||
);
|
||||
|
||||
return {
|
||||
|
||||
Reference in New Issue
Block a user