fix(local-repo): automatically generate a subfolder for new local repos (#582)
Closes #562 <!-- This is an auto-generated comment: release notes by coderabbit.ai --> ## Summary by CodeRabbit ## Release Notes * **New Features** * Improved repository setup with context-aware descriptions for new vs. existing repositories * Enhanced path handling: unique identifiers automatically appended for new repositories, exact paths preserved when importing existing ones * **Chores** * Updated documentation and build configuration <!-- end of auto-generated comment: release notes by coderabbit.ai -->
This commit is contained in:
parent
b1a0cdb195
commit
30f237dc1f
5 changed files with 45 additions and 5 deletions
1
.gitignore
vendored
1
.gitignore
vendored
|
|
@ -38,3 +38,4 @@ playwright/temp
|
||||||
openapi-ts-error-*.log
|
openapi-ts-error-*.log
|
||||||
.output
|
.output
|
||||||
tmp/
|
tmp/
|
||||||
|
qa-output
|
||||||
|
|
|
||||||
|
|
@ -5,6 +5,7 @@
|
||||||
- Never create migration files manually. Always use the provided command to generate migrations
|
- Never create migration files manually. Always use the provided command to generate migrations
|
||||||
- If you realize an automated migration is incorrect, make sure to remove all the associated entries from the `_journal.json` and the newly created files located in `app/drizzle/` before re-generating the migration
|
- If you realize an automated migration is incorrect, make sure to remove all the associated entries from the `_journal.json` and the newly created files located in `app/drizzle/` before re-generating the migration
|
||||||
- The dev server is running at http://localhost:3000. Username is `admin` and password is `password`
|
- The dev server is running at http://localhost:3000. Username is `admin` and password is `password`
|
||||||
|
- The repo is https://github.com/nicotsx/zerobyte
|
||||||
|
|
||||||
## Project Overview
|
## Project Overview
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,6 @@
|
||||||
import { useState } from "react";
|
import { useState } from "react";
|
||||||
import type { UseFormReturn } from "react-hook-form";
|
import type { UseFormReturn } from "react-hook-form";
|
||||||
|
import { useWatch } from "react-hook-form";
|
||||||
import { Check, Pencil, X, AlertTriangle } from "lucide-react";
|
import { Check, Pencil, X, AlertTriangle } from "lucide-react";
|
||||||
import { Button } from "../../../../components/ui/button";
|
import { Button } from "../../../../components/ui/button";
|
||||||
import { FormItem, FormLabel, FormDescription, FormField, FormControl } from "../../../../components/ui/form";
|
import { FormItem, FormLabel, FormDescription, FormField, FormControl } from "../../../../components/ui/form";
|
||||||
|
|
@ -33,6 +34,8 @@ export const LocalRepositoryForm = ({ form }: Props) => {
|
||||||
queryFn: getConstants,
|
queryFn: getConstants,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const isExistingRepository = useWatch({ control: form.control, name: "isExistingRepository" });
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<FormField
|
<FormField
|
||||||
control={form.control}
|
control={form.control}
|
||||||
|
|
@ -45,6 +48,7 @@ export const LocalRepositoryForm = ({ form }: Props) => {
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<div className="flex-1 text-sm font-mono bg-muted px-3 py-2 rounded-md border">
|
<div className="flex-1 text-sm font-mono bg-muted px-3 py-2 rounded-md border">
|
||||||
{field.value || constants.REPOSITORY_BASE}
|
{field.value || constants.REPOSITORY_BASE}
|
||||||
|
{!isExistingRepository && <span className="text-muted-foreground">/{"{unique-id}"}</span>}
|
||||||
</div>
|
</div>
|
||||||
<Button type="button" variant="outline" onClick={() => setShowPathWarning(true)} size="sm">
|
<Button type="button" variant="outline" onClick={() => setShowPathWarning(true)} size="sm">
|
||||||
<Pencil className="h-4 w-4 mr-2" />
|
<Pencil className="h-4 w-4 mr-2" />
|
||||||
|
|
@ -52,7 +56,11 @@ export const LocalRepositoryForm = ({ form }: Props) => {
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</FormControl>
|
</FormControl>
|
||||||
<FormDescription>The directory where the repository will be stored.</FormDescription>
|
<FormDescription>
|
||||||
|
{isExistingRepository
|
||||||
|
? "The exact path to your existing repository."
|
||||||
|
: "A unique subdirectory will be created inside this directory to store the repository."}
|
||||||
|
</FormDescription>
|
||||||
</FormItem>
|
</FormItem>
|
||||||
|
|
||||||
<AlertDialog open={showPathWarning} onOpenChange={setShowPathWarning}>
|
<AlertDialog open={showPathWarning} onOpenChange={setShowPathWarning}>
|
||||||
|
|
|
||||||
|
|
@ -75,7 +75,7 @@ describe("repositoriesService.createRepository", () => {
|
||||||
expect(created.status).toBe("healthy");
|
expect(created.status).toBe("healthy");
|
||||||
});
|
});
|
||||||
|
|
||||||
test("keeps an explicit local repository path unchanged", async () => {
|
test("creates a shortId-scoped repository path when using a custom directory", async () => {
|
||||||
// arrange
|
// arrange
|
||||||
const { organizationId, user } = await createTestSession();
|
const { organizationId, user } = await createTestSession();
|
||||||
const explicitPath = `${REPOSITORY_BASE}/custom-${randomUUID()}`;
|
const explicitPath = `${REPOSITORY_BASE}/custom-${randomUUID()}`;
|
||||||
|
|
@ -98,6 +98,37 @@ describe("repositoriesService.createRepository", () => {
|
||||||
throw new Error("Repository should be created");
|
throw new Error("Repository should be created");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const savedConfig = created.config as Extract<RepositoryConfig, { backend: "local" }>;
|
||||||
|
expect(savedConfig.path).toBe(`${explicitPath}/${created.shortId}`);
|
||||||
|
expect(savedConfig.path).not.toBe(explicitPath);
|
||||||
|
expect(created.status).toBe("healthy");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("keeps an explicit local repository path unchanged when importing existing repository", async () => {
|
||||||
|
// arrange
|
||||||
|
const { organizationId, user } = await createTestSession();
|
||||||
|
const explicitPath = `${REPOSITORY_BASE}/custom-${randomUUID()}`;
|
||||||
|
const config: RepositoryConfig = { backend: "local", path: explicitPath, isExistingRepository: true };
|
||||||
|
|
||||||
|
spyOn(restic, "snapshots").mockImplementation(() => Promise.resolve([]));
|
||||||
|
|
||||||
|
// act
|
||||||
|
const result = await withContext({ organizationId, userId: user.id }, () =>
|
||||||
|
repositoriesService.createRepository("existing repo", config),
|
||||||
|
);
|
||||||
|
|
||||||
|
const created = await db.query.repositoriesTable.findFirst({
|
||||||
|
where: {
|
||||||
|
id: result.repository.id,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
// assert
|
||||||
|
expect(created).toBeTruthy();
|
||||||
|
if (!created) {
|
||||||
|
throw new Error("Repository should be created");
|
||||||
|
}
|
||||||
|
|
||||||
const savedConfig = created.config as Extract<RepositoryConfig, { backend: "local" }>;
|
const savedConfig = created.config as Extract<RepositoryConfig, { backend: "local" }>;
|
||||||
expect(savedConfig.path).toBe(explicitPath);
|
expect(savedConfig.path).toBe(explicitPath);
|
||||||
expect(created.status).toBe("healthy");
|
expect(created.status).toBe("healthy");
|
||||||
|
|
|
||||||
|
|
@ -25,7 +25,6 @@ import { addCommonArgs, buildEnv, buildRepoUrl, cleanupTemporaryKeys, restic } f
|
||||||
import { safeSpawn } from "../../utils/spawn";
|
import { safeSpawn } from "../../utils/spawn";
|
||||||
import { backupsService } from "../backups/backups.service";
|
import { backupsService } from "../backups/backups.service";
|
||||||
import type { DumpPathKind, UpdateRepositoryBody } from "./repositories.dto";
|
import type { DumpPathKind, UpdateRepositoryBody } from "./repositories.dto";
|
||||||
import { REPOSITORY_BASE } from "~/server/core/constants";
|
|
||||||
import { findCommonAncestor } from "~/utils/common-ancestor";
|
import { findCommonAncestor } from "~/utils/common-ancestor";
|
||||||
import { prepareSnapshotDump } from "./helpers/dump";
|
import { prepareSnapshotDump } from "./helpers/dump";
|
||||||
import { executeDoctor } from "./helpers/doctor";
|
import { executeDoctor } from "./helpers/doctor";
|
||||||
|
|
@ -131,8 +130,8 @@ const createRepository = async (name: string, config: RepositoryConfig, compress
|
||||||
const id = crypto.randomUUID();
|
const id = crypto.randomUUID();
|
||||||
const shortId = generateShortId();
|
const shortId = generateShortId();
|
||||||
|
|
||||||
if (config.backend === "local" && config.path === REPOSITORY_BASE) {
|
if (config.backend === "local" && !config.isExistingRepository) {
|
||||||
config.path = `${REPOSITORY_BASE}/${shortId}`;
|
config.path = `${config.path}/${shortId}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
const encryptedConfig = await encryptConfig(config);
|
const encryptedConfig = await encryptConfig(config);
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue