diff --git a/packages/ui/src/components/views/GitView.tsx b/packages/ui/src/components/views/GitView.tsx
index ba5b2ed3..8ef8a75f 100644
--- a/packages/ui/src/components/views/GitView.tsx
+++ b/packages/ui/src/components/views/GitView.tsx
@@ -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 = () => {
{integrateCommitsProps ? (
{
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(() => {
diff --git a/packages/ui/src/hooks/useDetectedWorktreeRoot.ts b/packages/ui/src/hooks/useDetectedWorktreeRoot.ts
new file mode 100644
index 00000000..cccf4e7d
--- /dev/null
+++ b/packages/ui/src/hooks/useDetectedWorktreeRoot.ts
@@ -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/ → /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();
+
+ 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;
+}
diff --git a/packages/ui/src/lib/git/integrateWorktreeCommits.ts b/packages/ui/src/lib/git/integrateWorktreeCommits.ts
index a1fb8bae..507d8e53 100644
--- a/packages/ui/src/lib/git/integrateWorktreeCommits.ts
+++ b/packages/ui/src/lib/git/integrateWorktreeCommits.ts
@@ -355,5 +355,5 @@ export async function continueIntegrate(state: IntegrateInProgress): Promise undefined);
- return { kind: 'success', moved: remaining.length };
+ return { kind: 'success', moved: state.remainingCommits.length };
}