fix(worktree): fix worktree detection and state reset when switching (#779)
* fix(worktree): reset IntegrateCommitsSection state when switching worktrees
Three fixes for the re-integrate commits panel getting stuck:
1. Add `key={worktreeMetadata.path}` to IntegrateCommitsSection so React
fully remounts it when switching to a different worktree, clearing any
stale `ui` state (conflict, loading, ready) from the previous session.
2. Add `cancelled` flag to the conflict-restore effect so that an async
callback started for session A cannot overwrite session B's state after
the user switches sessions. Without this guard the stale callback could
restore the old session's conflict state on top of the new session's
computed-ready state.
3. Fix off-by-one in continueIntegrate: `moved` was returning
`remaining.length` (N-1, after shifting currentCommit out) instead of
`state.remainingCommits.length` (N), undercounting the commit that was
moved by `cherry-pick --continue`.
* fix(worktree): add git-based fallback detection when store metadata is missing
Root cause: the existing worktreeMetadata resolution relies entirely on
cached store state (worktreeMap + availableWorktrees). When the store
lookup fails—due to hydrateSessionWorktreeMetadata deleting entries on
API failure, availableWorktrees being stale, or worktrees created
externally via CLI—the "Re-integrate commits" section permanently shows
"Available in worktree mode." with no way to recover.
Fix: add useDetectedWorktreeMetadata hook that performs a lightweight
git probe (`git rev-parse --absolute-git-dir --abbrev-ref HEAD`) when
the store-based lookup returns undefined. If the current directory is a
secondary git worktree, a minimal WorktreeMetadata is synthesised with
the correct projectDirectory and branch, allowing IntegrateCommitsSection
and other worktree features to function regardless of store state.
The store-based lookup remains the primary fast path; the git probe only
runs as a fallback and caches its result per directory.
* fix(worktree): fix detection command and pass current branch from git status
Two bugs in the fallback worktree detection hook:
1. `git rev-parse --absolute-git-dir --abbrev-ref HEAD` combines two
independent rev-parse options whose combined output is unreliable –
the two-line assumption (`lines.length < 2`) caused silent null
returns, meaning the fallback never actually set worktreeMetadata.
Now uses only `git rev-parse --absolute-git-dir` (single-line,
deterministic output) for worktree detection.
2. The hook was called before `useGitStatus`, so no branch was
available. Move the call to after `const status = useGitStatus(...)`
and pass `status?.current` as `currentBranch`, eliminating the need
for a second git command and keeping the branch in sync with the
already-polled git status.
Also removes `detected` from the useEffect deps array – it was an
unnecessary dep that triggered a re-run on every detected state change.
* fix(worktree): use worktree toplevel path and reset stale metadata immediately
Two bugs in useDetectedWorktreeMetadata:
1. path was set from currentDirectory (the active sub-folder) instead of the
worktree root. git rev-parse --show-toplevel now provides the actual
worktree toplevel, so operations like `git worktree remove` receive a valid
root path regardless of which sub-directory is open.
2. When currentDirectory changed with no storeMetadata, the hook kept
returning the prior detected value until the async git probe finished.
Calling setDetected(undefined) before launching the async task eliminates
the stale-metadata window.
This commit is contained in:
@@ -45,6 +45,7 @@ import {
|
||||
|
||||
import { useRuntimeAPIs } from '@/hooks/useRuntimeAPIs';
|
||||
import { useUIStore } from '@/stores/useUIStore';
|
||||
import { useDetectedWorktreeMetadata } from '@/hooks/useDetectedWorktreeRoot';
|
||||
import { IntegrateCommitsSection } from './git/IntegrateCommitsSection';
|
||||
|
||||
import { GitHeader } from './git/GitHeader';
|
||||
@@ -251,7 +252,7 @@ export const GitView: React.FC = () => {
|
||||
|
||||
return undefined;
|
||||
}, [availableWorktrees, normalizedCurrentDirectory, worktreeMap]);
|
||||
const worktreeMetadata = React.useMemo(() => {
|
||||
const storeWorktreeMetadata = React.useMemo(() => {
|
||||
if (currentSessionId) {
|
||||
return worktreeMap.get(currentSessionId) ?? inferredWorktreeMetadata;
|
||||
}
|
||||
@@ -263,12 +264,13 @@ export const GitView: React.FC = () => {
|
||||
return undefined;
|
||||
}, [currentSessionId, inferredWorktreeMetadata, newSessionDraft?.open, worktreeMap]);
|
||||
|
||||
|
||||
const { profiles, globalIdentity, defaultGitIdentityId, loadProfiles, loadGlobalIdentity, loadDefaultGitIdentityId } =
|
||||
useGitIdentitiesStore();
|
||||
|
||||
const isGitRepo = useIsGitRepo(currentDirectory ?? null);
|
||||
const status = useGitStatus(currentDirectory ?? null);
|
||||
|
||||
const worktreeMetadata = useDetectedWorktreeMetadata(currentDirectory, storeWorktreeMetadata, status?.current ?? undefined);
|
||||
const branches = useGitBranches(currentDirectory ?? null);
|
||||
const log = useGitLog(currentDirectory ?? null);
|
||||
const currentIdentity = useGitIdentity(currentDirectory ?? null);
|
||||
@@ -2078,6 +2080,7 @@ export const GitView: React.FC = () => {
|
||||
<div className="space-y-4">
|
||||
{integrateCommitsProps ? (
|
||||
<IntegrateCommitsSection
|
||||
key={integrateCommitsProps.worktreeMetadata.path}
|
||||
repoRoot={integrateCommitsProps.repoRoot}
|
||||
sourceBranch={integrateCommitsProps.sourceBranch}
|
||||
worktreeMetadata={integrateCommitsProps.worktreeMetadata}
|
||||
|
||||
@@ -93,6 +93,7 @@ export const IntegrateCommitsSection: React.FC<{
|
||||
if (!conflictStorageKey || typeof window === 'undefined') return;
|
||||
const raw = window.localStorage.getItem(conflictStorageKey);
|
||||
if (!raw) return;
|
||||
let cancelled = false;
|
||||
try {
|
||||
const parsed = JSON.parse(raw) as IntegrateInProgress;
|
||||
if (!parsed?.tempWorktreePath || parsed.repoRoot !== repoRoot) {
|
||||
@@ -101,11 +102,13 @@ export const IntegrateCommitsSection: React.FC<{
|
||||
}
|
||||
void (async () => {
|
||||
const ok = await isCherryPickInProgress(parsed.tempWorktreePath).catch(() => false);
|
||||
if (cancelled) return;
|
||||
if (!ok) {
|
||||
window.localStorage.removeItem(conflictStorageKey);
|
||||
return;
|
||||
}
|
||||
const details = await getIntegrateConflictDetails(parsed.tempWorktreePath).catch(() => null);
|
||||
if (cancelled) return;
|
||||
if (!details) {
|
||||
return;
|
||||
}
|
||||
@@ -114,6 +117,9 @@ export const IntegrateCommitsSection: React.FC<{
|
||||
} catch {
|
||||
window.localStorage.removeItem(conflictStorageKey);
|
||||
}
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [conflictStorageKey, repoRoot]);
|
||||
|
||||
React.useEffect(() => {
|
||||
|
||||
@@ -0,0 +1,110 @@
|
||||
import React from 'react';
|
||||
import { execCommand } from '@/lib/execCommands';
|
||||
import type { WorktreeMetadata } from '@/types/worktree';
|
||||
|
||||
const normalizePath = (value: string): string => {
|
||||
if (!value) return '';
|
||||
const replaced = value.replace(/\\/g, '/');
|
||||
if (replaced === '/') return '/';
|
||||
return replaced.replace(/\/+$/, '');
|
||||
};
|
||||
|
||||
/**
|
||||
* Derive the primary worktree (project) root from the absolute git directory.
|
||||
*
|
||||
* Secondary worktree: /project/.git/worktrees/<name> → /project
|
||||
* Primary worktree: /project/.git → null (not a secondary)
|
||||
*/
|
||||
const deriveProjectRoot = (gitDir: string): string | null => {
|
||||
const normalized = normalizePath(gitDir);
|
||||
if (!normalized) return null;
|
||||
|
||||
const marker = '/.git/worktrees/';
|
||||
const idx = normalized.indexOf(marker);
|
||||
if (idx > 0) {
|
||||
return normalized.slice(0, idx) || null;
|
||||
}
|
||||
|
||||
return null;
|
||||
};
|
||||
|
||||
/**
|
||||
* When the store-based WorktreeMetadata lookup fails, this hook falls back to
|
||||
* a single `git rev-parse --absolute-git-dir` call to detect whether
|
||||
* `currentDirectory` is a secondary worktree. If it is, a minimal
|
||||
* WorktreeMetadata is synthesised so that "Re-integrate commits" and other
|
||||
* worktree features can function without explicit store entries.
|
||||
*
|
||||
* @param currentDirectory Effective directory for the active session/tab.
|
||||
* @param storeMetadata Result of the normal store-based lookup (may be undefined).
|
||||
* @param currentBranch Current git branch (from status?.current in the parent).
|
||||
*/
|
||||
export function useDetectedWorktreeMetadata(
|
||||
currentDirectory: string | undefined,
|
||||
storeMetadata: WorktreeMetadata | undefined,
|
||||
currentBranch: string | undefined,
|
||||
): WorktreeMetadata | undefined {
|
||||
const [detected, setDetected] = React.useState<WorktreeMetadata | undefined>();
|
||||
|
||||
React.useEffect(() => {
|
||||
if (storeMetadata) {
|
||||
setDetected(undefined);
|
||||
return;
|
||||
}
|
||||
|
||||
if (!currentDirectory) {
|
||||
setDetected(undefined);
|
||||
return;
|
||||
}
|
||||
|
||||
// Reset immediately so callers never see stale metadata from a previous directory.
|
||||
setDetected(undefined);
|
||||
|
||||
let cancelled = false;
|
||||
void (async () => {
|
||||
const [gitDirResult, toplevelResult] = await Promise.all([
|
||||
execCommand('git rev-parse --absolute-git-dir', currentDirectory),
|
||||
execCommand('git rev-parse --show-toplevel', currentDirectory),
|
||||
]);
|
||||
if (cancelled) return;
|
||||
|
||||
if (!gitDirResult.success || !toplevelResult.success) {
|
||||
return;
|
||||
}
|
||||
|
||||
const gitDir = normalizePath((gitDirResult.stdout || '').trim());
|
||||
const projectRoot = deriveProjectRoot(gitDir);
|
||||
|
||||
if (!projectRoot) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Use the worktree toplevel, not the active sub-directory, so that
|
||||
// worktree operations (e.g. `git worktree remove`) receive a valid root path.
|
||||
const worktreePath = normalizePath((toplevelResult.stdout || '').trim());
|
||||
|
||||
// Sanity-check: secondary worktree path must differ from project root
|
||||
if (!worktreePath || worktreePath === projectRoot) {
|
||||
return;
|
||||
}
|
||||
|
||||
const branch = currentBranch || '';
|
||||
const name = worktreePath.split('/').filter(Boolean).pop() || worktreePath;
|
||||
|
||||
setDetected({
|
||||
source: 'sdk',
|
||||
path: worktreePath,
|
||||
projectDirectory: projectRoot,
|
||||
branch,
|
||||
label: branch || name,
|
||||
name,
|
||||
});
|
||||
})();
|
||||
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [currentDirectory, storeMetadata, currentBranch]);
|
||||
|
||||
return storeMetadata ?? detected;
|
||||
}
|
||||
@@ -355,5 +355,5 @@ export async function continueIntegrate(state: IntegrateInProgress): Promise<Int
|
||||
|
||||
await removeTempWorktree(state.repoRoot, state.tempWorktreePath);
|
||||
await syncCleanTargetWorktrees(state.repoRoot, state.cleanTargetWorktrees).catch(() => undefined);
|
||||
return { kind: 'success', moved: remaining.length };
|
||||
return { kind: 'success', moved: state.remainingCommits.length };
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user