refactor: snapshot strip out base path (#542)
* refactor: strip out volume path in snapshot list / restore chore: lint issue * test: backups new include patterns
This commit is contained in:
parent
ca8248b2a0
commit
8fcd446926
18 changed files with 462 additions and 81 deletions
|
|
@ -21,25 +21,24 @@ import { RestoreProgress } from "~/client/components/restore-progress";
|
||||||
import { restoreSnapshotMutation } from "~/client/api-client/@tanstack/react-query.gen";
|
import { restoreSnapshotMutation } from "~/client/api-client/@tanstack/react-query.gen";
|
||||||
import { type RestoreCompletedEvent, useServerEvents } from "~/client/hooks/use-server-events";
|
import { type RestoreCompletedEvent, useServerEvents } from "~/client/hooks/use-server-events";
|
||||||
import { OVERWRITE_MODES, type OverwriteMode } from "~/schemas/restic";
|
import { OVERWRITE_MODES, type OverwriteMode } from "~/schemas/restic";
|
||||||
import type { Repository, Snapshot } from "~/client/lib/types";
|
import type { Repository } from "~/client/lib/types";
|
||||||
import { handleRepositoryError } from "~/client/lib/errors";
|
import { handleRepositoryError } from "~/client/lib/errors";
|
||||||
import { useNavigate } from "@tanstack/react-router";
|
import { useNavigate } from "@tanstack/react-router";
|
||||||
import { findCommonAncestor } from "~/utils/common-ancestor";
|
|
||||||
|
|
||||||
type RestoreLocation = "original" | "custom";
|
type RestoreLocation = "original" | "custom";
|
||||||
|
|
||||||
interface RestoreFormProps {
|
interface RestoreFormProps {
|
||||||
snapshot: Snapshot;
|
|
||||||
repository: Repository;
|
repository: Repository;
|
||||||
snapshotId: string;
|
snapshotId: string;
|
||||||
returnPath: string;
|
returnPath: string;
|
||||||
|
basePath?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function RestoreForm({ snapshot, repository, snapshotId, returnPath }: RestoreFormProps) {
|
export function RestoreForm({ repository, snapshotId, returnPath, basePath }: RestoreFormProps) {
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const { addEventListener } = useServerEvents();
|
const { addEventListener } = useServerEvents();
|
||||||
|
|
||||||
const volumeBasePath = findCommonAncestor(snapshot.paths);
|
const volumeBasePath = basePath ?? "/";
|
||||||
|
|
||||||
const [restoreLocation, setRestoreLocation] = useState<RestoreLocation>("original");
|
const [restoreLocation, setRestoreLocation] = useState<RestoreLocation>("original");
|
||||||
const [customTargetPath, setCustomTargetPath] = useState("");
|
const [customTargetPath, setCustomTargetPath] = useState("");
|
||||||
|
|
|
||||||
28
app/client/lib/volume-path.test.ts
Normal file
28
app/client/lib/volume-path.test.ts
Normal file
|
|
@ -0,0 +1,28 @@
|
||||||
|
import { describe, expect, test } from "bun:test";
|
||||||
|
import { getVolumeMountPath } from "./volume-path";
|
||||||
|
import { fromAny } from "@total-typescript/shoehorn";
|
||||||
|
|
||||||
|
describe("getVolumeMountPath", () => {
|
||||||
|
test("returns the configured path for directory volumes", () => {
|
||||||
|
const volume = {
|
||||||
|
shortId: "abc123",
|
||||||
|
config: {
|
||||||
|
backend: "directory",
|
||||||
|
path: "/mnt/data/projects",
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
expect(getVolumeMountPath(fromAny(volume))).toBe("/mnt/data/projects");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("returns the mounted data path for non-directory volumes", () => {
|
||||||
|
const volume = {
|
||||||
|
shortId: "vol789",
|
||||||
|
config: {
|
||||||
|
backend: "nfs",
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
expect(getVolumeMountPath(fromAny(volume))).toBe("/var/lib/zerobyte/volumes/vol789/_data");
|
||||||
|
});
|
||||||
|
});
|
||||||
11
app/client/lib/volume-path.ts
Normal file
11
app/client/lib/volume-path.ts
Normal file
|
|
@ -0,0 +1,11 @@
|
||||||
|
import type { Volume } from "./types";
|
||||||
|
|
||||||
|
const VOLUME_MOUNT_BASE = "/var/lib/zerobyte/volumes";
|
||||||
|
|
||||||
|
export const getVolumeMountPath = (volume: Volume): string => {
|
||||||
|
if (volume.config.backend === "directory") {
|
||||||
|
return volume.config.path;
|
||||||
|
}
|
||||||
|
|
||||||
|
return `${VOLUME_MOUNT_BASE}/${volume.shortId}/_data`;
|
||||||
|
};
|
||||||
|
|
@ -1,4 +1,5 @@
|
||||||
import { RotateCcw, Trash2 } from "lucide-react";
|
import { RotateCcw, Trash2 } from "lucide-react";
|
||||||
|
import { useQuery } from "@tanstack/react-query";
|
||||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "~/client/components/ui/card";
|
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "~/client/components/ui/card";
|
||||||
import { Button, buttonVariants } from "~/client/components/ui/button";
|
import { Button, buttonVariants } from "~/client/components/ui/button";
|
||||||
import type { Snapshot } from "~/client/lib/types";
|
import type { Snapshot } from "~/client/lib/types";
|
||||||
|
|
@ -6,20 +7,63 @@ import { formatDateTime } from "~/client/lib/datetime";
|
||||||
import { cn } from "~/client/lib/utils";
|
import { cn } from "~/client/lib/utils";
|
||||||
import { Link } from "@tanstack/react-router";
|
import { Link } from "@tanstack/react-router";
|
||||||
import { SnapshotTreeBrowser } from "~/client/components/file-browsers/snapshot-tree-browser";
|
import { SnapshotTreeBrowser } from "~/client/components/file-browsers/snapshot-tree-browser";
|
||||||
import { findCommonAncestor } from "~/utils/common-ancestor";
|
import { getBackupScheduleOptions } from "~/client/api-client/@tanstack/react-query.gen";
|
||||||
|
import { getVolumeMountPath } from "~/client/lib/volume-path";
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
snapshot: Snapshot;
|
snapshot: Snapshot;
|
||||||
repositoryId: string;
|
repositoryId: string;
|
||||||
backupId?: string;
|
backupId?: string;
|
||||||
|
basePath?: string;
|
||||||
onDeleteSnapshot?: (snapshotId: string) => void;
|
onDeleteSnapshot?: (snapshotId: string) => void;
|
||||||
isDeletingSnapshot?: boolean;
|
isDeletingSnapshot?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const SnapshotFileBrowser = (props: Props) => {
|
const treeProps = {
|
||||||
const { snapshot, repositoryId, backupId, onDeleteSnapshot, isDeletingSnapshot } = props;
|
pageSize: 500,
|
||||||
|
className: "flex flex-1 min-h-0 flex-col",
|
||||||
|
treeContainerClassName: "overflow-auto flex-1 min-h-0 border border-border rounded-md bg-card m-4",
|
||||||
|
treeClassName: "px-2 py-2",
|
||||||
|
emptyMessage: "No files in this snapshot",
|
||||||
|
stateClassName: "flex-1 min-h-0",
|
||||||
|
} as const;
|
||||||
|
|
||||||
const volumeBasePath = findCommonAncestor(snapshot.paths);
|
interface ScheduleAwareTreeBrowserProps {
|
||||||
|
scheduleShortId: string;
|
||||||
|
repositoryId: string;
|
||||||
|
snapshotId: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const ScheduleAwareTreeBrowser = ({ scheduleShortId, repositoryId, snapshotId }: ScheduleAwareTreeBrowserProps) => {
|
||||||
|
const { data: schedule, isPending } = useQuery({
|
||||||
|
...getBackupScheduleOptions({ path: { scheduleId: scheduleShortId } }),
|
||||||
|
retry: false,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (isPending) {
|
||||||
|
return <TreeBrowserFallback />;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<SnapshotTreeBrowser
|
||||||
|
repositoryId={repositoryId}
|
||||||
|
snapshotId={snapshotId}
|
||||||
|
basePath={schedule ? getVolumeMountPath(schedule.volume) : "/"}
|
||||||
|
{...treeProps}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const TreeBrowserFallback = () => (
|
||||||
|
<div className={cn(treeProps.treeContainerClassName, "flex items-center justify-center")}>
|
||||||
|
<p className="text-muted-foreground">Loading volume info...</p>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
|
||||||
|
export const SnapshotFileBrowser = (props: Props) => {
|
||||||
|
const { snapshot, repositoryId, backupId, basePath, onDeleteSnapshot, isDeletingSnapshot } = props;
|
||||||
|
|
||||||
|
const scheduleShortId = !basePath ? backupId || snapshot.tags?.[0] : undefined;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
|
|
@ -65,18 +109,27 @@ export const SnapshotFileBrowser = (props: Props) => {
|
||||||
</div>
|
</div>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent className="flex-1 overflow-hidden flex flex-col p-0">
|
<CardContent className="flex-1 overflow-hidden flex flex-col p-0">
|
||||||
<SnapshotTreeBrowser
|
{basePath ? (
|
||||||
repositoryId={repositoryId}
|
<SnapshotTreeBrowser
|
||||||
snapshotId={snapshot.short_id}
|
repositoryId={repositoryId}
|
||||||
basePath={volumeBasePath}
|
snapshotId={snapshot.short_id}
|
||||||
pageSize={500}
|
basePath={basePath}
|
||||||
className="flex flex-1 min-h-0 flex-col"
|
{...treeProps}
|
||||||
treeContainerClassName="overflow-auto flex-1 min-h-0 border border-border rounded-md bg-card m-4"
|
/>
|
||||||
treeClassName="px-2 py-2"
|
) : scheduleShortId ? (
|
||||||
loadingMessage="Loading files..."
|
<ScheduleAwareTreeBrowser
|
||||||
emptyMessage="No files in this snapshot"
|
scheduleShortId={scheduleShortId}
|
||||||
stateClassName="flex-1 min-h-0"
|
repositoryId={repositoryId}
|
||||||
/>
|
snapshotId={snapshot.short_id}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<SnapshotTreeBrowser
|
||||||
|
repositoryId={repositoryId}
|
||||||
|
snapshotId={snapshot.short_id}
|
||||||
|
basePath="/"
|
||||||
|
{...treeProps}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -1,15 +1,15 @@
|
||||||
import { RestoreForm } from "~/client/components/restore-form";
|
import { RestoreForm } from "~/client/components/restore-form";
|
||||||
import type { Repository, Snapshot } from "~/client/lib/types";
|
import type { Repository } from "~/client/lib/types";
|
||||||
|
|
||||||
type Props = {
|
type Props = {
|
||||||
snapshot: Snapshot;
|
|
||||||
repository: Repository;
|
repository: Repository;
|
||||||
snapshotId: string;
|
snapshotId: string;
|
||||||
returnPath: string;
|
returnPath: string;
|
||||||
|
basePath?: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
export function RestoreSnapshotPage(props: Props) {
|
export function RestoreSnapshotPage(props: Props) {
|
||||||
const { snapshot, returnPath, snapshotId, repository } = props;
|
const { returnPath, snapshotId, repository, basePath } = props;
|
||||||
|
|
||||||
return <RestoreForm snapshot={snapshot} repository={repository} snapshotId={snapshotId} returnPath={returnPath} />;
|
return <RestoreForm repository={repository} snapshotId={snapshotId} returnPath={returnPath} basePath={basePath} />;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -12,6 +12,7 @@ import { BackupSummaryCard } from "~/client/components/backup-summary-card";
|
||||||
import { useState } from "react";
|
import { useState } from "react";
|
||||||
import { Database } from "lucide-react";
|
import { Database } from "lucide-react";
|
||||||
import { Link, useParams } from "@tanstack/react-router";
|
import { Link, useParams } from "@tanstack/react-router";
|
||||||
|
import { getVolumeMountPath } from "~/client/lib/volume-path";
|
||||||
|
|
||||||
export const SnapshotError = () => {
|
export const SnapshotError = () => {
|
||||||
const { repositoryId } = useParams({ from: "/(dashboard)/repositories/$repositoryId/$snapshotId/" });
|
const { repositoryId } = useParams({ from: "/(dashboard)/repositories/$repositoryId/$snapshotId/" });
|
||||||
|
|
@ -33,23 +34,20 @@ export const SnapshotError = () => {
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
const FilebrowserFallback = ({ repositoryId, snapshotId }: { repositoryId: string; snapshotId: string }) => {
|
const SnapshotFileBrowserSkeleton = () => (
|
||||||
return (
|
<div className="space-y-4">
|
||||||
<SnapshotFileBrowser
|
<Card className="h-150 flex flex-col">
|
||||||
repositoryId={repositoryId}
|
<CardHeader>
|
||||||
snapshot={{
|
<CardTitle>File Browser</CardTitle>
|
||||||
duration: 0,
|
</CardHeader>
|
||||||
paths: [],
|
<CardContent className="flex-1 overflow-hidden flex flex-col p-0">
|
||||||
short_id: snapshotId,
|
<div className="overflow-auto flex-1 min-h-0 border border-border rounded-md bg-card m-4 flex flex-col items-center justify-center p-6 text-center">
|
||||||
size: 0,
|
<p className="text-muted-foreground">Loading snapshot...</p>
|
||||||
tags: [],
|
</div>
|
||||||
time: 0,
|
</CardContent>
|
||||||
hostname: "",
|
</Card>
|
||||||
retentionCategories: [],
|
</div>
|
||||||
}}
|
);
|
||||||
/>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
export function SnapshotDetailsPage({ repositoryId, snapshotId }: { repositoryId: string; snapshotId: string }) {
|
export function SnapshotDetailsPage({ repositoryId, snapshotId }: { repositoryId: string; snapshotId: string }) {
|
||||||
const [showAllPaths, setShowAllPaths] = useState(false);
|
const [showAllPaths, setShowAllPaths] = useState(false);
|
||||||
|
|
@ -65,7 +63,7 @@ export function SnapshotDetailsPage({ repositoryId, snapshotId }: { repositoryId
|
||||||
const { data, error } = useQuery({
|
const { data, error } = useQuery({
|
||||||
...getSnapshotDetailsOptions({ path: { id: repositoryId, snapshotId: snapshotId } }),
|
...getSnapshotDetailsOptions({ path: { id: repositoryId, snapshotId: snapshotId } }),
|
||||||
});
|
});
|
||||||
const backupSchedule = schedules?.find((s) => data?.tags.includes(s.shortId));
|
const backupSchedule = schedules?.find((s) => data?.tags?.includes(s.shortId));
|
||||||
|
|
||||||
if (error) {
|
if (error) {
|
||||||
return (
|
return (
|
||||||
|
|
@ -91,9 +89,13 @@ export function SnapshotDetailsPage({ repositoryId, snapshotId }: { repositoryId
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{data ? (
|
{data ? (
|
||||||
<SnapshotFileBrowser repositoryId={repositoryId} snapshot={data} />
|
<SnapshotFileBrowser
|
||||||
|
repositoryId={repositoryId}
|
||||||
|
snapshot={data}
|
||||||
|
basePath={backupSchedule ? getVolumeMountPath(backupSchedule.volume) : undefined}
|
||||||
|
/>
|
||||||
) : (
|
) : (
|
||||||
<FilebrowserFallback repositoryId={repositoryId} snapshotId={snapshotId} />
|
<SnapshotFileBrowserSkeleton />
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{data && (
|
{data && (
|
||||||
|
|
|
||||||
|
|
@ -2,6 +2,7 @@ import { createFileRoute } from "@tanstack/react-router";
|
||||||
import { getBackupSchedule } from "~/client/api-client";
|
import { getBackupSchedule } from "~/client/api-client";
|
||||||
import { getRepositoryOptions, getSnapshotDetailsOptions } from "~/client/api-client/@tanstack/react-query.gen";
|
import { getRepositoryOptions, getSnapshotDetailsOptions } from "~/client/api-client/@tanstack/react-query.gen";
|
||||||
import { RestoreSnapshotPage } from "~/client/modules/repositories/routes/restore-snapshot";
|
import { RestoreSnapshotPage } from "~/client/modules/repositories/routes/restore-snapshot";
|
||||||
|
import { getVolumeMountPath } from "~/client/lib/volume-path";
|
||||||
|
|
||||||
export const Route = createFileRoute("/(dashboard)/backups/$backupId/$snapshotId/restore")({
|
export const Route = createFileRoute("/(dashboard)/backups/$backupId/$snapshotId/restore")({
|
||||||
component: RouteComponent,
|
component: RouteComponent,
|
||||||
|
|
@ -17,9 +18,14 @@ export const Route = createFileRoute("/(dashboard)/backups/$backupId/$snapshotId
|
||||||
...getSnapshotDetailsOptions({ path: { id: schedule.data?.repositoryId, snapshotId: params.snapshotId } }),
|
...getSnapshotDetailsOptions({ path: { id: schedule.data?.repositoryId, snapshotId: params.snapshotId } }),
|
||||||
}),
|
}),
|
||||||
context.queryClient.ensureQueryData({ ...getRepositoryOptions({ path: { id: schedule.data?.repositoryId } }) }),
|
context.queryClient.ensureQueryData({ ...getRepositoryOptions({ path: { id: schedule.data?.repositoryId } }) }),
|
||||||
])
|
]);
|
||||||
|
|
||||||
return { snapshot, repository, schedule: schedule.data };
|
return {
|
||||||
|
snapshot,
|
||||||
|
repository,
|
||||||
|
schedule: schedule.data,
|
||||||
|
basePath: getVolumeMountPath(schedule.data.volume),
|
||||||
|
};
|
||||||
},
|
},
|
||||||
head: ({ params }) => ({
|
head: ({ params }) => ({
|
||||||
meta: [
|
meta: [
|
||||||
|
|
@ -42,14 +48,14 @@ export const Route = createFileRoute("/(dashboard)/backups/$backupId/$snapshotId
|
||||||
|
|
||||||
function RouteComponent() {
|
function RouteComponent() {
|
||||||
const { backupId, snapshotId } = Route.useParams();
|
const { backupId, snapshotId } = Route.useParams();
|
||||||
const { snapshot, repository } = Route.useLoaderData();
|
const { repository, basePath } = Route.useLoaderData();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<RestoreSnapshotPage
|
<RestoreSnapshotPage
|
||||||
returnPath={`/backups/${backupId}`}
|
returnPath={`/backups/${backupId}`}
|
||||||
snapshotId={snapshotId}
|
snapshotId={snapshotId}
|
||||||
snapshot={snapshot}
|
|
||||||
repository={repository}
|
repository={repository}
|
||||||
|
basePath={basePath}
|
||||||
/>
|
/>
|
||||||
)
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,9 +1,5 @@
|
||||||
import { createFileRoute } from "@tanstack/react-router";
|
import { createFileRoute } from "@tanstack/react-router";
|
||||||
import {
|
import { getRepositoryOptions } from "~/client/api-client/@tanstack/react-query.gen";
|
||||||
getRepositoryOptions,
|
|
||||||
getSnapshotDetailsOptions,
|
|
||||||
listSnapshotFilesOptions,
|
|
||||||
} from "~/client/api-client/@tanstack/react-query.gen";
|
|
||||||
import { SnapshotDetailsPage } from "~/client/modules/repositories/routes/snapshot-details";
|
import { SnapshotDetailsPage } from "~/client/modules/repositories/routes/snapshot-details";
|
||||||
|
|
||||||
export const Route = createFileRoute("/(dashboard)/repositories/$repositoryId/$snapshotId/")({
|
export const Route = createFileRoute("/(dashboard)/repositories/$repositoryId/$snapshotId/")({
|
||||||
|
|
@ -12,19 +8,7 @@ export const Route = createFileRoute("/(dashboard)/repositories/$repositoryId/$s
|
||||||
loader: async ({ params, context }) => {
|
loader: async ({ params, context }) => {
|
||||||
const res = await context.queryClient.ensureQueryData({
|
const res = await context.queryClient.ensureQueryData({
|
||||||
...getRepositoryOptions({ path: { id: params.repositoryId } }),
|
...getRepositoryOptions({ path: { id: params.repositoryId } }),
|
||||||
})
|
});
|
||||||
|
|
||||||
void context.queryClient.prefetchQuery({
|
|
||||||
...getSnapshotDetailsOptions({
|
|
||||||
path: { id: params.repositoryId, snapshotId: params.snapshotId },
|
|
||||||
}),
|
|
||||||
})
|
|
||||||
void context.queryClient.prefetchQuery({
|
|
||||||
...listSnapshotFilesOptions({
|
|
||||||
path: { id: params.repositoryId, snapshotId: params.snapshotId },
|
|
||||||
query: { path: "/" },
|
|
||||||
}),
|
|
||||||
})
|
|
||||||
|
|
||||||
return res;
|
return res;
|
||||||
},
|
},
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,8 @@
|
||||||
import { createFileRoute } from "@tanstack/react-router";
|
import { createFileRoute } from "@tanstack/react-router";
|
||||||
|
import { getBackupSchedule } from "~/client/api-client";
|
||||||
import { getRepositoryOptions, getSnapshotDetailsOptions } from "~/client/api-client/@tanstack/react-query.gen";
|
import { getRepositoryOptions, getSnapshotDetailsOptions } from "~/client/api-client/@tanstack/react-query.gen";
|
||||||
import { RestoreSnapshotPage } from "~/client/modules/repositories/routes/restore-snapshot";
|
import { RestoreSnapshotPage } from "~/client/modules/repositories/routes/restore-snapshot";
|
||||||
|
import { getVolumeMountPath } from "~/client/lib/volume-path";
|
||||||
|
|
||||||
export const Route = createFileRoute("/(dashboard)/repositories/$repositoryId/$snapshotId/restore")({
|
export const Route = createFileRoute("/(dashboard)/repositories/$repositoryId/$snapshotId/restore")({
|
||||||
component: RouteComponent,
|
component: RouteComponent,
|
||||||
|
|
@ -11,9 +13,18 @@ export const Route = createFileRoute("/(dashboard)/repositories/$repositoryId/$s
|
||||||
...getSnapshotDetailsOptions({ path: { id: params.repositoryId, snapshotId: params.snapshotId } }),
|
...getSnapshotDetailsOptions({ path: { id: params.repositoryId, snapshotId: params.snapshotId } }),
|
||||||
}),
|
}),
|
||||||
context.queryClient.ensureQueryData({ ...getRepositoryOptions({ path: { id: params.repositoryId } }) }),
|
context.queryClient.ensureQueryData({ ...getRepositoryOptions({ path: { id: params.repositoryId } }) }),
|
||||||
])
|
]);
|
||||||
|
|
||||||
return { snapshot, repository };
|
let basePath: string | undefined;
|
||||||
|
const scheduleShortId = snapshot.tags?.[0];
|
||||||
|
if (scheduleShortId) {
|
||||||
|
const scheduleRes = await getBackupSchedule({ path: { scheduleId: scheduleShortId } });
|
||||||
|
if (scheduleRes.data) {
|
||||||
|
basePath = getVolumeMountPath(scheduleRes.data.volume);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return { snapshot, repository, basePath };
|
||||||
},
|
},
|
||||||
staticData: {
|
staticData: {
|
||||||
breadcrumb: (match) => [
|
breadcrumb: (match) => [
|
||||||
|
|
@ -36,14 +47,14 @@ export const Route = createFileRoute("/(dashboard)/repositories/$repositoryId/$s
|
||||||
|
|
||||||
function RouteComponent() {
|
function RouteComponent() {
|
||||||
const { repositoryId, snapshotId } = Route.useParams();
|
const { repositoryId, snapshotId } = Route.useParams();
|
||||||
const { snapshot, repository } = Route.useLoaderData();
|
const { repository, basePath } = Route.useLoaderData();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<RestoreSnapshotPage
|
<RestoreSnapshotPage
|
||||||
returnPath={`/repositories/${repositoryId}/${snapshotId}`}
|
returnPath={`/repositories/${repositoryId}/${snapshotId}`}
|
||||||
snapshot={snapshot}
|
|
||||||
repository={repository}
|
repository={repository}
|
||||||
snapshotId={snapshotId}
|
snapshotId={snapshotId}
|
||||||
|
basePath={basePath}
|
||||||
/>
|
/>
|
||||||
)
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,9 @@
|
||||||
import { test, describe, expect } from "bun:test";
|
import { test, describe, expect } from "bun:test";
|
||||||
import { createApp } from "~/server/app";
|
import { createApp } from "~/server/app";
|
||||||
import { createTestSession, getAuthHeaders } from "~/test/helpers/auth";
|
import { createTestSession, getAuthHeaders } from "~/test/helpers/auth";
|
||||||
|
import { createTestVolume } from "~/test/helpers/volume";
|
||||||
|
import { createTestRepository } from "~/test/helpers/repository";
|
||||||
|
import { createTestBackupSchedule } from "~/test/helpers/backup";
|
||||||
|
|
||||||
const app = createApp();
|
const app = createApp();
|
||||||
|
|
||||||
|
|
@ -77,6 +80,26 @@ describe("backups security", () => {
|
||||||
});
|
});
|
||||||
|
|
||||||
describe("input validation", () => {
|
describe("input validation", () => {
|
||||||
|
test("should return a schedule when queried by short id", async () => {
|
||||||
|
const { token, organizationId } = await createTestSession();
|
||||||
|
const volume = await createTestVolume({ organizationId });
|
||||||
|
const repository = await createTestRepository({ organizationId });
|
||||||
|
const schedule = await createTestBackupSchedule({
|
||||||
|
organizationId,
|
||||||
|
volumeId: volume.id,
|
||||||
|
repositoryId: repository.id,
|
||||||
|
});
|
||||||
|
|
||||||
|
const res = await app.request(`/api/v1/backups/${schedule.shortId}`, {
|
||||||
|
headers: getAuthHeaders(token),
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(res.status).toBe(200);
|
||||||
|
const body = await res.json();
|
||||||
|
expect(body.id).toBe(schedule.id);
|
||||||
|
expect(body.shortId).toBe(schedule.shortId);
|
||||||
|
});
|
||||||
|
|
||||||
test("should return 404 for malformed schedule ID", async () => {
|
test("should return 404 for malformed schedule ID", async () => {
|
||||||
const { token } = await createTestSession();
|
const { token } = await createTestSession();
|
||||||
const res = await app.request("/api/v1/backups/not-a-number", {
|
const res = await app.request("/api/v1/backups/not-a-number", {
|
||||||
|
|
|
||||||
|
|
@ -187,6 +187,48 @@ describe("getSchedulesToExecute", () => {
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe("getScheduleByIdOrShortId", () => {
|
||||||
|
test("should resolve a schedule by numeric id string", async () => {
|
||||||
|
const volume = await createTestVolume();
|
||||||
|
const repository = await createTestRepository();
|
||||||
|
const schedule = await createTestBackupSchedule({
|
||||||
|
volumeId: volume.id,
|
||||||
|
repositoryId: repository.id,
|
||||||
|
});
|
||||||
|
|
||||||
|
const found = await backupsService.getScheduleByIdOrShortId(String(schedule.id));
|
||||||
|
|
||||||
|
expect(found.id).toBe(schedule.id);
|
||||||
|
expect(found.shortId).toBe(schedule.shortId);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("should resolve a schedule by short id", async () => {
|
||||||
|
const volume = await createTestVolume();
|
||||||
|
const repository = await createTestRepository();
|
||||||
|
const schedule = await createTestBackupSchedule({
|
||||||
|
volumeId: volume.id,
|
||||||
|
repositoryId: repository.id,
|
||||||
|
});
|
||||||
|
|
||||||
|
const found = await backupsService.getScheduleByIdOrShortId(schedule.shortId);
|
||||||
|
|
||||||
|
expect(found.id).toBe(schedule.id);
|
||||||
|
expect(found.shortId).toBe(schedule.shortId);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("should not return schedules from another organization", async () => {
|
||||||
|
const otherOrgId = faker.string.uuid();
|
||||||
|
const schedule = await createTestBackupSchedule({
|
||||||
|
organizationId: otherOrgId,
|
||||||
|
});
|
||||||
|
|
||||||
|
await expect(backupsService.getScheduleByIdOrShortId(schedule.shortId)).rejects.toThrow(
|
||||||
|
"Backup schedule not found",
|
||||||
|
);
|
||||||
|
await expect(backupsService.getScheduleByIdOrShortId(schedule.id)).rejects.toThrow("Backup schedule not found");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
describe("listSchedules", () => {
|
describe("listSchedules", () => {
|
||||||
test("should ignore schedules with missing relations", async () => {
|
test("should ignore schedules with missing relations", async () => {
|
||||||
const healthyVolume = await createTestVolume();
|
const healthyVolume = await createTestVolume();
|
||||||
|
|
|
||||||
|
|
@ -54,7 +54,7 @@ export const backupScheduleController = new Hono()
|
||||||
})
|
})
|
||||||
.get("/:scheduleId", getBackupScheduleDto, async (c) => {
|
.get("/:scheduleId", getBackupScheduleDto, async (c) => {
|
||||||
const scheduleId = c.req.param("scheduleId");
|
const scheduleId = c.req.param("scheduleId");
|
||||||
const schedule = await backupsService.getScheduleById(Number(scheduleId));
|
const schedule = await backupsService.getScheduleByIdOrShortId(scheduleId);
|
||||||
|
|
||||||
return c.json<GetBackupScheduleDto>(schedule, 200);
|
return c.json<GetBackupScheduleDto>(schedule, 200);
|
||||||
})
|
})
|
||||||
|
|
|
||||||
|
|
@ -56,6 +56,26 @@ const getScheduleByShortId = async (shortId: string) => {
|
||||||
return schedule;
|
return schedule;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const getScheduleByIdOrShortId = async (idOrShortId: string | number) => {
|
||||||
|
const organizationId = getOrganizationId();
|
||||||
|
const schedule = await db.query.backupSchedulesTable.findFirst({
|
||||||
|
where: {
|
||||||
|
AND: [{ OR: [{ id: Number(idOrShortId) }, { shortId: String(idOrShortId) }] }, { organizationId }],
|
||||||
|
},
|
||||||
|
with: { volume: true, repository: true },
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!schedule) {
|
||||||
|
throw new NotFoundError("Backup schedule not found");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!schedule.volume || !schedule.repository) {
|
||||||
|
throw new NotFoundError("Backup schedule not found");
|
||||||
|
}
|
||||||
|
|
||||||
|
return schedule;
|
||||||
|
};
|
||||||
|
|
||||||
const createSchedule = async (data: CreateBackupScheduleBody) => {
|
const createSchedule = async (data: CreateBackupScheduleBody) => {
|
||||||
const organizationId = getOrganizationId();
|
const organizationId = getOrganizationId();
|
||||||
if (!cron.validate(data.cronExpression)) {
|
if (!cron.validate(data.cronExpression)) {
|
||||||
|
|
@ -375,6 +395,7 @@ const cleanupOrphanedSchedules = async () => {
|
||||||
export const backupsService = {
|
export const backupsService = {
|
||||||
listSchedules,
|
listSchedules,
|
||||||
getScheduleById,
|
getScheduleById,
|
||||||
|
getScheduleByIdOrShortId,
|
||||||
createSchedule,
|
createSchedule,
|
||||||
updateSchedule,
|
updateSchedule,
|
||||||
deleteSchedule,
|
deleteSchedule,
|
||||||
|
|
|
||||||
|
|
@ -25,6 +25,7 @@ import { backupsService } from "../backups/backups.service";
|
||||||
import type { UpdateRepositoryBody } from "./repositories.dto";
|
import type { UpdateRepositoryBody } from "./repositories.dto";
|
||||||
import { executeDoctor } from "./doctor";
|
import { executeDoctor } from "./doctor";
|
||||||
import { REPOSITORY_BASE } from "~/server/core/constants";
|
import { REPOSITORY_BASE } from "~/server/core/constants";
|
||||||
|
import { findCommonAncestor } from "~/utils/common-ancestor";
|
||||||
|
|
||||||
const runningDoctors = new Map<string, AbortController>();
|
const runningDoctors = new Map<string, AbortController>();
|
||||||
|
|
||||||
|
|
@ -336,6 +337,9 @@ const restoreSnapshot = async (
|
||||||
|
|
||||||
const target = options?.targetPath || "/";
|
const target = options?.targetPath || "/";
|
||||||
|
|
||||||
|
const { paths } = await getSnapshotDetails(repository.id, snapshotId);
|
||||||
|
const basePath = findCommonAncestor(paths);
|
||||||
|
|
||||||
const releaseLock = await repoMutex.acquireShared(repository.id, `restore:${snapshotId}`);
|
const releaseLock = await repoMutex.acquireShared(repository.id, `restore:${snapshotId}`);
|
||||||
try {
|
try {
|
||||||
serverEvents.emit("restore:started", {
|
serverEvents.emit("restore:started", {
|
||||||
|
|
@ -345,6 +349,7 @@ const restoreSnapshot = async (
|
||||||
});
|
});
|
||||||
|
|
||||||
const result = await restic.restore(repository.config, snapshotId, target, {
|
const result = await restic.restore(repository.config, snapshotId, target, {
|
||||||
|
basePath,
|
||||||
...options,
|
...options,
|
||||||
organizationId,
|
organizationId,
|
||||||
onProgress: (progress) => {
|
onProgress: (progress) => {
|
||||||
|
|
|
||||||
34
app/server/utils/common-ancestor.test.ts
Normal file
34
app/server/utils/common-ancestor.test.ts
Normal file
|
|
@ -0,0 +1,34 @@
|
||||||
|
import { describe, expect, test } from "bun:test";
|
||||||
|
import { findCommonAncestor } from "~/utils/common-ancestor";
|
||||||
|
|
||||||
|
describe("findCommonAncestor", () => {
|
||||||
|
test("returns root for empty path lists", () => {
|
||||||
|
expect(findCommonAncestor([])).toBe("/");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("returns the original path for single-item lists", () => {
|
||||||
|
expect(findCommonAncestor(["/var/lib/zerobyte/volumes/vol123/_data"])).toBe(
|
||||||
|
"/var/lib/zerobyte/volumes/vol123/_data",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("returns the deepest shared ancestor for multiple absolute paths", () => {
|
||||||
|
expect(
|
||||||
|
findCommonAncestor([
|
||||||
|
"/var/lib/zerobyte/volumes/vol123/_data/Documents/report.pdf",
|
||||||
|
"/var/lib/zerobyte/volumes/vol123/_data/Photos/summer.jpg",
|
||||||
|
"/var/lib/zerobyte/volumes/vol123/_data/Music/track.mp3",
|
||||||
|
]),
|
||||||
|
).toBe("/var/lib/zerobyte/volumes/vol123/_data");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("returns root when absolute paths only share the filesystem root", () => {
|
||||||
|
expect(findCommonAncestor(["/etc/hosts", "/usr/local/bin"])).toBe("/");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("throws when any path is relative", () => {
|
||||||
|
expect(() => findCommonAncestor(["/var/lib/zerobyte", "relative/path"])).toThrow(
|
||||||
|
'Path "relative/path" is not absolute.',
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
@ -1,5 +1,71 @@
|
||||||
import { describe, expect, test } from "bun:test";
|
import { afterEach, beforeEach, describe, expect, mock, spyOn, test } from "bun:test";
|
||||||
import { buildRepoUrl } from "./restic";
|
import * as spawnModule from "./spawn";
|
||||||
|
import { buildRepoUrl, restic } from "./restic";
|
||||||
|
|
||||||
|
const successfulRestoreSummary = JSON.stringify({
|
||||||
|
message_type: "summary",
|
||||||
|
files_restored: 1,
|
||||||
|
files_skipped: 0,
|
||||||
|
bytes_skipped: 0,
|
||||||
|
});
|
||||||
|
|
||||||
|
let lastSafeSpawnArgs: string[] = [];
|
||||||
|
|
||||||
|
const safeSpawnMock = mock((params: Parameters<typeof spawnModule.safeSpawn>[0]) => {
|
||||||
|
lastSafeSpawnArgs = params.args;
|
||||||
|
|
||||||
|
return Promise.resolve({
|
||||||
|
exitCode: 0,
|
||||||
|
summary: successfulRestoreSummary,
|
||||||
|
error: "",
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
const getRestoreArg = (args: string[]): string => {
|
||||||
|
const restoreIndex = args.indexOf("restore");
|
||||||
|
if (restoreIndex < 0) {
|
||||||
|
throw new Error("Expected restore command in restic arguments");
|
||||||
|
}
|
||||||
|
|
||||||
|
const restoreArg = args[restoreIndex + 1];
|
||||||
|
if (!restoreArg) {
|
||||||
|
throw new Error("Expected restore argument after restore command");
|
||||||
|
}
|
||||||
|
|
||||||
|
return restoreArg;
|
||||||
|
};
|
||||||
|
|
||||||
|
const getOptionValues = (args: string[], option: string): string[] => {
|
||||||
|
const values: string[] = [];
|
||||||
|
for (let i = 0; i < args.length - 1; i++) {
|
||||||
|
if (args[i] === option) {
|
||||||
|
const value = args[i + 1];
|
||||||
|
if (value) {
|
||||||
|
values.push(value);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return values;
|
||||||
|
};
|
||||||
|
|
||||||
|
const getLastSafeSpawnArgs = (): string[] => {
|
||||||
|
if (lastSafeSpawnArgs.length === 0) {
|
||||||
|
throw new Error("Expected safeSpawn to be called");
|
||||||
|
}
|
||||||
|
|
||||||
|
return lastSafeSpawnArgs;
|
||||||
|
};
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
safeSpawnMock.mockClear();
|
||||||
|
lastSafeSpawnArgs = [];
|
||||||
|
spyOn(spawnModule, "safeSpawn").mockImplementation(safeSpawnMock);
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
mock.restore();
|
||||||
|
});
|
||||||
|
|
||||||
describe("buildRepoUrl", () => {
|
describe("buildRepoUrl", () => {
|
||||||
describe("S3 backend", () => {
|
describe("S3 backend", () => {
|
||||||
|
|
@ -104,3 +170,67 @@ describe("buildRepoUrl", () => {
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe("restore", () => {
|
||||||
|
const config = {
|
||||||
|
backend: "local" as const,
|
||||||
|
path: "/tmp/restic-repo",
|
||||||
|
isExistingRepository: true,
|
||||||
|
customPassword: "custom-password",
|
||||||
|
};
|
||||||
|
|
||||||
|
test("keeps snapshot restore arg and absolute include paths when target is root", async () => {
|
||||||
|
await restic.restore(config, "snapshot-123", "/", {
|
||||||
|
organizationId: "org-1",
|
||||||
|
include: [
|
||||||
|
"/var/lib/zerobyte/volumes/vol123/_data/Documents/report.pdf",
|
||||||
|
"/var/lib/zerobyte/volumes/vol123/_data/Photos/summer.jpg",
|
||||||
|
],
|
||||||
|
});
|
||||||
|
|
||||||
|
const args = getLastSafeSpawnArgs();
|
||||||
|
expect(getRestoreArg(args)).toBe("snapshot-123");
|
||||||
|
expect(getOptionValues(args, "--include")).toEqual([
|
||||||
|
"/var/lib/zerobyte/volumes/vol123/_data/Documents/report.pdf",
|
||||||
|
"/var/lib/zerobyte/volumes/vol123/_data/Photos/summer.jpg",
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("restores from common ancestor and strips include paths for non-root targets", async () => {
|
||||||
|
await restic.restore(config, "snapshot-456", "/tmp/restore-target", {
|
||||||
|
organizationId: "org-1",
|
||||||
|
include: [
|
||||||
|
"/var/lib/zerobyte/volumes/vol123/_data/Documents/report.pdf",
|
||||||
|
"/var/lib/zerobyte/volumes/vol123/_data/Photos/summer.jpg",
|
||||||
|
],
|
||||||
|
});
|
||||||
|
|
||||||
|
const args = getLastSafeSpawnArgs();
|
||||||
|
expect(getRestoreArg(args)).toBe("snapshot-456:/var/lib/zerobyte/volumes/vol123/_data");
|
||||||
|
expect(getOptionValues(args, "--include")).toEqual(["Documents/report.pdf", "Photos/summer.jpg"]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("uses base path for non-root restore when includes are omitted", async () => {
|
||||||
|
await restic.restore(config, "snapshot-789", "/tmp/restore-target", {
|
||||||
|
organizationId: "org-1",
|
||||||
|
basePath: "/var/lib/zerobyte/volumes/vol123/_data",
|
||||||
|
});
|
||||||
|
|
||||||
|
const args = getLastSafeSpawnArgs();
|
||||||
|
expect(getRestoreArg(args)).toBe("snapshot-789:/var/lib/zerobyte/volumes/vol123/_data");
|
||||||
|
expect(getOptionValues(args, "--include")).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("does not pass an empty include when include equals restore root", async () => {
|
||||||
|
await restic.restore(config, "snapshot-7202d8cc", "/Users/nicolas/Documents/restore", {
|
||||||
|
organizationId: "org-1",
|
||||||
|
include: ["/Users/nicolas/Developer/zerobyte/tmp/deep/test/files"],
|
||||||
|
overwrite: "always",
|
||||||
|
});
|
||||||
|
|
||||||
|
const args = getLastSafeSpawnArgs();
|
||||||
|
expect(getRestoreArg(args)).toBe("snapshot-7202d8cc:/Users/nicolas/Developer/zerobyte/tmp/deep/test/files");
|
||||||
|
expect(getOptionValues(args, "--include")).toEqual([]);
|
||||||
|
expect(args).not.toContain("");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
|
||||||
|
|
@ -23,6 +23,7 @@ import { ResticError } from "./errors";
|
||||||
import { safeJsonParse } from "./json";
|
import { safeJsonParse } from "./json";
|
||||||
import { logger } from "./logger";
|
import { logger } from "./logger";
|
||||||
import { exec, safeSpawn } from "./spawn";
|
import { exec, safeSpawn } from "./spawn";
|
||||||
|
import { findCommonAncestor } from "~/utils/common-ancestor";
|
||||||
|
|
||||||
const snapshotInfoSchema = type({
|
const snapshotInfoSchema = type({
|
||||||
gid: "number?",
|
gid: "number?",
|
||||||
|
|
@ -48,7 +49,10 @@ export const buildRepoUrl = (config: RepositoryConfig): string => {
|
||||||
return `s3:${endpoint}/${config.bucket}`;
|
return `s3:${endpoint}/${config.bucket}`;
|
||||||
}
|
}
|
||||||
case "r2": {
|
case "r2": {
|
||||||
const endpoint = config.endpoint.trim().replace(/^https?:\/\//, "").replace(/\/$/, "");
|
const endpoint = config.endpoint
|
||||||
|
.trim()
|
||||||
|
.replace(/^https?:\/\//, "")
|
||||||
|
.replace(/\/$/, "");
|
||||||
return `s3:${endpoint}/${config.bucket}`;
|
return `s3:${endpoint}/${config.bucket}`;
|
||||||
}
|
}
|
||||||
case "gcs":
|
case "gcs":
|
||||||
|
|
@ -399,6 +403,7 @@ const restore = async (
|
||||||
snapshotId: string,
|
snapshotId: string,
|
||||||
target: string,
|
target: string,
|
||||||
options: {
|
options: {
|
||||||
|
basePath?: string;
|
||||||
organizationId: string;
|
organizationId: string;
|
||||||
include?: string[];
|
include?: string[];
|
||||||
exclude?: string[];
|
exclude?: string[];
|
||||||
|
|
@ -412,15 +417,36 @@ const restore = async (
|
||||||
const repoUrl = buildRepoUrl(config);
|
const repoUrl = buildRepoUrl(config);
|
||||||
const env = await buildEnv(config, options.organizationId);
|
const env = await buildEnv(config, options.organizationId);
|
||||||
|
|
||||||
const args: string[] = ["--repo", repoUrl, "restore", snapshotId, "--target", target];
|
let restoreArg = snapshotId;
|
||||||
|
|
||||||
|
const includes = options.include?.length ? options.include : [options.basePath ?? "/"];
|
||||||
|
const commonAncestor = findCommonAncestor(includes);
|
||||||
|
if (target !== "/") {
|
||||||
|
restoreArg = `${snapshotId}:${commonAncestor}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
const args = ["--repo", repoUrl, "restore", restoreArg, "--target", target];
|
||||||
|
|
||||||
if (options?.overwrite) {
|
if (options?.overwrite) {
|
||||||
args.push("--overwrite", options.overwrite);
|
args.push("--overwrite", options.overwrite);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (options?.include?.length) {
|
if (options?.include?.length) {
|
||||||
for (const pattern of options.include) {
|
if (target === "/") {
|
||||||
args.push("--include", pattern);
|
for (const pattern of options.include) {
|
||||||
|
args.push("--include", pattern);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
const strippedIncludes = options.include.map((pattern) => path.relative(commonAncestor, pattern));
|
||||||
|
const includesCoverRestoreRoot = strippedIncludes.some((pattern) => pattern === "" || pattern === ".");
|
||||||
|
|
||||||
|
if (!includesCoverRestoreRoot) {
|
||||||
|
for (const pattern of strippedIncludes) {
|
||||||
|
if (pattern !== "" && pattern !== ".") {
|
||||||
|
args.push("--include", pattern);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,12 @@
|
||||||
export const findCommonAncestor = (paths: string[]): string => {
|
export const findCommonAncestor = (paths: string[]): string => {
|
||||||
|
for (const p of paths) {
|
||||||
|
if (!p.startsWith("/")) {
|
||||||
|
throw new Error(`Path "${p}" is not absolute.`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
if (paths.length === 0) return "/";
|
if (paths.length === 0) return "/";
|
||||||
if (paths.length === 1) return paths[0];
|
if (paths.length === 1) return paths[0] || "/";
|
||||||
|
|
||||||
const splitPaths = paths.map((path) => path.split("/").filter(Boolean));
|
const splitPaths = paths.map((path) => path.split("/").filter(Boolean));
|
||||||
const minLength = Math.min(...splitPaths.map((parts) => parts.length));
|
const minLength = Math.min(...splitPaths.map((parts) => parts.length));
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue