Harden remote API security boundaries
This commit is contained in:
@@ -33,8 +33,9 @@ import { useDeviceInfo } from '@/lib/device';
|
||||
import { useEffectiveDirectory } from '@/hooks/useEffectiveDirectory';
|
||||
import { useRuntimeAPIs } from '@/hooks/useRuntimeAPIs';
|
||||
import type { EditorAPI } from '@/lib/api/types';
|
||||
import { isVSCodeRuntime } from '@/lib/desktop';
|
||||
import { getDirectoryForFilePath, isAbsoluteFilePath, normalizeFilePath, toAbsoluteFilePath } from '@/lib/path-utils';
|
||||
import { isDesktopLocalOriginActive, isDesktopShell, isVSCodeRuntime } from '@/lib/desktop';
|
||||
import { ensureOutsideFileGrantForDesktop } from '@/lib/outsideFileGrants';
|
||||
import { getDirectoryForFilePath, isAbsoluteFilePath, isFilePathWithinDirectory, normalizeFilePath, toAbsoluteFilePath } from '@/lib/path-utils';
|
||||
|
||||
const useCurrentMermaidTheme = () => {
|
||||
const themeSystem = useOptionalThemeSystem();
|
||||
@@ -1371,7 +1372,7 @@ const fileReferenceExists = (resolvedPath: string): Promise<boolean> => {
|
||||
};
|
||||
|
||||
const getContextDirectory = (effectiveDirectory: string, resolvedPath: string): string => {
|
||||
return getDirectoryForFilePath(effectiveDirectory, resolvedPath);
|
||||
return effectiveDirectory || getDirectoryForFilePath(effectiveDirectory, resolvedPath);
|
||||
};
|
||||
|
||||
const useFileReferenceInteractions = ({
|
||||
@@ -1441,7 +1442,14 @@ const useFileReferenceInteractions = ({
|
||||
|
||||
linkedCount += 1;
|
||||
|
||||
void fileReferenceExists(resolved.resolvedPath).then((exists) => {
|
||||
const canGrantOutsideFile = isDesktopShell()
|
||||
&& isDesktopLocalOriginActive()
|
||||
&& !isFilePathWithinDirectory(resolved.resolvedPath, effectiveDirectory);
|
||||
const existsPromise = canGrantOutsideFile
|
||||
? Promise.resolve(true)
|
||||
: fileReferenceExists(resolved.resolvedPath);
|
||||
|
||||
void existsPromise.then((exists) => {
|
||||
if (cancelled || !exists || !container.contains(candidate)) {
|
||||
return;
|
||||
}
|
||||
@@ -1464,7 +1472,7 @@ const useFileReferenceInteractions = ({
|
||||
}
|
||||
};
|
||||
|
||||
const openFileReference = (sourceElement: HTMLElement) => {
|
||||
const openFileReference = async (sourceElement: HTMLElement) => {
|
||||
const raw = sourceElement.getAttribute('data-openchamber-file-ref') || extractPathCandidateFromElement(sourceElement);
|
||||
const resolved = getResolvedReference(raw, effectiveDirectory);
|
||||
if (!resolved) {
|
||||
@@ -1485,6 +1493,10 @@ const useFileReferenceInteractions = ({
|
||||
return;
|
||||
}
|
||||
|
||||
if (!isFilePathWithinDirectory(resolved.resolvedPath, effectiveDirectory)) {
|
||||
await ensureOutsideFileGrantForDesktop(resolved.resolvedPath, effectiveDirectory);
|
||||
}
|
||||
|
||||
const uiStore = useUIStore.getState();
|
||||
if (Number.isFinite(resolved.line ?? Number.NaN)) {
|
||||
uiStore.openContextFileAtLine(
|
||||
@@ -1514,7 +1526,7 @@ const useFileReferenceInteractions = ({
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
|
||||
openFileReference(fileRefElement);
|
||||
void openFileReference(fileRefElement);
|
||||
};
|
||||
|
||||
const handleKeyDown = (event: KeyboardEvent) => {
|
||||
@@ -1530,7 +1542,7 @@ const useFileReferenceInteractions = ({
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
|
||||
openFileReference(target);
|
||||
void openFileReference(target);
|
||||
};
|
||||
|
||||
annotateFileLinks();
|
||||
|
||||
@@ -19,11 +19,12 @@ import { RuntimeAPIContext } from '@/contexts/runtimeAPIContext';
|
||||
import { useDirectoryStore } from '@/stores/useDirectoryStore';
|
||||
import { useUIStore } from '@/stores/useUIStore';
|
||||
import { useSkillsStore } from '@/stores/useSkillsStore';
|
||||
import { ensureOutsideFileGrantForDesktop } from '@/lib/outsideFileGrants';
|
||||
import ReasoningPart from './ReasoningPart';
|
||||
import JustificationBlock from './JustificationBlock';
|
||||
import { areRenderRelevantPartsEqual } from '../renderCompare';
|
||||
import { getExternalFaviconUrl } from '@/lib/url';
|
||||
import { getDirectoryForFilePath, getRelativeFilePath, normalizeFilePath, toAbsoluteFilePath } from '@/lib/path-utils';
|
||||
import { getDirectoryForFilePath, getRelativeFilePath, isFilePathWithinDirectory, normalizeFilePath, toAbsoluteFilePath } from '@/lib/path-utils';
|
||||
|
||||
const TOOL_ROW_TEXT_CLASS = '!text-[length:var(--text-meta)] !leading-4 sm:!leading-6 tracking-normal';
|
||||
const TOOL_ROW_TITLE_CLASS = cn('typography-meta font-medium', TOOL_ROW_TEXT_CLASS);
|
||||
@@ -638,6 +639,19 @@ const StaticToolRowInner: React.FC<{
|
||||
return;
|
||||
}
|
||||
|
||||
if (!isFilePathWithinDirectory(absolutePath, currentDirectory)) {
|
||||
void ensureOutsideFileGrantForDesktop(absolutePath, currentDirectory).then(() => {
|
||||
const uiStore = useUIStore.getState();
|
||||
const contextDirectory = currentDirectory || getDirectoryForFilePath(currentDirectory, absolutePath);
|
||||
if (offset && Number.isFinite(offset)) {
|
||||
uiStore.openContextFileAtLine(contextDirectory, absolutePath, Math.max(1, Math.trunc(offset)), 1);
|
||||
return;
|
||||
}
|
||||
uiStore.openContextFile(contextDirectory, absolutePath);
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const uiStore = useUIStore.getState();
|
||||
const contextDirectory = getDirectoryForFilePath(currentDirectory, absolutePath);
|
||||
if (offset && Number.isFinite(offset)) {
|
||||
|
||||
@@ -2170,7 +2170,7 @@ export const ContextPanel: React.FC = () => {
|
||||
}
|
||||
|
||||
if (activeTab.mode === 'file' && activeTab.targetPath) {
|
||||
setSelectedFilePath(directoryKey, activeTab.targetPath);
|
||||
setSelectedFilePath(directoryKey, activeTab.targetPath, { allowOutsideRoot: true });
|
||||
return;
|
||||
}
|
||||
|
||||
|
||||
@@ -22,6 +22,8 @@ export const DesktopNetworkSettings: React.FC = () => {
|
||||
const [draftValue, setDraftValue] = React.useState(false);
|
||||
const [savedPassword, setSavedPassword] = React.useState('');
|
||||
const [draftPassword, setDraftPassword] = React.useState('');
|
||||
const [lanAccessActive, setLanAccessActive] = React.useState(false);
|
||||
const [lanAccessBlockedReason, setLanAccessBlockedReason] = React.useState<string | null>(null);
|
||||
const [isLoading, setIsLoading] = React.useState(true);
|
||||
const [isSaving, setIsSaving] = React.useState(false);
|
||||
const [launchAtLoginSupported, setLaunchAtLoginSupported] = React.useState(false);
|
||||
@@ -50,6 +52,8 @@ export const DesktopNetworkSettings: React.FC = () => {
|
||||
const data = (await response.json().catch(() => null)) as null | {
|
||||
desktopLanAccessEnabled?: unknown;
|
||||
desktopUiPassword?: unknown;
|
||||
desktopLanAccessActive?: unknown;
|
||||
desktopLanAccessBlockedReason?: unknown;
|
||||
};
|
||||
if (cancelled) {
|
||||
return;
|
||||
@@ -61,6 +65,10 @@ export const DesktopNetworkSettings: React.FC = () => {
|
||||
setDraftValue(enabled);
|
||||
setSavedPassword(password);
|
||||
setDraftPassword(password);
|
||||
setLanAccessActive(data?.desktopLanAccessActive === true);
|
||||
setLanAccessBlockedReason(
|
||||
typeof data?.desktopLanAccessBlockedReason === 'string' ? data.desktopLanAccessBlockedReason : null
|
||||
);
|
||||
setError(null);
|
||||
} catch (cause) {
|
||||
if (!cancelled) {
|
||||
@@ -135,12 +143,22 @@ export const DesktopNetworkSettings: React.FC = () => {
|
||||
}
|
||||
return Number.isFinite(parsed) && parsed > 0 ? parsed : null;
|
||||
}, []);
|
||||
const lanUrl = draftValue && lanAddress && currentPort ? `http://${lanAddress}:${currentPort}` : null;
|
||||
const lanUrl = draftValue && lanAccessActive && lanAddress && currentPort ? `http://${lanAddress}:${currentPort}` : null;
|
||||
const lanRequiresPassword = draftValue && !draftPassword.trim();
|
||||
const lanBlockedByMissingPassword = savedValue && !lanAccessActive && lanAccessBlockedReason === 'missing-password';
|
||||
const saveDisabled = isLoading || isSaving || !isDirty || lanRequiresPassword;
|
||||
|
||||
const handleToggle = React.useCallback(() => {
|
||||
setDraftValue((current) => !current);
|
||||
}, []);
|
||||
|
||||
const handlePasswordChange = React.useCallback((value: string) => {
|
||||
setDraftPassword(value);
|
||||
if (!value.trim()) {
|
||||
setDraftValue(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
const handleLaunchAtLoginToggle = React.useCallback(async () => {
|
||||
if (!launchAtLoginSupported || isSavingLaunchAtLogin) {
|
||||
return;
|
||||
@@ -252,9 +270,11 @@ export const DesktopNetworkSettings: React.FC = () => {
|
||||
type="password"
|
||||
className="h-7 max-w-sm"
|
||||
value={draftPassword}
|
||||
onChange={(event) => setDraftPassword(event.target.value)}
|
||||
onChange={(event) => handlePasswordChange(event.target.value)}
|
||||
placeholder={t('settings.openchamber.desktopPassword.field.passwordPlaceholder')}
|
||||
disabled={isLoading || isSaving}
|
||||
required={draftValue}
|
||||
aria-invalid={lanRequiresPassword}
|
||||
/>
|
||||
<div className="typography-micro text-muted-foreground/70">
|
||||
{t('settings.openchamber.desktopPassword.field.passwordDescription')}
|
||||
@@ -288,6 +308,11 @@ export const DesktopNetworkSettings: React.FC = () => {
|
||||
<div className="typography-micro text-[var(--status-warning)]/85">
|
||||
{t('settings.openchamber.desktopNetwork.field.warning')}
|
||||
</div>
|
||||
{lanRequiresPassword || lanBlockedByMissingPassword ? (
|
||||
<div className="typography-micro text-[var(--status-warning)]/85">
|
||||
{t('settings.openchamber.desktopNetwork.field.passwordRequiredWarning')}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -309,7 +334,7 @@ export const DesktopNetworkSettings: React.FC = () => {
|
||||
type="button"
|
||||
size="xs"
|
||||
onClick={handleSaveAndRestart}
|
||||
disabled={isLoading || isSaving || !isDirty}
|
||||
disabled={saveDisabled}
|
||||
className="shrink-0 !font-normal"
|
||||
>
|
||||
{isSaving ? t('settings.common.actions.saving') : t('settings.openchamber.desktopNetwork.actions.saveAndRestart')}
|
||||
|
||||
@@ -421,7 +421,7 @@ export const DirectoryExplorerDialog: React.FC<DirectoryExplorerDialogProps> = (
|
||||
});
|
||||
selectedTarget = result.path;
|
||||
} else if (shouldCreateSelection) {
|
||||
await opencodeClient.createDirectory(target, { allowOutsideWorkspace: true });
|
||||
await opencodeClient.createDirectory(target);
|
||||
}
|
||||
const added = addProject(selectedTarget);
|
||||
if (!added) {
|
||||
|
||||
@@ -664,7 +664,7 @@ export const DirectoryTree: React.FC<DirectoryTreeProps> = ({
|
||||
const fullPath = `${creatingInPath}/${dirName}`;
|
||||
|
||||
try {
|
||||
await opencodeClient.createDirectory(fullPath, { allowOutsideWorkspace: true });
|
||||
await opencodeClient.createDirectory(fullPath);
|
||||
|
||||
const children = await loadDirectory(creatingInPath);
|
||||
const updateItems = (items: DirectoryItem[]): DirectoryItem[] => {
|
||||
|
||||
@@ -620,6 +620,9 @@ export const ProjectNotesTodoPanel: React.FC<ProjectNotesTodoPanelProps> = ({
|
||||
path: result.path,
|
||||
allowOutsideWorkspace: 'true',
|
||||
});
|
||||
if (result.outsideFileGrant) {
|
||||
params.set('outsideFileGrant', result.outsideFileGrant);
|
||||
}
|
||||
const response = await runtimeFetch(`/api/fs/read?${params.toString()}`, { cache: 'no-store' });
|
||||
if (!response.ok) {
|
||||
toast.error(t('rightSidebar.contextNotesTodo.toast.readPlanFileFailed'));
|
||||
|
||||
@@ -43,9 +43,10 @@ import { useFileSearchStore } from '@/stores/useFileSearchStore';
|
||||
import { useDeviceInfo } from '@/lib/device';
|
||||
import { cn, getModifierLabel, getRevealLabelKey, hasModifier } from '@/lib/utils';
|
||||
import { getLanguageFromExtension, getImageMimeType, isDrawioFile, isImageFile } from '@/lib/toolHelpers';
|
||||
import { getRuntimeUrlResolver } from '@/lib/runtime-url';
|
||||
import { refreshRuntimeUrlAuthToken } from '@/lib/runtime-auth';
|
||||
import { getRuntimeApiBaseUrl } from '@/lib/runtime-switch';
|
||||
import { getRuntimeUrlResolver } from '@/lib/runtime-url';
|
||||
import { refreshRuntimeUrlAuthToken } from '@/lib/runtime-auth';
|
||||
import { getRuntimeApiBaseUrl } from '@/lib/runtime-switch';
|
||||
import { getOutsideFileGrant } from '@/lib/outsideFileGrants';
|
||||
import { DiagramEditor } from '@/components/diagram';
|
||||
import { useRuntimeAPIs } from '@/hooks/useRuntimeAPIs';
|
||||
import { EditorView } from '@codemirror/view';
|
||||
@@ -770,13 +771,17 @@ export const FilesView: React.FC<FilesViewProps> = ({ mode = 'full' }) => {
|
||||
|
||||
const openFiles = React.useMemo(() => openPaths.map(toFileNode), [openPaths, toFileNode]);
|
||||
const effectiveSelectedPath = React.useMemo(() => selectedPath ?? openPaths[0] ?? null, [openPaths, selectedPath]);
|
||||
const selectedFile = React.useMemo(() => (effectiveSelectedPath ? toFileNode(effectiveSelectedPath) : null), [effectiveSelectedPath, toFileNode]);
|
||||
const selectedFilePath = selectedFile?.path ?? '';
|
||||
const selectedFileIsOutsideWorkspace = Boolean(root && selectedFilePath && !isPathWithinRoot(selectedFilePath, root));
|
||||
const selectedFileReadOptions = React.useMemo(
|
||||
() => ({ allowOutsideWorkspace: mode === 'editor-only' && selectedFileIsOutsideWorkspace }),
|
||||
[mode, selectedFileIsOutsideWorkspace],
|
||||
);
|
||||
const selectedFile = React.useMemo(() => (effectiveSelectedPath ? toFileNode(effectiveSelectedPath) : null), [effectiveSelectedPath, toFileNode]);
|
||||
const selectedFilePath = selectedFile?.path ?? '';
|
||||
const selectedFileIsOutsideWorkspace = Boolean(root && selectedFilePath && !isPathWithinRoot(selectedFilePath, root));
|
||||
const selectedOutsideFileGrant = selectedFileIsOutsideWorkspace ? getOutsideFileGrant(selectedFilePath) : undefined;
|
||||
const selectedFileReadOptions = React.useMemo(
|
||||
() => ({
|
||||
allowOutsideWorkspace: mode === 'editor-only' && selectedFileIsOutsideWorkspace,
|
||||
outsideFileGrant: selectedOutsideFileGrant,
|
||||
}),
|
||||
[mode, selectedFileIsOutsideWorkspace, selectedOutsideFileGrant],
|
||||
);
|
||||
|
||||
// Editor tabs horizontal scroll fades
|
||||
const editorTabsScrollRef = React.useRef<HTMLDivElement>(null);
|
||||
@@ -1444,16 +1449,19 @@ export const FilesView: React.FC<FilesViewProps> = ({ mode = 'full' }) => {
|
||||
};
|
||||
}, [currentDirectory, debouncedSearchQuery, searchFiles, showHidden, showGitignored]);
|
||||
|
||||
const readFile = React.useCallback(async (path: string, options?: { allowOutsideWorkspace?: boolean; optional?: boolean }): Promise<string> => {
|
||||
const readFile = React.useCallback(async (path: string, options?: { allowOutsideWorkspace?: boolean; outsideFileGrant?: string; optional?: boolean }): Promise<string> => {
|
||||
if (files.readFile) {
|
||||
const result = await files.readFile(path, options);
|
||||
return result.content ?? '';
|
||||
}
|
||||
|
||||
const params = new URLSearchParams({ path });
|
||||
if (options?.allowOutsideWorkspace) {
|
||||
params.set('allowOutsideWorkspace', 'true');
|
||||
}
|
||||
if (options?.allowOutsideWorkspace) {
|
||||
params.set('allowOutsideWorkspace', 'true');
|
||||
}
|
||||
if (options?.outsideFileGrant) {
|
||||
params.set('outsideFileGrant', options.outsideFileGrant);
|
||||
}
|
||||
if (options?.optional) {
|
||||
params.set('optional', 'true');
|
||||
}
|
||||
@@ -1468,7 +1476,7 @@ export const FilesView: React.FC<FilesViewProps> = ({ mode = 'full' }) => {
|
||||
return response.text();
|
||||
}, [files, t]);
|
||||
|
||||
const readFileStat = React.useCallback(async (path: string, options?: { allowOutsideWorkspace?: boolean }): Promise<FileStatSnapshot | null> => {
|
||||
const readFileStat = React.useCallback(async (path: string, options?: { allowOutsideWorkspace?: boolean; outsideFileGrant?: string }): Promise<FileStatSnapshot | null> => {
|
||||
if (files.statFile) {
|
||||
const result = await files.statFile(path, options);
|
||||
return {
|
||||
@@ -1711,7 +1719,11 @@ export const FilesView: React.FC<FilesViewProps> = ({ mode = 'full' }) => {
|
||||
|
||||
setFileLoading(true);
|
||||
|
||||
const readOptions = { allowOutsideWorkspace: mode === 'editor-only' && Boolean(root) && !isPathWithinRoot(node.path, root) };
|
||||
const outsideFileGrant = getOutsideFileGrant(node.path);
|
||||
const readOptions = {
|
||||
allowOutsideWorkspace: mode === 'editor-only' && Boolean(root) && !isPathWithinRoot(node.path, root),
|
||||
outsideFileGrant,
|
||||
};
|
||||
|
||||
await readFile(node.path, readOptions)
|
||||
.then((content) => {
|
||||
@@ -2798,9 +2810,9 @@ export const FilesView: React.FC<FilesViewProps> = ({ mode = 'full' }) => {
|
||||
[lightTheme.metadata.id, darkTheme.metadata.id],
|
||||
);
|
||||
|
||||
const imageAssetAuthKey = selectedFile?.path && isSelectedImage && !runtime.isDesktop && !isSelectedSvg
|
||||
? `${selectedFile.path}|${selectedFileReadOptions.allowOutsideWorkspace ? 'outside' : 'workspace'}`
|
||||
: '';
|
||||
const imageAssetAuthKey = selectedFile?.path && isSelectedImage && !runtime.isDesktop && !isSelectedSvg
|
||||
? `${selectedFile.path}|${selectedFileReadOptions.allowOutsideWorkspace ? 'outside' : 'workspace'}|${selectedFileReadOptions.outsideFileGrant ?? ''}`
|
||||
: '';
|
||||
|
||||
React.useEffect(() => {
|
||||
if (!imageAssetAuthKey) {
|
||||
@@ -2830,10 +2842,11 @@ export const FilesView: React.FC<FilesViewProps> = ({ mode = 'full' }) => {
|
||||
: desktopImageSrc)
|
||||
: (isSelectedSvg
|
||||
? `data:${getImageMimeType(selectedFile.path)};utf8,${encodeURIComponent(fileContent)}`
|
||||
: imageAssetAuthReadyKey === imageAssetAuthKey ? getRuntimeUrlResolver().authenticatedAsset('/api/fs/raw', {
|
||||
path: selectedFile.path,
|
||||
allowOutsideWorkspace: selectedFileReadOptions.allowOutsideWorkspace ? 'true' : undefined,
|
||||
}) : ''))
|
||||
: imageAssetAuthReadyKey === imageAssetAuthKey ? getRuntimeUrlResolver().authenticatedAsset('/api/fs/raw', {
|
||||
path: selectedFile.path,
|
||||
allowOutsideWorkspace: selectedFileReadOptions.allowOutsideWorkspace ? 'true' : undefined,
|
||||
outsideFileGrant: selectedFileReadOptions.outsideFileGrant,
|
||||
}) : ''))
|
||||
: '';
|
||||
|
||||
React.useEffect(() => {
|
||||
@@ -2849,10 +2862,11 @@ export const FilesView: React.FC<FilesViewProps> = ({ mode = 'full' }) => {
|
||||
|
||||
const srcPromise = files.readFileBinary
|
||||
? files.readFileBinary(selectedFile.path, selectedFileReadOptions).then((result) => result.dataUrl)
|
||||
: Promise.resolve(getRuntimeUrlResolver().authenticatedAsset('/api/fs/raw', {
|
||||
path: selectedFile.path,
|
||||
allowOutsideWorkspace: selectedFileReadOptions.allowOutsideWorkspace ? 'true' : undefined,
|
||||
}));
|
||||
: Promise.resolve(getRuntimeUrlResolver().authenticatedAsset('/api/fs/raw', {
|
||||
path: selectedFile.path,
|
||||
allowOutsideWorkspace: selectedFileReadOptions.allowOutsideWorkspace ? 'true' : undefined,
|
||||
outsideFileGrant: selectedFileReadOptions.outsideFileGrant,
|
||||
}));
|
||||
|
||||
await srcPromise
|
||||
.then((src) => {
|
||||
|
||||
@@ -18,7 +18,7 @@ import { Icon } from "@/components/icon/Icon";
|
||||
import { useSessionUIStore } from '@/sync/session-ui-store';
|
||||
import { useInputStore } from '@/sync/input-store';
|
||||
import { useUIStore } from '@/stores/useUIStore';
|
||||
import { execCommand } from '@/lib/execCommands';
|
||||
import { getGitCommitSummaries } from '@/lib/gitApi';
|
||||
import { renderMagicPrompt } from '@/lib/magicPrompts';
|
||||
import {
|
||||
abortIntegrate,
|
||||
@@ -143,18 +143,7 @@ export const IntegrateCommitsSection: React.FC<{
|
||||
const max = 50;
|
||||
// Show newest -> oldest.
|
||||
const subset = plan.commits.slice(-max).reverse();
|
||||
const quoted = subset.map((s) => JSON.stringify(s)).join(' ');
|
||||
const result = await execCommand(
|
||||
`git show -s --format=%H%x09%h%x09%s ${quoted}`,
|
||||
repoRoot
|
||||
);
|
||||
const lines = (result.stdout || '').split(/\r?\n/).filter(Boolean);
|
||||
const parsed: Array<{ sha: string; short: string; subject: string }> = [];
|
||||
for (const line of lines) {
|
||||
const [sha, short, subject] = line.split('\t');
|
||||
if (!sha || !short) continue;
|
||||
parsed.push({ sha, short, subject: subject || '' });
|
||||
}
|
||||
const parsed = await getGitCommitSummaries(repoRoot, subset);
|
||||
if (!cancelled) {
|
||||
setCommitSummaries(parsed);
|
||||
setShowAllCommits(false);
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import React from 'react';
|
||||
import { execCommand } from '@/lib/execCommands';
|
||||
import { resolveGitPrimaryRoot, resolveGitTopLevel } from '@/lib/gitApi';
|
||||
import type { WorktreeMetadata } from '@/types/worktree';
|
||||
|
||||
const normalizePath = (value: string): string => {
|
||||
@@ -9,29 +9,10 @@ const normalizePath = (value: string): string => {
|
||||
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
|
||||
* narrow git runtime APIs 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.
|
||||
*
|
||||
@@ -62,29 +43,19 @@ export function useDetectedWorktreeMetadata(
|
||||
|
||||
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),
|
||||
]);
|
||||
const [projectRootRaw, worktreePathRaw] = await Promise.all([
|
||||
resolveGitPrimaryRoot(currentDirectory),
|
||||
resolveGitTopLevel(currentDirectory),
|
||||
]).catch(() => ['', '']);
|
||||
if (cancelled) return;
|
||||
|
||||
if (!gitDirResult.success || !toplevelResult.success) {
|
||||
return;
|
||||
}
|
||||
|
||||
const gitDir = normalizePath((gitDirResult.stdout || '').trim());
|
||||
const projectRoot = deriveProjectRoot(gitDir);
|
||||
|
||||
if (!projectRoot) {
|
||||
return;
|
||||
}
|
||||
|
||||
const projectRoot = normalizePath(projectRootRaw);
|
||||
// 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());
|
||||
const worktreePath = normalizePath(worktreePathRaw);
|
||||
|
||||
// Sanity-check: secondary worktree path must differ from project root
|
||||
if (!worktreePath || worktreePath === projectRoot) {
|
||||
if (!projectRoot || !worktreePath || worktreePath === projectRoot) {
|
||||
return;
|
||||
}
|
||||
|
||||
|
||||
@@ -592,6 +592,7 @@ export interface ListDirectoryOptions {
|
||||
|
||||
export interface FileReadOptions {
|
||||
allowOutsideWorkspace?: boolean;
|
||||
outsideFileGrant?: string;
|
||||
optional?: boolean;
|
||||
}
|
||||
|
||||
|
||||
@@ -32,11 +32,11 @@ const classifyReadError = (error: unknown): ContextFileOpenFailureReason => {
|
||||
|
||||
const readFileContent = async (files: FilesAPI, path: string): Promise<string> => {
|
||||
if (files.readFile) {
|
||||
const result = await files.readFile(path, { allowOutsideWorkspace: true, optional: true });
|
||||
const result = await files.readFile(path, { optional: true });
|
||||
return result.content ?? '';
|
||||
}
|
||||
|
||||
const params = new URLSearchParams({ path, allowOutsideWorkspace: 'true', optional: 'true' });
|
||||
const params = new URLSearchParams({ path, optional: 'true' });
|
||||
const response = await runtimeFetch(`/api/fs/read?${params.toString()}`, {
|
||||
// Avoid conditional requests (304 + empty body).
|
||||
cache: 'no-store',
|
||||
|
||||
@@ -192,6 +192,7 @@ export type DesktopSettings = {
|
||||
type DesktopBridgeGlobal = {
|
||||
invoke?: (cmd: string, args?: Record<string, unknown>) => Promise<unknown>;
|
||||
openDialog?: (options: Record<string, unknown>) => Promise<unknown>;
|
||||
grantFileAccess?: (path: string) => Promise<unknown>;
|
||||
openExternal?: (url: string) => Promise<unknown>;
|
||||
listen?: (
|
||||
event: string,
|
||||
@@ -433,22 +434,43 @@ export const requestDirectoryAccess = async (
|
||||
return { success: true, path: directoryPath };
|
||||
};
|
||||
|
||||
const isDesktopFileGrantResult = (
|
||||
value: unknown
|
||||
): value is { path?: unknown; outsideFileGrant?: unknown } => (
|
||||
value !== null && typeof value === 'object' && !Array.isArray(value)
|
||||
);
|
||||
|
||||
export const requestFileAccess = async (
|
||||
options?: { filters?: Array<{ name: string; extensions: string[] }>; defaultPath?: string }
|
||||
): Promise<{ success: boolean; path?: string; error?: string }> => {
|
||||
): Promise<{ success: boolean; path?: string; outsideFileGrant?: string; error?: string }> => {
|
||||
if (hasDesktopInvoke() && isDesktopLocalOriginActive()) {
|
||||
try {
|
||||
const selected = await getDesktopBridge()?.openDialog?.({
|
||||
directory: false,
|
||||
multiple: false,
|
||||
title: 'Select File',
|
||||
returnGrant: true,
|
||||
...(options?.filters ? { filters: options.filters } : {}),
|
||||
...(options?.defaultPath ? { defaultPath: options.defaultPath } : {}),
|
||||
});
|
||||
if (!selected || typeof selected !== 'string') {
|
||||
if (!selected) {
|
||||
return { success: false, error: 'File selection cancelled' };
|
||||
}
|
||||
return { success: true, path: selected };
|
||||
if (typeof selected === 'string') {
|
||||
return { success: true, path: selected };
|
||||
}
|
||||
if (!isDesktopFileGrantResult(selected)) {
|
||||
return { success: false, error: 'File selection cancelled' };
|
||||
}
|
||||
const path = typeof selected.path === 'string' ? selected.path : '';
|
||||
if (!path) {
|
||||
return { success: false, error: 'File selection cancelled' };
|
||||
}
|
||||
return {
|
||||
success: true,
|
||||
path,
|
||||
outsideFileGrant: typeof selected.outsideFileGrant === 'string' ? selected.outsideFileGrant : undefined,
|
||||
};
|
||||
} catch (error) {
|
||||
console.warn('Failed to request file access', error);
|
||||
return { success: false, error: error instanceof Error ? error.message : String(error) };
|
||||
@@ -458,6 +480,34 @@ export const requestFileAccess = async (
|
||||
return { success: false, error: 'Native file picker not available' };
|
||||
};
|
||||
|
||||
export const requestExistingFileAccess = async (
|
||||
path: string
|
||||
): Promise<{ success: boolean; path?: string; outsideFileGrant?: string; error?: string }> => {
|
||||
const targetPath = typeof path === 'string' ? path.trim() : '';
|
||||
if (!targetPath) {
|
||||
return { success: false, error: 'Path is required' };
|
||||
}
|
||||
if (!hasDesktopInvoke() || !isDesktopLocalOriginActive()) {
|
||||
return { success: false, error: 'Native file access not available' };
|
||||
}
|
||||
|
||||
try {
|
||||
const selected = await getDesktopBridge()?.grantFileAccess?.(targetPath);
|
||||
if (!isDesktopFileGrantResult(selected)) {
|
||||
return { success: false, error: 'File access was not granted' };
|
||||
}
|
||||
const grantedPath = typeof selected.path === 'string' ? selected.path : '';
|
||||
const outsideFileGrant = typeof selected.outsideFileGrant === 'string' ? selected.outsideFileGrant : '';
|
||||
if (!grantedPath || !outsideFileGrant) {
|
||||
return { success: false, error: 'File access was not granted' };
|
||||
}
|
||||
return { success: true, path: grantedPath, outsideFileGrant };
|
||||
} catch (error) {
|
||||
console.warn('Failed to request existing file access', error);
|
||||
return { success: false, error: error instanceof Error ? error.message : String(error) };
|
||||
}
|
||||
};
|
||||
|
||||
export const startAccessingDirectory = async (
|
||||
directoryPath: string
|
||||
): Promise<{ success: boolean; error?: string }> => {
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import type { CommandExecResult } from '@/lib/api/types';
|
||||
import { execCommand } from '@/lib/execCommands';
|
||||
import { runtimeFetch } from '@/lib/runtime-fetch';
|
||||
|
||||
export type IntegratePlan = {
|
||||
repoRoot: string;
|
||||
@@ -32,334 +31,44 @@ export type IntegrateResult =
|
||||
| { kind: 'success'; moved: number }
|
||||
| { kind: 'conflict'; state: IntegrateInProgress; details: IntegrateConflictDetails };
|
||||
|
||||
const shellQuote = (value: string): string => {
|
||||
const v = value.trim();
|
||||
if (!v) return "''";
|
||||
return `'${v.replace(/'/g, `'\\''`)}'`;
|
||||
const postIntegrate = async <T>(action: string, body: unknown): Promise<T> => {
|
||||
const response = await runtimeFetch(`/api/git/integrate/${action}`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
if (!response.ok) {
|
||||
const payload = await response.json().catch(() => null) as { error?: string } | null;
|
||||
throw new Error(payload?.error || `Git integrate request failed: ${response.statusText}`);
|
||||
}
|
||||
return response.json() as Promise<T>;
|
||||
};
|
||||
|
||||
const trimLines = (value: string | undefined): string[] =>
|
||||
(value || '')
|
||||
.split(/\r?\n/)
|
||||
.map((l) => l.trim())
|
||||
.filter(Boolean);
|
||||
|
||||
const isOk = (result: CommandExecResult): boolean => Boolean(result.success);
|
||||
|
||||
const stdoutText = (result: CommandExecResult): string => (result.stdout || '').trim();
|
||||
const stderrText = (result: CommandExecResult): string => (result.stderr || '').trim();
|
||||
|
||||
type GitWorktreeEntry = { path: string; branchRef: string | null };
|
||||
|
||||
async function listGitWorktrees(repoRoot: string): Promise<GitWorktreeEntry[]> {
|
||||
const out = await execCommand('git worktree list --porcelain', repoRoot);
|
||||
const lines = (out.stdout || '').split(/\r?\n/);
|
||||
|
||||
const entries: GitWorktreeEntry[] = [];
|
||||
let current: GitWorktreeEntry | null = null;
|
||||
|
||||
for (const line of lines) {
|
||||
if (line.startsWith('worktree ')) {
|
||||
if (current) entries.push(current);
|
||||
current = { path: line.slice('worktree '.length).trim(), branchRef: null };
|
||||
continue;
|
||||
}
|
||||
if (!current) continue;
|
||||
if (line.startsWith('branch ')) {
|
||||
current.branchRef = line.slice('branch '.length).trim();
|
||||
}
|
||||
}
|
||||
if (current) entries.push(current);
|
||||
|
||||
return entries.filter((e) => Boolean(e.path));
|
||||
}
|
||||
|
||||
async function computeCleanWorktreesToSync(args: {
|
||||
repoRoot: string;
|
||||
targetBranch: string;
|
||||
excludePaths: string[];
|
||||
}): Promise<string[]> {
|
||||
const targetRef = `refs/heads/${args.targetBranch}`;
|
||||
const exclude = new Set(args.excludePaths);
|
||||
const entries = await listGitWorktrees(args.repoRoot);
|
||||
const candidates = entries
|
||||
.filter((e) => e.branchRef === targetRef)
|
||||
.map((e) => e.path)
|
||||
.filter((p) => p && !exclude.has(p));
|
||||
|
||||
const clean: string[] = [];
|
||||
for (const path of candidates) {
|
||||
const status = await execCommand('git status --porcelain', path);
|
||||
if (!stdoutText(status)) {
|
||||
clean.push(path);
|
||||
}
|
||||
}
|
||||
return clean;
|
||||
}
|
||||
|
||||
async function syncCleanTargetWorktrees(repoRoot: string, paths: string[]): Promise<void> {
|
||||
for (const path of paths) {
|
||||
await execCommand('git reset --hard', path).catch(() => undefined);
|
||||
}
|
||||
}
|
||||
|
||||
async function ensureLocalBranch(repoRoot: string, candidate: string): Promise<string> {
|
||||
const raw = candidate.trim();
|
||||
if (!raw || raw === 'HEAD') {
|
||||
return 'HEAD';
|
||||
}
|
||||
|
||||
const hasLocal = await execCommand(
|
||||
`git show-ref --verify --quiet ${shellQuote(`refs/heads/${raw}`)}`,
|
||||
repoRoot
|
||||
);
|
||||
if (isOk(hasLocal)) {
|
||||
return raw;
|
||||
}
|
||||
|
||||
// remotes/origin/main -> main (track origin/main)
|
||||
if (raw.startsWith('remotes/')) {
|
||||
const remoteRef = raw.slice('remotes/'.length);
|
||||
const parts = remoteRef.split('/');
|
||||
const remote = parts[0] || 'origin';
|
||||
const name = parts.slice(1).join('/');
|
||||
if (name) {
|
||||
await execCommand(`git branch --track ${shellQuote(name)} ${shellQuote(`${remote}/${name}`)}`, repoRoot);
|
||||
return name;
|
||||
}
|
||||
}
|
||||
|
||||
// Try origin/<raw>
|
||||
const remoteCheck = await execCommand(
|
||||
`git show-ref --verify --quiet ${shellQuote(`refs/remotes/origin/${raw}`)}`,
|
||||
repoRoot
|
||||
);
|
||||
if (isOk(remoteCheck)) {
|
||||
await execCommand(`git branch --track ${shellQuote(raw)} ${shellQuote(`origin/${raw}`)}`, repoRoot);
|
||||
return raw;
|
||||
}
|
||||
|
||||
return raw;
|
||||
}
|
||||
|
||||
export async function computeIntegratePlan(args: {
|
||||
repoRoot: string;
|
||||
sourceBranch: string;
|
||||
targetBranch: string;
|
||||
}): Promise<IntegratePlan> {
|
||||
const repoRoot = args.repoRoot;
|
||||
const sourceBranch = args.sourceBranch.trim();
|
||||
const targetBranchRaw = args.targetBranch.trim();
|
||||
if (!sourceBranch || !targetBranchRaw) {
|
||||
return { repoRoot, sourceBranch, targetBranch: targetBranchRaw, commits: [] };
|
||||
}
|
||||
|
||||
const targetBranch = await ensureLocalBranch(repoRoot, targetBranchRaw);
|
||||
|
||||
const cherry = await execCommand(`git cherry ${shellQuote(targetBranch)} ${shellQuote(sourceBranch)}`, repoRoot);
|
||||
const cherryLines = trimLines(cherry.stdout);
|
||||
const plus = new Set<string>();
|
||||
for (const line of cherryLines) {
|
||||
const match = line.match(/^\+\s+([0-9a-f]{7,40})\b/i);
|
||||
if (match) {
|
||||
plus.add(match[1]);
|
||||
}
|
||||
}
|
||||
|
||||
const revList = await execCommand(
|
||||
`git rev-list --reverse ${shellQuote(`${targetBranch}..${sourceBranch}`)}`,
|
||||
repoRoot
|
||||
);
|
||||
const ordered = trimLines(revList.stdout);
|
||||
const commits = ordered.filter((sha) => plus.has(sha));
|
||||
|
||||
return { repoRoot, sourceBranch, targetBranch, commits };
|
||||
}
|
||||
|
||||
async function createTempWorktree(repoRoot: string, targetBranch: string): Promise<string> {
|
||||
// Use two separate execCommand calls instead of shell-specific && operator
|
||||
// to support non-POSIX shells like Nushell (see #870)
|
||||
const mkdirResult = await execCommand('mkdir -p "$HOME/.config/openchamber/tmp"', repoRoot);
|
||||
if (!isOk(mkdirResult)) {
|
||||
throw new Error(stderrText(mkdirResult) || 'Failed to create temp directory parent');
|
||||
}
|
||||
const tmp = await execCommand(
|
||||
'mktemp -d "$HOME/.config/openchamber/tmp/oc-integrate-XXXXXX"',
|
||||
repoRoot
|
||||
);
|
||||
const tmpDir = stdoutText(tmp);
|
||||
if (!tmpDir) {
|
||||
throw new Error(stderrText(tmp) || 'Failed to create temp directory');
|
||||
}
|
||||
const add = await execCommand(
|
||||
`git worktree add --force ${shellQuote(tmpDir)} ${shellQuote(targetBranch)}`,
|
||||
repoRoot
|
||||
);
|
||||
if (!isOk(add)) {
|
||||
throw new Error(stderrText(add) || 'Failed to create temp worktree');
|
||||
}
|
||||
return tmpDir;
|
||||
}
|
||||
|
||||
async function removeTempWorktree(repoRoot: string, tmpDir: string): Promise<void> {
|
||||
await execCommand(`git worktree remove --force ${shellQuote(tmpDir)}`, repoRoot).catch(() => undefined);
|
||||
await execCommand('git worktree prune', repoRoot).catch(() => undefined);
|
||||
}
|
||||
|
||||
async function maybeFastForwardUpstream(tmpDir: string): Promise<void> {
|
||||
const upstream = await execCommand('git rev-parse --abbrev-ref --symbolic-full-name @{u}', tmpDir);
|
||||
const upstreamRef = stdoutText(upstream);
|
||||
if (!upstreamRef) {
|
||||
return;
|
||||
}
|
||||
await execCommand('git fetch', tmpDir);
|
||||
const ff = await execCommand(`git merge --ff-only ${shellQuote(upstreamRef)}`, tmpDir);
|
||||
if (!isOk(ff)) {
|
||||
throw new Error(stderrText(ff) || 'Fast-forward failed');
|
||||
}
|
||||
}
|
||||
|
||||
async function collectConflictDetails(tmpDir: string): Promise<IntegrateConflictDetails> {
|
||||
const status = await execCommand('git status --porcelain', tmpDir);
|
||||
const unmerged = await execCommand('git diff --name-only --diff-filter=U', tmpDir);
|
||||
const diff = await execCommand('git diff', tmpDir);
|
||||
const meta = await execCommand('git show --no-patch --pretty=fuller CHERRY_PICK_HEAD', tmpDir);
|
||||
const patch = await execCommand('git show CHERRY_PICK_HEAD', tmpDir);
|
||||
|
||||
return {
|
||||
statusPorcelain: status.stdout || '',
|
||||
unmergedFiles: trimLines(unmerged.stdout),
|
||||
diff: diff.stdout || diff.stderr || '',
|
||||
currentPatchMeta: meta.stdout || meta.stderr || '',
|
||||
currentPatch: patch.stdout || patch.stderr || '',
|
||||
};
|
||||
return postIntegrate<IntegratePlan>('plan', args);
|
||||
}
|
||||
|
||||
export async function getIntegrateConflictDetails(tmpDir: string): Promise<IntegrateConflictDetails> {
|
||||
return collectConflictDetails(tmpDir);
|
||||
return postIntegrate<IntegrateConflictDetails>('conflict-details', { tempWorktreePath: tmpDir });
|
||||
}
|
||||
|
||||
export async function isCherryPickInProgress(tmpDir: string): Promise<boolean> {
|
||||
const head = await execCommand('git rev-parse --verify --quiet CHERRY_PICK_HEAD', tmpDir);
|
||||
return isOk(head);
|
||||
const result = await postIntegrate<{ inProgress: boolean }>('cherry-pick-status', { tempWorktreePath: tmpDir });
|
||||
return result.inProgress;
|
||||
}
|
||||
|
||||
export async function integrateWorktreeCommits(plan: IntegratePlan): Promise<IntegrateResult> {
|
||||
if (plan.commits.length === 0) {
|
||||
return { kind: 'noop', reason: 'No commits to move' };
|
||||
}
|
||||
|
||||
const tmpDir = await createTempWorktree(plan.repoRoot, plan.targetBranch);
|
||||
|
||||
let remaining: string[] = [];
|
||||
try {
|
||||
await maybeFastForwardUpstream(tmpDir);
|
||||
|
||||
const clean = await execCommand('git status --porcelain', tmpDir);
|
||||
if (stdoutText(clean)) {
|
||||
throw new Error('Target branch has local changes; abort integration and retry');
|
||||
}
|
||||
|
||||
const cleanTargetWorktrees = await computeCleanWorktreesToSync({
|
||||
repoRoot: plan.repoRoot,
|
||||
targetBranch: plan.targetBranch,
|
||||
excludePaths: [tmpDir],
|
||||
}).catch(() => []);
|
||||
|
||||
remaining = [...plan.commits];
|
||||
while (remaining.length > 0) {
|
||||
const sha = remaining[0];
|
||||
const pick = await execCommand(`git cherry-pick ${shellQuote(sha)}`, tmpDir);
|
||||
if (isOk(pick)) {
|
||||
remaining.shift();
|
||||
continue;
|
||||
}
|
||||
|
||||
const unmerged = await execCommand('git diff --name-only --diff-filter=U', tmpDir);
|
||||
const unmergedFiles = trimLines(unmerged.stdout);
|
||||
if (unmergedFiles.length > 0) {
|
||||
const details = await collectConflictDetails(tmpDir);
|
||||
return {
|
||||
kind: 'conflict',
|
||||
state: {
|
||||
repoRoot: plan.repoRoot,
|
||||
tempWorktreePath: tmpDir,
|
||||
sourceBranch: plan.sourceBranch,
|
||||
targetBranch: plan.targetBranch,
|
||||
cleanTargetWorktrees,
|
||||
remainingCommits: remaining,
|
||||
currentCommit: sha,
|
||||
},
|
||||
details,
|
||||
};
|
||||
}
|
||||
|
||||
throw new Error(stderrText(pick) || 'Cherry-pick failed');
|
||||
}
|
||||
|
||||
await removeTempWorktree(plan.repoRoot, tmpDir);
|
||||
await syncCleanTargetWorktrees(plan.repoRoot, cleanTargetWorktrees).catch(() => undefined);
|
||||
return { kind: 'success', moved: plan.commits.length };
|
||||
} catch (e) {
|
||||
// Cleanup on any non-conflict error.
|
||||
await removeTempWorktree(plan.repoRoot, tmpDir).catch(() => undefined);
|
||||
throw e;
|
||||
}
|
||||
return postIntegrate<IntegrateResult>('run', { plan });
|
||||
}
|
||||
|
||||
export async function abortIntegrate(state: IntegrateInProgress): Promise<void> {
|
||||
await execCommand('git cherry-pick --abort', state.tempWorktreePath).catch(() => undefined);
|
||||
await removeTempWorktree(state.repoRoot, state.tempWorktreePath);
|
||||
await postIntegrate<{ success: boolean }>('abort', { state });
|
||||
}
|
||||
|
||||
export async function continueIntegrate(state: IntegrateInProgress): Promise<IntegrateResult> {
|
||||
const cont = await execCommand('git cherry-pick --continue', state.tempWorktreePath);
|
||||
if (!isOk(cont)) {
|
||||
const unmerged = await execCommand('git diff --name-only --diff-filter=U', state.tempWorktreePath);
|
||||
const unmergedFiles = trimLines(unmerged.stdout);
|
||||
if (unmergedFiles.length > 0) {
|
||||
const details = await collectConflictDetails(state.tempWorktreePath);
|
||||
return { kind: 'conflict', state, details };
|
||||
}
|
||||
throw new Error(stderrText(cont) || 'Cherry-pick continue failed');
|
||||
}
|
||||
|
||||
const tmpDir = state.tempWorktreePath;
|
||||
const remaining = [...state.remainingCommits];
|
||||
if (remaining.length > 0 && remaining[0] === state.currentCommit) {
|
||||
remaining.shift();
|
||||
}
|
||||
|
||||
const still = [...remaining];
|
||||
while (still.length > 0) {
|
||||
const sha = still[0];
|
||||
const pick = await execCommand(`git cherry-pick ${shellQuote(sha)}`, tmpDir);
|
||||
if (isOk(pick)) {
|
||||
still.shift();
|
||||
continue;
|
||||
}
|
||||
const unmerged = await execCommand('git diff --name-only --diff-filter=U', tmpDir);
|
||||
const unmergedFiles = trimLines(unmerged.stdout);
|
||||
if (unmergedFiles.length > 0) {
|
||||
const details = await collectConflictDetails(tmpDir);
|
||||
return {
|
||||
kind: 'conflict',
|
||||
state: {
|
||||
repoRoot: state.repoRoot,
|
||||
tempWorktreePath: tmpDir,
|
||||
sourceBranch: state.sourceBranch,
|
||||
targetBranch: state.targetBranch,
|
||||
cleanTargetWorktrees: state.cleanTargetWorktrees,
|
||||
remainingCommits: still,
|
||||
currentCommit: sha,
|
||||
},
|
||||
details,
|
||||
};
|
||||
}
|
||||
throw new Error(stderrText(pick) || 'Cherry-pick failed');
|
||||
}
|
||||
|
||||
await removeTempWorktree(state.repoRoot, state.tempWorktreePath);
|
||||
await syncCleanTargetWorktrees(state.repoRoot, state.cleanTargetWorktrees).catch(() => undefined);
|
||||
return { kind: 'success', moved: state.remainingCommits.length };
|
||||
return postIntegrate<IntegrateResult>('continue', { state });
|
||||
}
|
||||
|
||||
@@ -101,6 +101,24 @@ export async function getGitStatus(directory: string, options?: { mode?: 'light'
|
||||
return gitHttp.getGitStatus(directory, options);
|
||||
}
|
||||
|
||||
export async function resolveGitPrimaryRoot(directory: string): Promise<string> {
|
||||
const result = await gitHttp.resolveGitPrimaryRoot(directory);
|
||||
return result.root;
|
||||
}
|
||||
|
||||
export async function resolveGitTopLevel(directory: string): Promise<string> {
|
||||
const result = await gitHttp.resolveGitTopLevel(directory);
|
||||
return result.root;
|
||||
}
|
||||
|
||||
export async function getGitCommitSummaries(
|
||||
directory: string,
|
||||
shas: string[]
|
||||
): Promise<Array<{ sha: string; short: string; subject: string }>> {
|
||||
const result = await gitHttp.getGitCommitSummaries(directory, shas);
|
||||
return result.commits;
|
||||
}
|
||||
|
||||
export async function getGitDiff(directory: string, options: import('./api/types').GetGitDiffOptions): Promise<import('./api/types').GitDiffResponse> {
|
||||
const runtime = getRuntimeGit();
|
||||
if (runtime) return runtime.getGitDiff(directory, options);
|
||||
|
||||
@@ -133,6 +133,52 @@ export async function getGitStatus(directory: string, options?: { mode?: 'light'
|
||||
}
|
||||
}
|
||||
|
||||
export async function resolveGitPrimaryRoot(directory: string): Promise<{ root: string }> {
|
||||
const response = await runtimeFetch(buildUrl(`${API_BASE}/primary-root`, directory));
|
||||
if (!response.ok) {
|
||||
throw new Error(`Failed to resolve git primary root: ${response.statusText}`);
|
||||
}
|
||||
const payload = await response.json().catch(() => ({})) as { root?: string };
|
||||
return { root: typeof payload.root === 'string' && payload.root ? payload.root : directory };
|
||||
}
|
||||
|
||||
export async function resolveGitTopLevel(directory: string): Promise<{ root: string }> {
|
||||
const response = await runtimeFetch(buildUrl(`${API_BASE}/toplevel`, directory));
|
||||
if (!response.ok) {
|
||||
throw new Error(`Failed to resolve git toplevel: ${response.statusText}`);
|
||||
}
|
||||
const payload = await response.json().catch(() => ({})) as { root?: string };
|
||||
return { root: typeof payload.root === 'string' && payload.root ? payload.root : directory };
|
||||
}
|
||||
|
||||
export async function getGitCommitSummaries(
|
||||
directory: string,
|
||||
shas: string[]
|
||||
): Promise<{ commits: Array<{ sha: string; short: string; subject: string }> }> {
|
||||
const response = await runtimeFetch(buildUrl(`${API_BASE}/commit-summaries`, directory), {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ shas }),
|
||||
});
|
||||
if (!response.ok) {
|
||||
throw new Error(`Failed to get git commit summaries: ${response.statusText}`);
|
||||
}
|
||||
const payload = await response.json().catch(() => ({})) as {
|
||||
commits?: Array<{ sha?: string; short?: string; subject?: string }>;
|
||||
};
|
||||
return {
|
||||
commits: Array.isArray(payload.commits)
|
||||
? payload.commits
|
||||
.map((entry) => ({
|
||||
sha: typeof entry.sha === 'string' ? entry.sha : '',
|
||||
short: typeof entry.short === 'string' ? entry.short : '',
|
||||
subject: typeof entry.subject === 'string' ? entry.subject : '',
|
||||
}))
|
||||
.filter((entry) => entry.sha && entry.short)
|
||||
: [],
|
||||
};
|
||||
}
|
||||
|
||||
export async function getGitDiff(directory: string, options: GetGitDiffOptions): Promise<GitDiffResponse> {
|
||||
const { path, staged, contextLines } = options;
|
||||
if (!path) {
|
||||
|
||||
@@ -809,6 +809,7 @@ export const settingsDict = {
|
||||
'settings.openchamber.desktopNetwork.field.allowLanAccess': 'Let other devices on your local network open this app',
|
||||
'settings.openchamber.desktopNetwork.field.allowLanAccessDescription': 'Restarts the app so phones, tablets, and other computers on your Wi-Fi can open it.',
|
||||
'settings.openchamber.desktopNetwork.field.warning': 'Warning: while enabled, the app is reachable by anyone on the same local network.',
|
||||
'settings.openchamber.desktopNetwork.field.passwordRequiredWarning': 'LAN access requires a Desktop UI Password. Until one is set, the desktop app starts local-only.',
|
||||
'settings.openchamber.desktopPassword.field.password': 'Desktop UI Password',
|
||||
'settings.openchamber.desktopPassword.field.passwordPlaceholder': 'No password required',
|
||||
'settings.openchamber.desktopPassword.field.passwordDescription': 'OpenChamber asks after restart, then when the login session expires: after 12 hours, or 7 days with Trust this device. Leave empty to disable login.',
|
||||
|
||||
@@ -776,6 +776,7 @@ export const settingsDict = {
|
||||
"settings.openchamber.desktopNetwork.field.allowLanAccess": "Permitir que otros dispositivos en tu red local abran esta aplicación",
|
||||
"settings.openchamber.desktopNetwork.field.allowLanAccessDescription": "Reinicia la aplicación para que los teléfonos, tablets y otros ordenadores en tu Wi-Fi puedan abrirla.",
|
||||
"settings.openchamber.desktopNetwork.field.warning": "Advertencia: mientras esté habilitado, la aplicación es accesible por cualquiera en la misma red local.",
|
||||
"settings.openchamber.desktopNetwork.field.passwordRequiredWarning": "El acceso LAN requiere una contraseña de UI de escritorio. Hasta que se configure, la app de escritorio se inicia solo localmente.",
|
||||
"settings.openchamber.desktopPassword.field.password": "Contraseña de UI de escritorio",
|
||||
"settings.openchamber.desktopPassword.field.passwordPlaceholder": "No se requiere contraseña",
|
||||
"settings.openchamber.desktopPassword.field.passwordDescription": "OpenChamber la pide después del reinicio y luego cuando vence la sesión: tras 12 horas, o 7 días con Confiar en este dispositivo. Déjalo vacío para desactivar el inicio de sesión.",
|
||||
|
||||
@@ -765,6 +765,7 @@ export const settingsDict = {
|
||||
'settings.openchamber.desktopNetwork.field.allowLanAccess': 'Autorisez les autres appareils de votre réseau local à ouvrir cette application',
|
||||
'settings.openchamber.desktopNetwork.field.allowLanAccessDescription': 'Redémarre l\'application afin que les téléphones, tablettes et autres ordinateurs connectés à votre réseau Wi-Fi puissent l\'ouvrir.',
|
||||
'settings.openchamber.desktopNetwork.field.warning': 'Attention : lorsqu\'elle est activée, l\'application est accessible à toute personne se trouvant sur le même réseau local.',
|
||||
'settings.openchamber.desktopNetwork.field.passwordRequiredWarning': 'L\'accès LAN nécessite un mot de passe de l\'interface utilisateur du bureau. Tant qu\'il n\'est pas défini, l\'application de bureau démarre en accès local uniquement.',
|
||||
'settings.openchamber.desktopPassword.field.password': 'Mot de passe de l\'interface utilisateur du bureau',
|
||||
'settings.openchamber.desktopPassword.field.passwordPlaceholder': 'Aucun mot de passe requis',
|
||||
'settings.openchamber.desktopPassword.field.passwordDescription': 'OpenChamber demande après le redémarrage, puis quand la session de connexion expire : après 12 heures, ou 7 jours avec Trust this device. Laissez vide pour désactiver la connexion.',
|
||||
|
||||
@@ -776,6 +776,7 @@ export const settingsDict = {
|
||||
'settings.openchamber.desktopNetwork.field.allowLanAccess': '로컬 네트워크의 다른 기기에서 이 앱 열기 허용',
|
||||
'settings.openchamber.desktopNetwork.field.allowLanAccessDescription': '휴대폰, 태블릿, Wi-Fi의 다른 컴퓨터에서 열 수 있도록 앱을 다시 시작합니다.',
|
||||
'settings.openchamber.desktopNetwork.field.warning': '경고: 활성화된 동안 같은 로컬 네트워크의 누구나 앱에 접속할 수 있습니다.',
|
||||
'settings.openchamber.desktopNetwork.field.passwordRequiredWarning': 'LAN 접속에는 Desktop UI 비밀번호가 필요합니다. 설정하기 전까지 desktop 앱은 로컬 전용으로 시작됩니다.',
|
||||
'settings.openchamber.desktopPassword.field.password': 'Desktop UI 비밀번호',
|
||||
'settings.openchamber.desktopPassword.field.passwordPlaceholder': '비밀번호 필요 없음',
|
||||
'settings.openchamber.desktopPassword.field.passwordDescription': 'OpenChamber는 다시 시작 후 비밀번호를 요청하고, 이후 로그인 세션이 만료되면 다시 요청합니다. 기본 12시간, 이 디바이스 신뢰 선택 시 7일입니다. 로그인을 끄려면 비워 두세요.',
|
||||
|
||||
@@ -685,6 +685,7 @@ export const settingsDict = {
|
||||
'settings.openchamber.desktopNetwork.field.launchAtLoginAria': 'Uruchamiaj OpenChamber przy logowaniu',
|
||||
'settings.openchamber.desktopNetwork.field.launchAtLoginDescription': 'Uruchamia aplikację w tle bez otwierania okna. Kliknij ikonę w Docku, aby ją otworzyć.',
|
||||
'settings.openchamber.desktopNetwork.field.warning': 'Ostrzeżenie: po włączeniu aplikacja jest dostępna dla każdego w tej samej sieci lokalnej.',
|
||||
'settings.openchamber.desktopNetwork.field.passwordRequiredWarning': 'Dostęp LAN wymaga hasła UI pulpitu. Dopóki go nie ustawisz, aplikacja pulpitu uruchamia się tylko lokalnie.',
|
||||
'settings.openchamber.desktopPassword.field.password': 'Hasło UI pulpitu',
|
||||
'settings.openchamber.desktopPassword.field.passwordPlaceholder': 'Hasło nie jest wymagane',
|
||||
'settings.openchamber.desktopPassword.field.passwordDescription': 'OpenChamber pyta po restarcie, a potem po wygaśnięciu sesji logowania: po 12 godzinach albo po 7 dniach z opcją Zaufaj temu urządzeniu. Zostaw puste, aby wyłączyć logowanie.',
|
||||
|
||||
@@ -776,6 +776,7 @@ export const settingsDict = {
|
||||
"settings.openchamber.desktopNetwork.field.allowLanAccess": "Permitir que outros dispositivos na sua rede local abram este aplicativo",
|
||||
"settings.openchamber.desktopNetwork.field.allowLanAccessDescription": "Reinicia o aplicativo para que os telefones, tablets e outros computadores em seu Wi-Fi possam abri-lo.",
|
||||
"settings.openchamber.desktopNetwork.field.warning": "Aviso: enquanto estiver habilitado, o aplicativo ficará acessível a qualquer pessoa na mesma rede local.",
|
||||
"settings.openchamber.desktopNetwork.field.passwordRequiredWarning": "O acesso LAN exige uma senha da UI do desktop. Até configurar uma, o app de desktop inicia apenas localmente.",
|
||||
"settings.openchamber.desktopPassword.field.password": "Senha da UI do desktop",
|
||||
"settings.openchamber.desktopPassword.field.passwordPlaceholder": "Nenhuma senha obrigatória",
|
||||
"settings.openchamber.desktopPassword.field.passwordDescription": "O OpenChamber pede após reiniciar e depois quando a sessão expira: em 12 horas, ou 7 dias com Confiar neste dispositivo. Deixe vazio para desativar o login.",
|
||||
|
||||
@@ -776,6 +776,7 @@ export const settingsDict = {
|
||||
"settings.openchamber.desktopNetwork.field.allowLanAccess": "Дозволити іншим пристроям у локальній мережі відкривати цей застосунок",
|
||||
"settings.openchamber.desktopNetwork.field.allowLanAccessDescription": "Перезапускає застосунок, щоб телефони, планшети та інші комп’ютери в мережі Wi-Fi могли його відкрити.",
|
||||
"settings.openchamber.desktopNetwork.field.warning": "Попередження: якщо це ввімкнено, застосунок доступний усім у тій самій локальній мережі.",
|
||||
"settings.openchamber.desktopNetwork.field.passwordRequiredWarning": "Для LAN-доступу потрібен пароль десктопного UI. Доки його не задано, десктопний застосунок запускається лише локально.",
|
||||
"settings.openchamber.desktopPassword.field.password": "Пароль для десктопного UI",
|
||||
"settings.openchamber.desktopPassword.field.passwordPlaceholder": "Пароль не потрібен",
|
||||
"settings.openchamber.desktopPassword.field.passwordDescription": "OpenChamber попросить пароль після перезапуску, а потім коли сесія логіну спливе: через 12 годин або через 7 днів із «Довіряти цьому пристрою». Залиште порожнім, щоб вимкнути логін.",
|
||||
|
||||
@@ -776,6 +776,7 @@ export const settingsDict = {
|
||||
'settings.openchamber.desktopNetwork.field.allowLanAccess': '允许你本地网络中的其他设备打开此应用',
|
||||
'settings.openchamber.desktopNetwork.field.allowLanAccessDescription': '会重启应用,以便手机、平板和同一 Wi‑Fi 下的其他电脑访问。',
|
||||
'settings.openchamber.desktopNetwork.field.warning': '警告:启用后,同一本地网络中的任何人都可访问此应用。',
|
||||
'settings.openchamber.desktopNetwork.field.passwordRequiredWarning': '局域网访问需要桌面 UI 密码。在设置密码之前,桌面应用只会以本机访问模式启动。',
|
||||
'settings.openchamber.desktopPassword.field.password': '桌面 UI 密码',
|
||||
'settings.openchamber.desktopPassword.field.passwordPlaceholder': '不需要密码',
|
||||
'settings.openchamber.desktopPassword.field.passwordDescription': 'OpenChamber 会在重启后要求输入密码,之后在登录会话过期时再次要求:12 小时后,或选择“信任此设备”后 7 天。留空可关闭登录。',
|
||||
|
||||
@@ -770,6 +770,7 @@
|
||||
'settings.openchamber.desktopNetwork.field.allowLanAccess': '允許你本機網路中的其他裝置開啟此應用程式',
|
||||
'settings.openchamber.desktopNetwork.field.allowLanAccessDescription': '會重新啟動應用程式,以便手機、平板和同一 Wi‑Fi 下的其他電腦存取。',
|
||||
'settings.openchamber.desktopNetwork.field.warning': '警告:啟用後,同一區域網路中的任何人都可存取此應用程式。',
|
||||
'settings.openchamber.desktopNetwork.field.passwordRequiredWarning': '區域網路存取需要桌面 UI 密碼。設定前,桌面應用程式只會以本機模式啟動。',
|
||||
'settings.openchamber.desktopNetwork.hint.openAfterRestart': '重新啟動後可在其他裝置開啟:',
|
||||
'settings.openchamber.desktopNetwork.hint.openNow': '可在其他裝置開啟:',
|
||||
'settings.openchamber.desktopNetwork.actions.saveAndRestart': '儲存並重新啟動',
|
||||
|
||||
@@ -0,0 +1,73 @@
|
||||
import { requestExistingFileAccess } from '@/lib/desktop';
|
||||
import { isFilePathWithinDirectory, normalizeFilePath } from '@/lib/path-utils';
|
||||
|
||||
type OutsideFileGrantEntry = {
|
||||
outsideFileGrant: string;
|
||||
expiresAt: number;
|
||||
};
|
||||
|
||||
const DEFAULT_GRANT_TTL_MS = 10 * 60 * 1000;
|
||||
const grantsByPath = new Map<string, OutsideFileGrantEntry>();
|
||||
|
||||
export const getOutsideFileGrant = (path: string): string | undefined => {
|
||||
const normalizedPath = normalizeFilePath(path);
|
||||
if (!normalizedPath) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const entry = grantsByPath.get(normalizedPath);
|
||||
if (!entry) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
if (entry.expiresAt <= Date.now()) {
|
||||
grantsByPath.delete(normalizedPath);
|
||||
return undefined;
|
||||
}
|
||||
|
||||
return entry.outsideFileGrant;
|
||||
};
|
||||
|
||||
export const rememberOutsideFileGrant = (
|
||||
path: string,
|
||||
outsideFileGrant: string,
|
||||
expiresAt?: number,
|
||||
): void => {
|
||||
const normalizedPath = normalizeFilePath(path);
|
||||
if (!normalizedPath || !outsideFileGrant) {
|
||||
return;
|
||||
}
|
||||
|
||||
grantsByPath.set(normalizedPath, {
|
||||
outsideFileGrant,
|
||||
expiresAt: typeof expiresAt === 'number' && Number.isFinite(expiresAt)
|
||||
? expiresAt
|
||||
: Date.now() + DEFAULT_GRANT_TTL_MS,
|
||||
});
|
||||
};
|
||||
|
||||
export const ensureOutsideFileGrantForDesktop = async (
|
||||
path: string,
|
||||
workspaceRoot: string,
|
||||
): Promise<string | undefined> => {
|
||||
const normalizedPath = normalizeFilePath(path);
|
||||
if (!normalizedPath || !workspaceRoot || isFilePathWithinDirectory(normalizedPath, workspaceRoot)) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const existing = getOutsideFileGrant(normalizedPath);
|
||||
if (existing) {
|
||||
return existing;
|
||||
}
|
||||
|
||||
const result = await requestExistingFileAccess(normalizedPath);
|
||||
if (!result.success || !result.path || !result.outsideFileGrant) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
rememberOutsideFileGrant(result.path, result.outsideFileGrant);
|
||||
if (normalizeFilePath(result.path) !== normalizedPath) {
|
||||
rememberOutsideFileGrant(normalizedPath, result.outsideFileGrant);
|
||||
}
|
||||
return result.outsideFileGrant;
|
||||
};
|
||||
@@ -15,7 +15,7 @@ export interface RuntimeUrlResolver {
|
||||
authenticatedAsset(path: string, query?: RuntimeUrlQuery): string;
|
||||
auth(path: string, query?: RuntimeUrlQuery): string;
|
||||
health(query?: RuntimeUrlQuery): string;
|
||||
rawFile(path: string, options?: { download?: boolean }): string;
|
||||
rawFile(path: string, options?: { download?: boolean; allowOutsideWorkspace?: boolean; outsideFileGrant?: string }): string;
|
||||
sse(path: string, query?: RuntimeUrlQuery): string;
|
||||
websocket(path: string, query?: RuntimeUrlQuery): string;
|
||||
}
|
||||
@@ -118,7 +118,12 @@ export const createRuntimeUrlResolver = (config: RuntimeUrlConfig = {}): Runtime
|
||||
authenticatedAsset: (path, query) => withUrlAuth(http(path, query)),
|
||||
auth: http,
|
||||
health: (query) => http('/health', query),
|
||||
rawFile: (path, options) => http('/api/fs/raw', { path, download: options?.download === true ? true : undefined }),
|
||||
rawFile: (path, options) => http('/api/fs/raw', {
|
||||
path,
|
||||
download: options?.download === true ? true : undefined,
|
||||
allowOutsideWorkspace: options?.allowOutsideWorkspace === true ? true : undefined,
|
||||
outsideFileGrant: options?.outsideFileGrant,
|
||||
}),
|
||||
sse: (path, query) => withUrlAuth(realtime(path, query)),
|
||||
websocket: (path, query) => toWebSocketUrl(withUrlAuth(realtime(path, query)), config),
|
||||
};
|
||||
|
||||
@@ -1,21 +1,15 @@
|
||||
import { beforeEach, describe, expect, mock, test } from 'bun:test';
|
||||
|
||||
type ExecResult = { command: string; success: boolean; stdout?: string };
|
||||
|
||||
// Per-test controllable behaviour plus manual call tracking (the project's
|
||||
// tsconfig does not load bun-test's mock matcher types, so existing tests track
|
||||
// calls via plain arrays rather than `toHaveBeenCalled*`).
|
||||
let execImpl: (command: string, cwd: string) => ExecResult | Promise<ExecResult> = () => ({ command: '', success: false });
|
||||
let resolveRootImpl: (directory: string) => string | Promise<string> = (directory) => directory;
|
||||
let statusImpl: (directory: string) => { current: string } = () => ({ current: 'HEAD' });
|
||||
|
||||
const execCalls: Array<{ command: string; cwd: string }> = [];
|
||||
const resolveRootCalls: string[] = [];
|
||||
const statusCalls: string[] = [];
|
||||
|
||||
mock.module('@/lib/execCommands', () => ({
|
||||
execCommand: (command: string, cwd: string) => {
|
||||
execCalls.push({ command, cwd });
|
||||
return Promise.resolve(execImpl(command, cwd));
|
||||
},
|
||||
execCommands: () => Promise.resolve({ success: false, results: [] }),
|
||||
}));
|
||||
|
||||
@@ -24,28 +18,25 @@ mock.module('@/lib/gitApi', () => ({
|
||||
statusCalls.push(directory);
|
||||
return Promise.resolve(statusImpl(directory));
|
||||
},
|
||||
resolveGitPrimaryRoot: (directory: string) => {
|
||||
resolveRootCalls.push(directory);
|
||||
return Promise.resolve(resolveRootImpl(directory));
|
||||
},
|
||||
}));
|
||||
|
||||
const { getRootBranch, invalidateResolvedProjectRootCache } = await import('./worktreeStatus');
|
||||
|
||||
// Helper: a single `git rev-parse --absolute-git-dir --git-common-dir` reply.
|
||||
const revParse = (absoluteGitDir: string, commonDir: string): ExecResult => ({
|
||||
command: 'git rev-parse --absolute-git-dir --git-common-dir',
|
||||
success: true,
|
||||
stdout: `${absoluteGitDir}\n${commonDir}`,
|
||||
});
|
||||
|
||||
describe('worktreeStatus.getRootBranch', () => {
|
||||
beforeEach(() => {
|
||||
invalidateResolvedProjectRootCache();
|
||||
execCalls.length = 0;
|
||||
resolveRootCalls.length = 0;
|
||||
statusCalls.length = 0;
|
||||
execImpl = () => ({ command: '', success: false });
|
||||
resolveRootImpl = (directory) => directory;
|
||||
statusImpl = () => ({ current: 'HEAD' });
|
||||
});
|
||||
|
||||
test('derives root from absolute-git-dir and returns its branch', async () => {
|
||||
execImpl = () => revParse('/repo/.git', '.git');
|
||||
resolveRootImpl = () => '/repo';
|
||||
statusImpl = () => ({ current: 'main' });
|
||||
|
||||
expect(await getRootBranch('/repo')).toBe('main');
|
||||
@@ -53,39 +44,38 @@ describe('worktreeStatus.getRootBranch', () => {
|
||||
});
|
||||
|
||||
test('caches root resolution across repeated calls', async () => {
|
||||
execImpl = () => revParse('/repo/.git', '.git');
|
||||
resolveRootImpl = () => '/repo';
|
||||
statusImpl = () => ({ current: 'main' });
|
||||
|
||||
await getRootBranch('/repo');
|
||||
await getRootBranch('/repo');
|
||||
await getRootBranch('/repo');
|
||||
|
||||
// rev-parse runs once; the static root resolution is cached.
|
||||
expect(execCalls.length).toBe(1);
|
||||
expect(resolveRootCalls.length).toBe(1);
|
||||
});
|
||||
|
||||
test('dedupes concurrent resolutions of the same directory', async () => {
|
||||
execImpl = () => revParse('/repo/.git', '.git');
|
||||
resolveRootImpl = () => '/repo';
|
||||
statusImpl = () => ({ current: 'main' });
|
||||
|
||||
await Promise.all([getRootBranch('/repo'), getRootBranch('/repo'), getRootBranch('/repo')]);
|
||||
|
||||
expect(execCalls.length).toBe(1);
|
||||
expect(resolveRootCalls.length).toBe(1);
|
||||
});
|
||||
|
||||
test('invalidation forces re-resolution', async () => {
|
||||
execImpl = () => revParse('/repo/.git', '.git');
|
||||
resolveRootImpl = () => '/repo';
|
||||
statusImpl = () => ({ current: 'main' });
|
||||
|
||||
await getRootBranch('/repo');
|
||||
invalidateResolvedProjectRootCache('/repo');
|
||||
await getRootBranch('/repo');
|
||||
|
||||
expect(execCalls.length).toBe(2);
|
||||
expect(resolveRootCalls.length).toBe(2);
|
||||
});
|
||||
|
||||
test('falls back to the directory itself in a non-git folder', async () => {
|
||||
execImpl = () => ({ command: '', success: false });
|
||||
resolveRootImpl = (directory) => directory;
|
||||
statusImpl = () => ({ current: 'HEAD' });
|
||||
|
||||
expect(await getRootBranch('/plain')).toBe('HEAD');
|
||||
@@ -93,8 +83,7 @@ describe('worktreeStatus.getRootBranch', () => {
|
||||
});
|
||||
|
||||
test('resolves a linked worktree to its primary root and fetches that branch', async () => {
|
||||
// Worktree's own git dir lives under the primary repo's .git/worktrees.
|
||||
execImpl = () => revParse('/repo/.git/worktrees/wt', '/repo/.git');
|
||||
resolveRootImpl = () => '/repo';
|
||||
statusImpl = () => ({ current: 'main' });
|
||||
|
||||
// knownBranch is the *worktree* branch, which must NOT be returned for the root.
|
||||
@@ -103,10 +92,10 @@ describe('worktreeStatus.getRootBranch', () => {
|
||||
});
|
||||
|
||||
test('invalidation mid-flight does not let a stale resolve re-seed the cache', async () => {
|
||||
let releaseExec: (result: ExecResult) => void = () => {};
|
||||
execImpl = () =>
|
||||
new Promise<ExecResult>((resolve) => {
|
||||
releaseExec = resolve;
|
||||
let releaseResolve: (result: string) => void = () => {};
|
||||
resolveRootImpl = () =>
|
||||
new Promise<string>((resolve) => {
|
||||
releaseResolve = resolve;
|
||||
});
|
||||
statusImpl = () => ({ current: 'main' });
|
||||
|
||||
@@ -115,24 +104,24 @@ describe('worktreeStatus.getRootBranch', () => {
|
||||
// A worktree topology change invalidates the cache while the resolve runs.
|
||||
invalidateResolvedProjectRootCache();
|
||||
// Now let the original resolve settle — it must NOT populate the cache.
|
||||
releaseExec(revParse('/repo/.git', '.git'));
|
||||
releaseResolve('/repo');
|
||||
await pending;
|
||||
|
||||
execImpl = () => revParse('/repo/.git', '.git');
|
||||
resolveRootImpl = () => '/repo';
|
||||
await getRootBranch('/repo');
|
||||
|
||||
// Second call recomputes because the stale in-flight result was discarded.
|
||||
expect(execCalls.length).toBe(2);
|
||||
expect(resolveRootCalls.length).toBe(2);
|
||||
});
|
||||
|
||||
test('bounds the root cache by evicting the least-recently-used entry past the count cap', async () => {
|
||||
execImpl = (_command, cwd) => revParse(`${cwd}/.git`, '.git');
|
||||
resolveRootImpl = (directory) => directory;
|
||||
statusImpl = () => ({ current: 'main' });
|
||||
|
||||
for (let i = 0; i < 500; i += 1) {
|
||||
await getRootBranch(`/repo-${i}`);
|
||||
}
|
||||
const afterFill = execCalls.length;
|
||||
const afterFill = resolveRootCalls.length;
|
||||
expect(afterFill).toBe(500);
|
||||
|
||||
await getRootBranch('/repo-overflow');
|
||||
@@ -140,11 +129,11 @@ describe('worktreeStatus.getRootBranch', () => {
|
||||
await getRootBranch('/repo-499');
|
||||
|
||||
// /repo-overflow and evicted /repo-0 re-run; /repo-499 remains cached.
|
||||
expect(execCalls.length).toBe(afterFill + 2);
|
||||
expect(resolveRootCalls.length).toBe(afterFill + 2);
|
||||
});
|
||||
|
||||
test('uses knownBranch fast-path when the directory is its own root', async () => {
|
||||
execImpl = () => revParse('/repo/.git', '.git');
|
||||
resolveRootImpl = () => '/repo';
|
||||
|
||||
expect(await getRootBranch('/repo', { knownBranch: 'develop' })).toBe('develop');
|
||||
// No git status round-trip needed in the fast path.
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import { getGitStatus } from '@/lib/gitApi';
|
||||
import { execCommand } from '@/lib/execCommands';
|
||||
import { getGitStatus, resolveGitPrimaryRoot } from '@/lib/gitApi';
|
||||
import type { WorktreeMetadata } from '@/types/worktree';
|
||||
|
||||
const normalizePath = (value: string): string => {
|
||||
@@ -13,39 +12,6 @@ const normalizePath = (value: string): string => {
|
||||
return replaced.replace(/\/+$/, '');
|
||||
};
|
||||
|
||||
const toAbsolutePath = (baseDir: string, maybeRelativePath: string): string => {
|
||||
const normalizedBase = normalizePath(baseDir);
|
||||
const normalizedInput = normalizePath(maybeRelativePath);
|
||||
if (!normalizedInput) return normalizedBase;
|
||||
if (normalizedInput.startsWith('/')) return normalizedInput;
|
||||
|
||||
const stack = normalizedBase.split('/').filter(Boolean);
|
||||
const parts = normalizedInput.split('/').filter(Boolean);
|
||||
for (const part of parts) {
|
||||
if (part === '.') continue;
|
||||
if (part === '..') {
|
||||
stack.pop();
|
||||
continue;
|
||||
}
|
||||
stack.push(part);
|
||||
}
|
||||
return `/${stack.join('/')}`;
|
||||
};
|
||||
|
||||
const derivePrimaryWorktreeRootFromGitDir = (gitDir: string): string | null => {
|
||||
const normalized = normalizePath(gitDir);
|
||||
if (!normalized) return null;
|
||||
if (normalized.endsWith('/.git')) {
|
||||
return normalized.slice(0, -'/.git'.length) || null;
|
||||
}
|
||||
const worktreesMarker = '/.git/worktrees/';
|
||||
const markerIndex = normalized.indexOf(worktreesMarker);
|
||||
if (markerIndex > 0) {
|
||||
return normalized.slice(0, markerIndex) || null;
|
||||
}
|
||||
return null;
|
||||
};
|
||||
|
||||
export async function getWorktreeStatus(worktreePath: string): Promise<WorktreeMetadata['status']> {
|
||||
const normalizedPath = normalizePath(worktreePath);
|
||||
const status = await getGitStatus(normalizedPath);
|
||||
@@ -118,38 +84,7 @@ export function invalidateResolvedProjectRootCache(directory?: string): void {
|
||||
}
|
||||
|
||||
const computeProjectRoot = async (directory: string): Promise<string> => {
|
||||
// A single `git rev-parse` invocation returns both paths (absolute-git-dir on
|
||||
// the first line, git-common-dir on the second), halving subprocess spawns
|
||||
// versus issuing the two queries separately. In a non-git directory the whole
|
||||
// command fails, mirroring the previous fall-through to `directory`.
|
||||
const result = await execCommand('git rev-parse --absolute-git-dir --git-common-dir', directory);
|
||||
if (!result.success) {
|
||||
return directory;
|
||||
}
|
||||
|
||||
const lines = (result.stdout || '')
|
||||
.split('\n')
|
||||
.map((line) => line.trim())
|
||||
.filter(Boolean);
|
||||
|
||||
const absoluteGitDir = normalizePath(lines[0] || '');
|
||||
if (absoluteGitDir) {
|
||||
const rootFromAbsoluteGitDir = derivePrimaryWorktreeRootFromGitDir(absoluteGitDir);
|
||||
if (rootFromAbsoluteGitDir) {
|
||||
return rootFromAbsoluteGitDir;
|
||||
}
|
||||
}
|
||||
|
||||
const rawCommonDir = normalizePath(lines[1] || '');
|
||||
if (rawCommonDir) {
|
||||
const commonDir = toAbsolutePath(directory, rawCommonDir);
|
||||
const rootFromCommonDir = derivePrimaryWorktreeRootFromGitDir(commonDir);
|
||||
if (rootFromCommonDir) {
|
||||
return rootFromCommonDir;
|
||||
}
|
||||
}
|
||||
|
||||
return directory;
|
||||
return resolveGitPrimaryRoot(directory).catch(() => directory);
|
||||
};
|
||||
|
||||
export const resolveProjectRoot = async (directory: string): Promise<string> => {
|
||||
|
||||
@@ -15,10 +15,10 @@ type FilesViewTabsState = {
|
||||
};
|
||||
|
||||
type FilesViewTabsActions = {
|
||||
addOpenPath: (root: string, path: string) => void;
|
||||
addOpenPath: (root: string, path: string, options?: { allowOutsideRoot?: boolean }) => void;
|
||||
removeOpenPath: (root: string, path: string) => void;
|
||||
removeOpenPathsByPrefix: (root: string, prefixPath: string) => void;
|
||||
setSelectedPath: (root: string, path: string | null) => void;
|
||||
setSelectedPath: (root: string, path: string | null, options?: { allowOutsideRoot?: boolean }) => void;
|
||||
ensureSelectedPath: (root: string) => void;
|
||||
toggleExpandedPath: (root: string, path: string) => void;
|
||||
expandPath: (root: string, path: string) => void;
|
||||
@@ -163,10 +163,10 @@ export const useFilesViewTabsStore = create<FilesViewTabsStore>()(
|
||||
(set, get) => ({
|
||||
byRoot: {},
|
||||
|
||||
addOpenPath: (root, path) => {
|
||||
addOpenPath: (root, path, options) => {
|
||||
const normalizedRoot = normalizePath((root || '').trim());
|
||||
const normalizedPath = normalizePath((path || '').trim());
|
||||
if (!normalizedRoot || !normalizedPath || !isPathWithinRoot(normalizedPath, normalizedRoot)) {
|
||||
if (!normalizedRoot || !normalizedPath || (!options?.allowOutsideRoot && !isPathWithinRoot(normalizedPath, normalizedRoot))) {
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -270,10 +270,10 @@ export const useFilesViewTabsStore = create<FilesViewTabsStore>()(
|
||||
});
|
||||
},
|
||||
|
||||
setSelectedPath: (root, path) => {
|
||||
setSelectedPath: (root, path, options) => {
|
||||
const normalizedRoot = normalizePath((root || '').trim());
|
||||
const normalizedPath = path ? normalizePath(path.trim()) : null;
|
||||
if (!normalizedRoot || (normalizedPath && !isPathWithinRoot(normalizedPath, normalizedRoot))) {
|
||||
if (!normalizedRoot || (normalizedPath && !options?.allowOutsideRoot && !isPathWithinRoot(normalizedPath, normalizedRoot))) {
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -424,7 +424,7 @@ export const useFilesViewTabsStore = create<FilesViewTabsStore>()(
|
||||
byRoot: sanitizeByRoot(rawByRoot),
|
||||
};
|
||||
},
|
||||
partialize: (state) => ({ byRoot: state.byRoot }),
|
||||
partialize: (state) => ({ byRoot: sanitizeByRoot(state.byRoot) }),
|
||||
}
|
||||
),
|
||||
{ name: 'files-view-tabs-store' }
|
||||
|
||||
Reference in New Issue
Block a user