Files
openchamber/packages/ui/src/hooks/useDetectedWorktreeRoot.ts
T

117 lines
3.7 KiB
TypeScript
Raw Normal View History

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;
2026-04-17 01:13:59 +08:00
const headState = !branch ? 'unborn' : 'branch';
setDetected({
source: 'sdk',
path: worktreePath,
projectDirectory: projectRoot,
branch,
label: branch || name,
name,
2026-04-17 01:13:59 +08:00
// Phase 1 canonical fields — this hook is fallback-only
worktreeRoot: worktreePath,
worktreeStatus: 'ready',
headState,
worktreeSource: 'existing',
});
})();
return () => {
cancelled = true;
};
}, [currentDirectory, storeMetadata, currentBranch]);
return storeMetadata ?? detected;
}