fix: single item file restore

This commit is contained in:
Nicolas Meienberger 2026-03-08 13:19:07 +01:00
parent 3d2900ab69
commit 875c88c3f4
5 changed files with 25 additions and 2 deletions

View file

@ -2240,6 +2240,7 @@ export type RestoreSnapshotData = {
excludeXattr?: Array<string>; excludeXattr?: Array<string>;
include?: Array<string>; include?: Array<string>;
overwrite?: 'always' | 'if-changed' | 'if-newer' | 'never'; overwrite?: 'always' | 'if-changed' | 'if-newer' | 'never';
selectedItemKind?: 'dir' | 'file';
targetPath?: string; targetPath?: string;
}; };
path: { path: {

View file

@ -133,6 +133,7 @@ export function RestoreForm({ repository, snapshotId, returnPath, basePath }: Re
body: { body: {
snapshotId, snapshotId,
include: includePaths.length > 0 ? includePaths : undefined, include: includePaths.length > 0 ? includePaths : undefined,
selectedItemKind: includePaths.length === 1 ? (selectedPathKind ?? undefined) : undefined,
excludeXattr: excludeXattrArray && excludeXattrArray.length > 0 ? excludeXattrArray : undefined, excludeXattr: excludeXattrArray && excludeXattrArray.length > 0 ? excludeXattrArray : undefined,
targetPath, targetPath,
overwrite: overwriteMode, overwrite: overwriteMode,
@ -145,6 +146,7 @@ export function RestoreForm({ repository, snapshotId, returnPath, basePath }: Re
restoreLocation, restoreLocation,
customTargetPath, customTargetPath,
selectedPaths, selectedPaths,
selectedPathKind,
overwriteMode, overwriteMode,
restoreSnapshot, restoreSnapshot,
]); ]);

View file

@ -375,6 +375,7 @@ export const overwriteModeSchema = type.valueOf(OVERWRITE_MODES);
export const restoreSnapshotBody = type({ export const restoreSnapshotBody = type({
snapshotId: "string", snapshotId: "string",
include: "string[]?", include: "string[]?",
selectedItemKind: dumpPathKindSchema.optional(),
exclude: "string[]?", exclude: "string[]?",
excludeXattr: "string[]?", excludeXattr: "string[]?",
delete: "boolean?", delete: "boolean?",

View file

@ -100,6 +100,20 @@ describe("restore command", () => {
expect(getOptionValues("--include")).toEqual([]); expect(getOptionValues("--include")).toEqual([]);
}); });
test("restores a single selected file from its parent directory for non-root targets", async () => {
const { getRestoreArg, getOptionValues } = setup();
const options: Parameters<typeof restore>[3] & { selectedItemKind: "file" } = {
organizationId: "org-1",
include: ["/var/lib/zerobyte/volumes/vol123/_data/archive/backup.20260301-233001.7z"],
selectedItemKind: "file",
};
await restore(config, "snapshot-single-file", "/tmp/restore-target", options);
expect(getRestoreArg()).toBe("snapshot-single-file:/var/lib/zerobyte/volumes/vol123/_data/archive");
expect(getOptionValues("--include")).toEqual(["backup.20260301-233001.7z"]);
});
test("does not pass an empty include when include equals restore root", async () => { test("does not pass an empty include when include equals restore root", async () => {
const { getArgs, getRestoreArg, getOptionValues } = setup(); const { getArgs, getRestoreArg, getOptionValues } = setup();
await restore(config, "snapshot-7202d8cc", "/Users/nicolas/Documents/restore", { await restore(config, "snapshot-7202d8cc", "/Users/nicolas/Documents/restore", {

View file

@ -33,6 +33,7 @@ export const restore = async (
basePath?: string; basePath?: string;
organizationId: string; organizationId: string;
include?: string[]; include?: string[];
selectedItemKind?: "file" | "dir";
exclude?: string[]; exclude?: string[];
excludeXattr?: string[]; excludeXattr?: string[];
delete?: boolean; delete?: boolean;
@ -47,7 +48,11 @@ export const restore = async (
let restoreArg = snapshotId; let restoreArg = snapshotId;
const includes = options.include?.length ? options.include : [options.basePath ?? "/"]; const includes = options.include?.length ? options.include : [options.basePath ?? "/"];
const commonAncestor = findCommonAncestor(includes); const commonAncestor =
options.selectedItemKind === "file" && includes.length === 1
? path.posix.dirname(includes[0] ?? "/")
: findCommonAncestor(includes);
if (target !== "/") { if (target !== "/") {
restoreArg = `${snapshotId}:${commonAncestor}`; restoreArg = `${snapshotId}:${commonAncestor}`;
} }
@ -64,7 +69,7 @@ export const restore = async (
args.push("--include", pattern); args.push("--include", pattern);
} }
} else { } else {
const strippedIncludes = options.include.map((pattern) => path.relative(commonAncestor, pattern)); const strippedIncludes = options.include.map((pattern) => path.posix.relative(commonAncestor, pattern));
const includesCoverRestoreRoot = strippedIncludes.some((pattern) => pattern === "" || pattern === "."); const includesCoverRestoreRoot = strippedIncludes.some((pattern) => pattern === "" || pattern === ".");
if (!includesCoverRestoreRoot) { if (!includesCoverRestoreRoot) {