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:
jwcrystal
2026-04-01 18:33:49 +03:00
committed by GitHub
parent 6180f388e8
commit aa071556bf
4 changed files with 122 additions and 3 deletions
+5 -2
View File
@@ -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 };
}