fix: make branch updates use latest remote refs

Fetch remote targets before merge or rebase
Prefer remote branch targets over stale local branches
Fix Update copy and branch search input
This commit is contained in:
Bohdan Triapitsyn
2026-05-06 00:32:55 +03:00
parent 8540f51395
commit 32929d592e
7 changed files with 108 additions and 66 deletions
+52 -30
View File
@@ -1396,6 +1396,12 @@ export const GitView: React.FC = () => {
return 'main';
}, [effectiveRemotes, localBranches, rootBranchHint, worktreeMetadata?.createdFromBranch]);
const updateTargetBranch = React.useMemo(() => {
const remoteNames = effectiveRemotes.map((remote) => remote.name);
const remoteCandidates = remoteNames.map((remote) => `${remote}/${baseBranch}`);
return remoteCandidates.find((candidate) => remoteBranches.includes(candidate)) ?? baseBranch;
}, [baseBranch, effectiveRemotes, remoteBranches]);
const availableIdentities = React.useMemo(() => {
const unique = new Map<string, GitIdentityProfile>();
if (globalIdentity) {
@@ -1762,6 +1768,29 @@ export const GitView: React.FC = () => {
setBranchOperation(null);
}, []);
const resolveIntegrationTarget = React.useCallback((branch: string) => {
const trimmed = branch.trim();
const knownRemoteNames = new Set(effectiveRemotes.map((remote) => remote.name));
const slashIndex = trimmed.indexOf('/');
if (slashIndex > 0) {
const remote = trimmed.slice(0, slashIndex);
const remoteBranch = trimmed.slice(slashIndex + 1);
if (knownRemoteNames.has(remote) && remoteBranch) {
return { branch: trimmed, remote, remoteBranch };
}
}
for (const remote of effectiveRemotes) {
const remoteCandidate = `${remote.name}/${trimmed}`;
if (remoteBranches.includes(remoteCandidate)) {
return { branch: remoteCandidate, remote: remote.name, remoteBranch: trimmed };
}
}
return { branch: trimmed, remote: null, remoteBranch: null };
}, [effectiveRemotes, remoteBranches]);
const handleMerge = React.useCallback(
async (branch: string) => {
if (!currentDirectory) return;
@@ -1770,21 +1799,17 @@ export const GitView: React.FC = () => {
const currentBranch = status?.current;
const knownRemoteNames = new Set(effectiveRemotes.map((r) => r.name));
const target = resolveIntegrationTarget(branch);
try {
// If it's a remote-tracking branch (prefix matches a known remote), fetch latest first
const slashIndex = branch.indexOf('/');
if (slashIndex > 0 && knownRemoteNames.has(branch.substring(0, slashIndex))) {
const remote = branch.substring(0, slashIndex);
const remoteBranch = branch.substring(slashIndex + 1);
addOperationLog(`Fetching ${remote}/${remoteBranch}...`, 'running');
await git.gitFetch(currentDirectory, { remote, branch: remoteBranch });
updateLastLog('done', `Fetched ${remote}/${remoteBranch}`);
if (target.remote && target.remoteBranch) {
addOperationLog(`Fetching ${target.remote}/${target.remoteBranch}...`, 'running');
await git.gitFetch(currentDirectory, { remote: target.remote, branch: target.remoteBranch });
updateLastLog('done', `Fetched ${target.remote}/${target.remoteBranch}`);
}
addOperationLog(`Merging ${branch} into ${currentBranch}...`, 'running');
const result = await git.merge(currentDirectory, { branch });
addOperationLog(`Merging ${target.branch} into ${currentBranch}...`, 'running');
const result = await git.merge(currentDirectory, { branch: target.branch });
if (result.conflict) {
updateLastLog('error', `Merge conflicts detected`);
@@ -1793,7 +1818,7 @@ export const GitView: React.FC = () => {
setConflictDialogOpen(true);
persistConflictState(currentDirectory, result.conflictFiles ?? [], 'merge');
} else {
updateLastLog('done', `Merged ${branch} into ${currentBranch}`);
updateLastLog('done', `Merged ${target.branch} into ${currentBranch}`);
clearConflictState();
addOperationLog('Refreshing repository status...', 'running');
await refreshStatusAndBranches();
@@ -1804,16 +1829,16 @@ export const GitView: React.FC = () => {
if (isUncommittedChangesError(err)) {
updateLastLog('error', 'Uncommitted changes detected');
setStashDialogOperation('merge');
setStashDialogBranch(branch);
setStashDialogBranch(target.branch);
setStashDialogOpen(true);
} else {
const message = err instanceof Error ? err.message : `Failed to merge ${branch}`;
const message = err instanceof Error ? err.message : `Failed to merge ${target.branch}`;
updateLastLog('error', message);
}
}
// Note: branchOperation is cleared when dialog closes via handleOperationComplete
},
[currentDirectory, git, status, effectiveRemotes, refreshStatusAndBranches, refreshLog, isUncommittedChangesError, persistConflictState, clearConflictState, addOperationLog, updateLastLog, resetOperationLogs]
[currentDirectory, git, status, resolveIntegrationTarget, refreshStatusAndBranches, refreshLog, isUncommittedChangesError, persistConflictState, clearConflictState, addOperationLog, updateLastLog, resetOperationLogs]
);
const handleRebase = React.useCallback(
@@ -1824,21 +1849,17 @@ export const GitView: React.FC = () => {
const currentBranch = status?.current;
const knownRemoteNames = new Set(effectiveRemotes.map((r) => r.name));
const target = resolveIntegrationTarget(branch);
try {
// If it's a remote-tracking branch (prefix matches a known remote), fetch latest first
const slashIndex = branch.indexOf('/');
if (slashIndex > 0 && knownRemoteNames.has(branch.substring(0, slashIndex))) {
const remote = branch.substring(0, slashIndex);
const remoteBranch = branch.substring(slashIndex + 1);
addOperationLog(`Fetching ${remote}/${remoteBranch}...`, 'running');
await git.gitFetch(currentDirectory, { remote, branch: remoteBranch });
updateLastLog('done', `Fetched ${remote}/${remoteBranch}`);
if (target.remote && target.remoteBranch) {
addOperationLog(`Fetching ${target.remote}/${target.remoteBranch}...`, 'running');
await git.gitFetch(currentDirectory, { remote: target.remote, branch: target.remoteBranch });
updateLastLog('done', `Fetched ${target.remote}/${target.remoteBranch}`);
}
addOperationLog(`Rebasing ${currentBranch} onto ${branch}...`, 'running');
const result = await git.rebase(currentDirectory, { onto: branch });
addOperationLog(`Rebasing ${currentBranch} onto ${target.branch}...`, 'running');
const result = await git.rebase(currentDirectory, { onto: target.branch });
if (result.conflict) {
updateLastLog('error', `Rebase conflicts detected`);
@@ -1847,7 +1868,7 @@ export const GitView: React.FC = () => {
setConflictDialogOpen(true);
persistConflictState(currentDirectory, result.conflictFiles ?? [], 'rebase');
} else {
updateLastLog('done', `Rebased ${currentBranch} onto ${branch}`);
updateLastLog('done', `Rebased ${currentBranch} onto ${target.branch}`);
clearConflictState();
addOperationLog('Refreshing repository status...', 'running');
await refreshStatusAndBranches();
@@ -1858,16 +1879,16 @@ export const GitView: React.FC = () => {
if (isUncommittedChangesError(err)) {
updateLastLog('error', 'Uncommitted changes detected');
setStashDialogOperation('rebase');
setStashDialogBranch(branch);
setStashDialogBranch(target.branch);
setStashDialogOpen(true);
} else {
const message = err instanceof Error ? err.message : `Failed to rebase onto ${branch}`;
const message = err instanceof Error ? err.message : `Failed to rebase onto ${target.branch}`;
updateLastLog('error', message);
}
}
// Note: branchOperation is cleared when dialog closes via handleOperationComplete
},
[currentDirectory, git, status, effectiveRemotes, refreshStatusAndBranches, refreshLog, isUncommittedChangesError, persistConflictState, clearConflictState, addOperationLog, updateLastLog, resetOperationLogs]
[currentDirectory, git, status, resolveIntegrationTarget, refreshStatusAndBranches, refreshLog, isUncommittedChangesError, persistConflictState, clearConflictState, addOperationLog, updateLastLog, resetOperationLogs]
);
const handleAbortConflict = React.useCallback(async () => {
@@ -2249,6 +2270,7 @@ export const GitView: React.FC = () => {
currentBranch={status?.current}
localBranches={localBranches}
remoteBranches={remoteBranches}
defaultTargetBranch={updateTargetBranch}
onMerge={handleMerge}
onRebase={handleRebase}
disabled={isBusy}
@@ -53,6 +53,7 @@ interface BranchIntegrationSectionProps {
operationLogs?: OperationLogEntry[];
onOperationComplete?: () => void;
mode?: 'dialog' | 'inline';
defaultTargetBranch?: string;
}
export const BranchIntegrationSection: React.FC<BranchIntegrationSectionProps> = ({
@@ -66,6 +67,7 @@ export const BranchIntegrationSection: React.FC<BranchIntegrationSectionProps> =
operationLogs = [],
onOperationComplete,
mode = 'dialog',
defaultTargetBranch,
}) => {
const { t } = useI18n();
const [dialogOpen, setDialogOpen] = React.useState(false);
@@ -94,10 +96,15 @@ export const BranchIntegrationSection: React.FC<BranchIntegrationSectionProps> =
// Filter branches based on search
const filteredLocal = React.useMemo(() => {
const term = branchSearch.toLowerCase();
const filtered = localBranches.filter((b) => b !== currentBranch);
const remoteBranchNames = new Set(
remoteBranches
.map((branch) => branch.slice(branch.indexOf('/') + 1))
.filter(Boolean)
);
const filtered = localBranches.filter((branch) => branch !== currentBranch && !remoteBranchNames.has(branch));
if (!term) return filtered;
return filtered.filter((b) => b.toLowerCase().includes(term));
}, [branchSearch, localBranches, currentBranch]);
}, [branchSearch, localBranches, currentBranch, remoteBranches]);
const filteredRemote = React.useMemo(() => {
const term = branchSearch.toLowerCase();
@@ -105,9 +112,16 @@ export const BranchIntegrationSection: React.FC<BranchIntegrationSectionProps> =
return remoteBranches.filter((b) => b.toLowerCase().includes(term));
}, [branchSearch, remoteBranches]);
const resolveDefaultBranch = React.useCallback(() => {
if (!defaultTargetBranch) return null;
if (remoteBranches.includes(defaultTargetBranch)) return defaultTargetBranch;
if (localBranches.includes(defaultTargetBranch)) return defaultTargetBranch;
return null;
}, [defaultTargetBranch, localBranches, remoteBranches]);
const handleOpenDialog = () => {
setDialogOpen(true);
setSelectedBranch(null);
setSelectedBranch(resolveDefaultBranch());
setOperation('merge');
setBranchSearch('');
};
@@ -158,6 +172,11 @@ export const BranchIntegrationSection: React.FC<BranchIntegrationSectionProps> =
}
}, [branchDropdownOpen]);
React.useEffect(() => {
if (mode !== 'inline' || selectedBranch) return;
setSelectedBranch(resolveDefaultBranch());
}, [mode, resolveDefaultBranch, selectedBranch]);
const renderOperating = () => (
<div className="space-y-3">
<div
@@ -306,6 +325,7 @@ export const BranchIntegrationSection: React.FC<BranchIntegrationSectionProps> =
placeholder={t('gitView.branch.searchPlaceholder')}
value={branchSearch}
onValueChange={setBranchSearch}
onKeyDown={(event) => event.stopPropagation()}
/>
<CommandList className="h-full min-h-0" disableHorizontal>
<CommandEmpty>{t('gitView.branch.empty')}</CommandEmpty>
+11 -11
View File
@@ -364,19 +364,19 @@ export const dict = {
'sessions.sidebar.sessionDialogs.actions.deleteLocalBranch': 'Delete local branch',
'sessions.sidebar.sessionDialogs.actions.deleting': 'Deleting…',
'sessions.sidebar.sessionDialogs.actions.deleteWorktree': 'Delete worktree',
'gitView.branch.branchToMergeInto': 'Branch To Merge Into',
'gitView.branch.branchToRebaseOnto': 'Branch To Rebase Onto',
'gitView.branch.branchToMergeInto': 'Branch to merge into {branch}',
'gitView.branch.branchToRebaseOnto': 'Branch to rebase onto',
'gitView.branch.create': 'Create new branch...',
'gitView.branch.currentBadge': 'Current',
'gitView.branch.currentBranchFallback': 'current branch',
'gitView.branch.currentBranchTooltip': 'Current branch',
'gitView.branch.detachedHead': 'Detached HEAD',
'gitView.branch.dialogDescriptionPrefix': 'Dialog Description Prefix',
'gitView.branch.dialogDescriptionPrefix': 'Choose how to bring another branch into',
'gitView.branch.empty': 'No branches found.',
'gitView.branch.localBranches': 'Local branches',
'gitView.branch.mergeDescription': 'Merge Description',
'gitView.branch.mergeDescription': 'Create a merge commit and preserve branch history.',
'gitView.branch.mergeRebase': 'Merge/Rebase',
'gitView.branch.mergeRebaseTooltip': 'Merge Rebase tooltip',
'gitView.branch.mergeRebaseTooltip': 'Update this branch from another branch.',
'gitView.branch.mergingInProgress': 'Merging In Progress',
'gitView.branch.namePlaceholder': 'Branch name',
'gitView.branch.newBranchPlaceholder': 'New branch name',
@@ -387,17 +387,17 @@ export const dict = {
'gitView.branch.operationFailed': 'Operation Failed',
'gitView.branch.pushToPrefix': 'Push To Prefix',
'gitView.branch.pushToSuffix': 'Push To Suffix',
'gitView.branch.rebaseDescription': 'Rebase Description',
'gitView.branch.rebaseDescription': 'Replay your commits on top of the selected branch.',
'gitView.branch.rebasingInProgress': 'Rebasing In Progress',
'gitView.branch.remoteBranches': 'Remote branches',
'gitView.branch.renameTitle': 'Rename branch',
'gitView.branch.searchPlaceholder': 'Search branches...',
'gitView.branch.selectBranch': 'Select Branch',
'gitView.branch.summaryMergeInfix': 'Summary Merge Infix',
'gitView.branch.summaryMergePrefix': 'Summary Merge Prefix',
'gitView.branch.summaryRebaseInfix': 'Summary Rebase Infix',
'gitView.branch.summaryRebasePrefix': 'Summary Rebase Prefix',
'gitView.branch.updateDescriptionPrefix': 'Update Description Prefix',
'gitView.branch.summaryMergeInfix': 'into',
'gitView.branch.summaryMergePrefix': 'This will merge',
'gitView.branch.summaryRebaseInfix': 'onto',
'gitView.branch.summaryRebasePrefix': 'This will rebase',
'gitView.branch.updateDescriptionPrefix': 'Bring the latest changes into',
'gitView.branch.updateTitle': 'Update branch',
'gitView.changes.changedFilesAria': 'Changed files',
'gitView.changes.clearSelectionAria': 'Clear file selection',
+5 -5
View File
@@ -372,12 +372,12 @@ export const dict: Record<I18nKey, string> = {
"gitView.branch.currentBranchFallback": "rama actual",
"gitView.branch.currentBranchTooltip": "Rama actual",
"gitView.branch.detachedHead": "HEAD desvinculado",
"gitView.branch.dialogDescriptionPrefix": "Confirma la operación sobre",
"gitView.branch.dialogDescriptionPrefix": "Elige cómo traer otra rama a",
"gitView.branch.empty": "No se encontraron ramas.",
"gitView.branch.localBranches": "Ramas locales",
"gitView.branch.mergeDescription": "Hacer merge de la rama seleccionada",
"gitView.branch.mergeDescription": "Crea un commit de merge y conserva el historial.",
"gitView.branch.mergeRebase": "Merge/Rebase",
"gitView.branch.mergeRebaseTooltip": "Merge o rebase con otra rama",
"gitView.branch.mergeRebaseTooltip": "Actualiza esta rama desde otra rama.",
"gitView.branch.mergingInProgress": "Merge en curso",
"gitView.branch.namePlaceholder": "Nombre de la rama",
"gitView.branch.newBranchPlaceholder": "Nombre de la nueva rama",
@@ -388,7 +388,7 @@ export const dict: Record<I18nKey, string> = {
"gitView.branch.operationFailed": "No se pudo completar la operación",
"gitView.branch.pushToPrefix": "Enviar",
"gitView.branch.pushToSuffix": "a:",
"gitView.branch.rebaseDescription": "Hacer rebase de la rama seleccionada",
"gitView.branch.rebaseDescription": "Reaplica tus commits encima de la rama seleccionada.",
"gitView.branch.rebasingInProgress": "Rebase en progreso",
"gitView.branch.remoteBranches": "Ramas remotas",
"gitView.branch.renameTitle": "Cambiar nombre de rama",
@@ -398,7 +398,7 @@ export const dict: Record<I18nKey, string> = {
"gitView.branch.summaryMergePrefix": "Se hará merge de",
"gitView.branch.summaryRebaseInfix": "sobre",
"gitView.branch.summaryRebasePrefix": "Se hará rebase de",
"gitView.branch.updateDescriptionPrefix": "Confirma la operación sobre",
"gitView.branch.updateDescriptionPrefix": "Trae los últimos cambios a",
"gitView.branch.updateTitle": "Actualizar rama",
"gitView.changes.changedFilesAria": "Archivos modificados",
"gitView.changes.clearSelectionAria": "Limpiar selección de archivos",
+1 -1
View File
@@ -398,7 +398,7 @@ export const dict: Record<I18nKey, string> = {
'gitView.branch.summaryMergePrefix': '현재 브랜치에',
'gitView.branch.summaryRebaseInfix': '위로 리베이스',
'gitView.branch.summaryRebasePrefix': '현재 브랜치를',
'gitView.branch.updateDescriptionPrefix': '브랜치 설명 접두사 업데이트',
'gitView.branch.updateDescriptionPrefix': '최신 변경 사항을 가져올 대상:',
'gitView.branch.updateTitle': '브랜치 업데이트',
'gitView.changes.changedFilesAria': '변경된 파일',
'gitView.changes.clearSelectionAria': '파일 선택 해제',
+5 -5
View File
@@ -372,12 +372,12 @@ export const dict: Record<I18nKey, string> = {
"gitView.branch.currentBranchFallback": "branch atual",
"gitView.branch.currentBranchTooltip": "Branch atual",
"gitView.branch.detachedHead": "HEAD desvinculado",
"gitView.branch.dialogDescriptionPrefix": "Confirmà operación sobre",
"gitView.branch.dialogDescriptionPrefix": "Escolha como trazer outra branch para",
"gitView.branch.empty": "Nenhuma branch encontrada.",
"gitView.branch.localBranches": "Branches locais",
"gitView.branch.mergeDescription": "Fazer merge da branch selecionada",
"gitView.branch.mergeDescription": "Cria um commit de merge e preserva o histórico.",
"gitView.branch.mergeRebase": "Merge/Rebase",
"gitView.branch.mergeRebaseTooltip": "Merge ou rebase com outra branch",
"gitView.branch.mergeRebaseTooltip": "Atualiza esta branch a partir de outra branch.",
"gitView.branch.mergingInProgress": "Merge em curso",
"gitView.branch.namePlaceholder": "Nome da branch",
"gitView.branch.newBranchPlaceholder": "Nome da nova branch",
@@ -388,7 +388,7 @@ export const dict: Record<I18nKey, string> = {
"gitView.branch.operationFailed": "Não foi possível completar a operación",
"gitView.branch.pushToPrefix": "Enviar",
"gitView.branch.pushToSuffix": "a:",
"gitView.branch.rebaseDescription": "Hacer rebase da branch selecionada",
"gitView.branch.rebaseDescription": "Reaplica seus commits sobre a branch selecionada.",
"gitView.branch.rebasingInProgress": "Rebase em andamento",
"gitView.branch.remoteBranches": "Branches remotas",
"gitView.branch.renameTitle": "Renomear de branch",
@@ -398,7 +398,7 @@ export const dict: Record<I18nKey, string> = {
"gitView.branch.summaryMergePrefix": "Se hará merge de",
"gitView.branch.summaryRebaseInfix": "sobre",
"gitView.branch.summaryRebasePrefix": "Se hará rebase de",
"gitView.branch.updateDescriptionPrefix": "Confirmà operación sobre",
"gitView.branch.updateDescriptionPrefix": "Traz as alterações mais recentes para",
"gitView.branch.updateTitle": "Atualizar branch",
"gitView.changes.changedFilesAria": "Arquivos modificados",
"gitView.changes.clearSelectionAria": "Limpar selección de arquivos",
+11 -11
View File
@@ -365,19 +365,19 @@ export const dict: Record<I18nKey, string> = {
"sessions.sidebar.sessionDialogs.actions.deleteLocalBranch": "Видалити локальну гілку",
"sessions.sidebar.sessionDialogs.actions.deleting": "Видалення…",
"sessions.sidebar.sessionDialogs.actions.deleteWorktree": "Видалити worktree",
"gitView.branch.branchToMergeInto": "Гілка для злиття",
"gitView.branch.branchToRebaseOnto": "Гілка для перебазування",
"gitView.branch.branchToMergeInto": "Гілка, яку злити в {branch}",
"gitView.branch.branchToRebaseOnto": "Гілка, на яку перебазуватися",
"gitView.branch.create": "Створити нову гілку...",
"gitView.branch.currentBadge": "поточний",
"gitView.branch.currentBranchFallback": "поточна гілка",
"gitView.branch.currentBranchTooltip": "Поточна гілка",
"gitView.branch.detachedHead": "Відокремлено HEAD",
"gitView.branch.dialogDescriptionPrefix": "Виконайте операцію з гілкою.",
"gitView.branch.dialogDescriptionPrefix": "Виберіть, як підтягнути іншу гілку в",
"gitView.branch.empty": "Гілок не знайдено.",
"gitView.branch.localBranches": "Локальні гілки",
"gitView.branch.mergeDescription": "Злити поточну гілку в цільову.",
"gitView.branch.mergeDescription": "Створити merge commit і зберегти історію гілок.",
"gitView.branch.mergeRebase": "Злити/перебазувати",
"gitView.branch.mergeRebaseTooltip": "Відкрити дії злиття або перебазування",
"gitView.branch.mergeRebaseTooltip": "Оновити цю гілку з іншої гілки.",
"gitView.branch.mergingInProgress": "Виконується злиття",
"gitView.branch.namePlaceholder": "Назва гілки",
"gitView.branch.newBranchPlaceholder": "Нова назва гілки",
@@ -388,17 +388,17 @@ export const dict: Record<I18nKey, string> = {
"gitView.branch.operationFailed": "Операцію не виконано",
"gitView.branch.pushToPrefix": "Надіслати до",
"gitView.branch.pushToSuffix": "після завершення",
"gitView.branch.rebaseDescription": "Перебазувати поточну гілку на цільову.",
"gitView.branch.rebaseDescription": "Переграти ваші коміти поверх вибраної гілки.",
"gitView.branch.rebasingInProgress": "Виконується перебазування",
"gitView.branch.remoteBranches": "Віддалені гілки",
"gitView.branch.renameTitle": "Перейменувати гілку",
"gitView.branch.searchPlaceholder": "Пошук гілок...",
"gitView.branch.selectBranch": "Вибрати гілку",
"gitView.branch.summaryMergeInfix": "буде злито в",
"gitView.branch.summaryMergePrefix": "Поточна гілка",
"gitView.branch.summaryRebaseInfix": "буде перебазовано на",
"gitView.branch.summaryRebasePrefix": "Поточна гілка",
"gitView.branch.updateDescriptionPrefix": "Оновити поточну гілку з віддаленої.",
"gitView.branch.summaryMergeInfix": "в",
"gitView.branch.summaryMergePrefix": "Буде злито",
"gitView.branch.summaryRebaseInfix": "на",
"gitView.branch.summaryRebasePrefix": "Буде перебазовано",
"gitView.branch.updateDescriptionPrefix": "Підтягнути останні зміни в",
"gitView.branch.updateTitle": "Оновлення гілки",
"gitView.changes.changedFilesAria": "Змінені файли",
"gitView.changes.clearSelectionAria": "Очистити вибір файлу",