fix(worktree): restore last source branch reliably (#2030)
* fix(worktree): restore last source branch reliably * chore: retrigger review --------- Co-authored-by: bashrusakh <bashrusakh@users.noreply.github.com>
This commit is contained in:
committed by
GitHub
co-authored by
bashrusakh
parent
0d4118e87a
commit
e5bba59a75
@@ -12,11 +12,12 @@ import {
|
|||||||
import { useGitStore, useGitBranches, useGitLoadingBranches, useGitLoadingStatus, useIsGitRepo } from '@/stores/useGitStore';
|
import { useGitStore, useGitBranches, useGitLoadingBranches, useGitLoadingStatus, useIsGitRepo } from '@/stores/useGitStore';
|
||||||
import { useRuntimeAPIs } from '@/hooks/useRuntimeAPIs';
|
import { useRuntimeAPIs } from '@/hooks/useRuntimeAPIs';
|
||||||
import { getRootBranch } from '@/lib/worktrees/worktreeStatus';
|
import { getRootBranch } from '@/lib/worktrees/worktreeStatus';
|
||||||
|
import {
|
||||||
|
LAST_WORKTREE_SOURCE_BRANCH_KEY,
|
||||||
|
resolveWorktreeSourceBranchPreference,
|
||||||
|
} from '@/lib/worktrees/worktreeSourceBranchPreference';
|
||||||
import { useI18n } from '@/lib/i18n';
|
import { useI18n } from '@/lib/i18n';
|
||||||
|
|
||||||
/** localStorage key matching NewWorktreeDialog */
|
|
||||||
const LAST_SOURCE_BRANCH_KEY = 'oc:lastWorktreeSourceBranch';
|
|
||||||
|
|
||||||
export interface BranchSelectorProps {
|
export interface BranchSelectorProps {
|
||||||
/** Current directory to check for git repository */
|
/** Current directory to check for git repository */
|
||||||
directory: string | null;
|
directory: string | null;
|
||||||
@@ -117,24 +118,35 @@ export const BranchSelector: React.FC<BranchSelectorProps> = ({
|
|||||||
// Resolve default source branch (same priority as NewWorktreeDialog)
|
// Resolve default source branch (same priority as NewWorktreeDialog)
|
||||||
React.useEffect(() => {
|
React.useEffect(() => {
|
||||||
if (disabled || isLoading || allBranches.length === 0) return;
|
if (disabled || isLoading || allBranches.length === 0) return;
|
||||||
// If current value is valid, keep it
|
|
||||||
if (value && allBranches.includes(value)) return;
|
if (value && allBranches.includes(value)) return;
|
||||||
|
|
||||||
|
const currentValue = value;
|
||||||
|
let cancelled = false;
|
||||||
|
|
||||||
const resolve = async () => {
|
const resolve = async () => {
|
||||||
try {
|
try {
|
||||||
const rootBranch = directory ? await getRootBranch(directory).catch(() => null) : null;
|
const rootBranch = directory ? await getRootBranch(directory).catch(() => null) : null;
|
||||||
const saved = localStorage.getItem(LAST_SOURCE_BRANCH_KEY);
|
if (cancelled) return;
|
||||||
|
|
||||||
if (saved && allBranches.includes(saved)) {
|
const saved = localStorage.getItem(LAST_WORKTREE_SOURCE_BRANCH_KEY);
|
||||||
onChange(saved);
|
|
||||||
} else if (rootBranch && allBranches.includes(rootBranch)) {
|
const {
|
||||||
onChange(rootBranch);
|
sourceBranch,
|
||||||
} else if (allBranches.includes('main')) {
|
shouldClearSavedSourceBranch,
|
||||||
onChange('main');
|
} = resolveWorktreeSourceBranchPreference({
|
||||||
} else if (allBranches.includes('master')) {
|
branches: allBranches,
|
||||||
onChange('master');
|
savedSourceBranch: saved,
|
||||||
} else if (allBranches[0]) {
|
rootBranch,
|
||||||
onChange(allBranches[0]);
|
});
|
||||||
|
|
||||||
|
if (shouldClearSavedSourceBranch) {
|
||||||
|
localStorage.removeItem(LAST_WORKTREE_SOURCE_BRANCH_KEY);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (cancelled || (currentValue && allBranches.includes(currentValue))) return;
|
||||||
|
|
||||||
|
if (sourceBranch) {
|
||||||
|
onChange(sourceBranch);
|
||||||
}
|
}
|
||||||
} catch {
|
} catch {
|
||||||
// ignore
|
// ignore
|
||||||
@@ -142,6 +154,9 @@ export const BranchSelector: React.FC<BranchSelectorProps> = ({
|
|||||||
};
|
};
|
||||||
|
|
||||||
void resolve();
|
void resolve();
|
||||||
|
return () => {
|
||||||
|
cancelled = true;
|
||||||
|
};
|
||||||
}, [allBranches, directory, disabled, isLoading, onChange, value]);
|
}, [allBranches, directory, disabled, isLoading, onChange, value]);
|
||||||
|
|
||||||
const isDisabled = disabled || !isGitRepository || isLoading;
|
const isDisabled = disabled || !isGitRepository || isLoading;
|
||||||
|
|||||||
@@ -40,6 +40,11 @@ import { generateBranchSlug } from '@/lib/git/branchNameGenerator';
|
|||||||
import { renderMagicPrompt } from '@/lib/magicPrompts';
|
import { renderMagicPrompt } from '@/lib/magicPrompts';
|
||||||
import { parseModelIdentifier } from '@/lib/modelIdentifier';
|
import { parseModelIdentifier } from '@/lib/modelIdentifier';
|
||||||
import { rankBranchesForQuery } from '@/lib/worktrees/branchSearch';
|
import { rankBranchesForQuery } from '@/lib/worktrees/branchSearch';
|
||||||
|
import {
|
||||||
|
LAST_WORKTREE_SOURCE_BRANCH_KEY,
|
||||||
|
resolveWorktreeSourceBranchPreference,
|
||||||
|
resolveWorktreeSourceBranchToPersist,
|
||||||
|
} from '@/lib/worktrees/worktreeSourceBranchPreference';
|
||||||
import { useRuntimeAPIs } from '@/hooks/useRuntimeAPIs';
|
import { useRuntimeAPIs } from '@/hooks/useRuntimeAPIs';
|
||||||
import { useGitBranches, useGitStore, useGitLoadingBranches } from '@/stores/useGitStore';
|
import { useGitBranches, useGitStore, useGitLoadingBranches } from '@/stores/useGitStore';
|
||||||
import { GitHubIntegrationDialog } from './GitHubIntegrationDialog';
|
import { GitHubIntegrationDialog } from './GitHubIntegrationDialog';
|
||||||
@@ -105,8 +110,6 @@ const slugifyWorktreeName = (value: string): string => {
|
|||||||
.slice(0, 80);
|
.slice(0, 80);
|
||||||
};
|
};
|
||||||
|
|
||||||
const LAST_SOURCE_BRANCH_KEY = 'oc:lastWorktreeSourceBranch';
|
|
||||||
|
|
||||||
const sanitizeRemoteName = (value: string): string => {
|
const sanitizeRemoteName = (value: string): string => {
|
||||||
const normalized = String(value || '')
|
const normalized = String(value || '')
|
||||||
.trim()
|
.trim()
|
||||||
@@ -586,24 +589,34 @@ export function NewWorktreeDialog({
|
|||||||
// Get current state based on mode
|
// Get current state based on mode
|
||||||
const currentState = mode === 'new-branch' ? newBranchState : existingBranchState;
|
const currentState = mode === 'new-branch' ? newBranchState : existingBranchState;
|
||||||
|
|
||||||
// Set default source branch when branches become available
|
// Set default source branch when the dialog opens and branches become available
|
||||||
React.useEffect(() => {
|
React.useEffect(() => {
|
||||||
if (!branches?.all || !projectDirectory) return;
|
if (!open || !branches?.all || !projectDirectory) return;
|
||||||
if (newBranchState.sourceBranch) return; // Already set
|
if (newBranchState.sourceBranch) return;
|
||||||
|
|
||||||
|
const currentSourceBranch = newBranchState.sourceBranch;
|
||||||
|
let cancelled = false;
|
||||||
|
|
||||||
const loadDefaultSourceBranch = async () => {
|
const loadDefaultSourceBranch = async () => {
|
||||||
try {
|
try {
|
||||||
const rootBranch = await getRootBranch(projectDirectory).catch(() => null);
|
const rootBranch = await getRootBranch(projectDirectory).catch(() => null);
|
||||||
const savedSourceBranch = localStorage.getItem(LAST_SOURCE_BRANCH_KEY);
|
if (cancelled) return;
|
||||||
const defaultSourceBranch = savedSourceBranch && branches.all?.includes(savedSourceBranch)
|
|
||||||
? savedSourceBranch
|
const savedSourceBranch = localStorage.getItem(LAST_WORKTREE_SOURCE_BRANCH_KEY);
|
||||||
: rootBranch && branches.all?.includes(rootBranch)
|
const {
|
||||||
? rootBranch
|
sourceBranch: defaultSourceBranch,
|
||||||
: branches.all?.includes('main')
|
shouldClearSavedSourceBranch,
|
||||||
? 'main'
|
} = resolveWorktreeSourceBranchPreference({
|
||||||
: branches.all?.includes('master')
|
branches: branches.all,
|
||||||
? 'master'
|
savedSourceBranch,
|
||||||
: branches.all?.[0] || '';
|
rootBranch,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (shouldClearSavedSourceBranch) {
|
||||||
|
localStorage.removeItem(LAST_WORKTREE_SOURCE_BRANCH_KEY);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (cancelled || currentSourceBranch) return;
|
||||||
|
|
||||||
if (defaultSourceBranch) {
|
if (defaultSourceBranch) {
|
||||||
setNewBranchState(prev => ({
|
setNewBranchState(prev => ({
|
||||||
@@ -617,11 +630,14 @@ export function NewWorktreeDialog({
|
|||||||
};
|
};
|
||||||
|
|
||||||
void loadDefaultSourceBranch();
|
void loadDefaultSourceBranch();
|
||||||
}, [branches, projectDirectory, newBranchState.sourceBranch]);
|
return () => {
|
||||||
|
cancelled = true;
|
||||||
|
};
|
||||||
|
}, [open, branches?.all, projectDirectory, newBranchState.sourceBranch]);
|
||||||
|
|
||||||
// Reset state on each open. Resetting on close would empty the form during
|
// Reset state on each open. Resetting on close would empty the form during
|
||||||
// the close animation, causing visible flicker.
|
// the close animation, causing visible flicker.
|
||||||
React.useEffect(() => {
|
React.useLayoutEffect(() => {
|
||||||
if (!open) return;
|
if (!open) return;
|
||||||
|
|
||||||
setMode('new-branch');
|
setMode('new-branch');
|
||||||
@@ -890,9 +906,16 @@ export function NewWorktreeDialog({
|
|||||||
setIsCreating(false);
|
setIsCreating(false);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Save source branch preference (only if not from PR)
|
// Save the last source-branch choice for the next open.
|
||||||
if (newBranchState.sourceBranch && mode === 'new-branch' && !newBranchState.linkedPr) {
|
const lastSourceBranch = resolveWorktreeSourceBranchToPersist({
|
||||||
localStorage.setItem(LAST_SOURCE_BRANCH_KEY, newBranchState.sourceBranch);
|
mode,
|
||||||
|
sourceBranch: newBranchState.sourceBranch,
|
||||||
|
linkedPr: !!newBranchState.linkedPr,
|
||||||
|
selectedBranch: existingBranchState.selectedBranch,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (lastSourceBranch) {
|
||||||
|
localStorage.setItem(LAST_WORKTREE_SOURCE_BRANCH_KEY, lastSourceBranch);
|
||||||
}
|
}
|
||||||
|
|
||||||
toast.success(t('session.newWorktree.toast.worktreeCreated'), {
|
toast.success(t('session.newWorktree.toast.worktreeCreated'), {
|
||||||
|
|||||||
@@ -0,0 +1,70 @@
|
|||||||
|
import { describe, expect, test } from 'bun:test';
|
||||||
|
|
||||||
|
import {
|
||||||
|
resolveWorktreeSourceBranchPreference,
|
||||||
|
resolveWorktreeSourceBranchToPersist,
|
||||||
|
} from './worktreeSourceBranchPreference';
|
||||||
|
|
||||||
|
describe('resolveWorktreeSourceBranchPreference', () => {
|
||||||
|
test('keeps a valid saved source branch', () => {
|
||||||
|
expect(resolveWorktreeSourceBranchPreference({
|
||||||
|
branches: ['develop', 'main', 'remotes/origin/main'],
|
||||||
|
savedSourceBranch: 'develop',
|
||||||
|
rootBranch: 'main',
|
||||||
|
})).toEqual({
|
||||||
|
sourceBranch: 'develop',
|
||||||
|
shouldClearSavedSourceBranch: false,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test('falls back to the root branch and marks a stale saved branch for cleanup', () => {
|
||||||
|
expect(resolveWorktreeSourceBranchPreference({
|
||||||
|
branches: ['main', 'develop', 'remotes/origin/main'],
|
||||||
|
savedSourceBranch: 'feature/stale',
|
||||||
|
rootBranch: 'main',
|
||||||
|
})).toEqual({
|
||||||
|
sourceBranch: 'main',
|
||||||
|
shouldClearSavedSourceBranch: true,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test('uses a deterministic fallback when the root branch is unavailable', () => {
|
||||||
|
expect(resolveWorktreeSourceBranchPreference({
|
||||||
|
branches: ['feature/new', 'master'],
|
||||||
|
savedSourceBranch: 'feature/stale',
|
||||||
|
rootBranch: null,
|
||||||
|
})).toEqual({
|
||||||
|
sourceBranch: 'master',
|
||||||
|
shouldClearSavedSourceBranch: true,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('resolveWorktreeSourceBranchToPersist', () => {
|
||||||
|
test('persists the source branch for a new worktree without a linked PR', () => {
|
||||||
|
expect(resolveWorktreeSourceBranchToPersist({
|
||||||
|
mode: 'new-branch',
|
||||||
|
sourceBranch: 'develop',
|
||||||
|
linkedPr: false,
|
||||||
|
selectedBranch: '',
|
||||||
|
})).toBe('develop');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('skips persisting a PR-linked source branch', () => {
|
||||||
|
expect(resolveWorktreeSourceBranchToPersist({
|
||||||
|
mode: 'new-branch',
|
||||||
|
sourceBranch: 'feature/pr',
|
||||||
|
linkedPr: true,
|
||||||
|
selectedBranch: '',
|
||||||
|
})).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('persists the selected branch for existing-branch mode', () => {
|
||||||
|
expect(resolveWorktreeSourceBranchToPersist({
|
||||||
|
mode: 'existing-branch',
|
||||||
|
sourceBranch: 'feature/unused',
|
||||||
|
linkedPr: false,
|
||||||
|
selectedBranch: 'hotfix/fix-123',
|
||||||
|
})).toBe('hotfix/fix-123');
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,64 @@
|
|||||||
|
export const LAST_WORKTREE_SOURCE_BRANCH_KEY = 'oc:lastWorktreeSourceBranch';
|
||||||
|
|
||||||
|
export interface WorktreeSourceBranchPreferenceArgs {
|
||||||
|
branches: readonly string[];
|
||||||
|
savedSourceBranch: string | null;
|
||||||
|
rootBranch: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface WorktreeSourceBranchPreferenceResult {
|
||||||
|
sourceBranch: string;
|
||||||
|
shouldClearSavedSourceBranch: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface WorktreeSourceBranchPersistArgs {
|
||||||
|
mode: 'new-branch' | 'existing-branch';
|
||||||
|
sourceBranch: string;
|
||||||
|
linkedPr: boolean;
|
||||||
|
selectedBranch: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const resolveWorktreeSourceBranchToPersist = ({
|
||||||
|
mode,
|
||||||
|
sourceBranch,
|
||||||
|
linkedPr,
|
||||||
|
selectedBranch,
|
||||||
|
}: WorktreeSourceBranchPersistArgs): string | null => {
|
||||||
|
if (mode === 'existing-branch') {
|
||||||
|
return selectedBranch || null;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (linkedPr) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return sourceBranch || null;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const resolveWorktreeSourceBranchPreference = ({
|
||||||
|
branches,
|
||||||
|
savedSourceBranch,
|
||||||
|
rootBranch,
|
||||||
|
}: WorktreeSourceBranchPreferenceArgs): WorktreeSourceBranchPreferenceResult => {
|
||||||
|
const savedSourceBranchIsValid = Boolean(savedSourceBranch && branches.includes(savedSourceBranch));
|
||||||
|
|
||||||
|
if (savedSourceBranchIsValid && savedSourceBranch) {
|
||||||
|
return {
|
||||||
|
sourceBranch: savedSourceBranch,
|
||||||
|
shouldClearSavedSourceBranch: false,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const sourceBranch = rootBranch && branches.includes(rootBranch)
|
||||||
|
? rootBranch
|
||||||
|
: branches.includes('main')
|
||||||
|
? 'main'
|
||||||
|
: branches.includes('master')
|
||||||
|
? 'master'
|
||||||
|
: branches[0] ?? '';
|
||||||
|
|
||||||
|
return {
|
||||||
|
sourceBranch,
|
||||||
|
shouldClearSavedSourceBranch: Boolean(savedSourceBranch),
|
||||||
|
};
|
||||||
|
};
|
||||||
Reference in New Issue
Block a user