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:
Nico 2026-02-26 19:43:42 +01:00 committed by GitHub
parent b1a0cdb195
commit 30f237dc1f
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
5 changed files with 45 additions and 5 deletions

1
.gitignore vendored
View file

@ -38,3 +38,4 @@ playwright/temp
openapi-ts-error-*.log
.output
tmp/
qa-output

View file

@ -5,6 +5,7 @@
- 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
- 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

View file

@ -1,5 +1,6 @@
import { useState } from "react";
import type { UseFormReturn } from "react-hook-form";
import { useWatch } from "react-hook-form";
import { Check, Pencil, X, AlertTriangle } from "lucide-react";
import { Button } from "../../../../components/ui/button";
import { FormItem, FormLabel, FormDescription, FormField, FormControl } from "../../../../components/ui/form";
@ -33,6 +34,8 @@ export const LocalRepositoryForm = ({ form }: Props) => {
queryFn: getConstants,
});
const isExistingRepository = useWatch({ control: form.control, name: "isExistingRepository" });
return (
<FormField
control={form.control}
@ -45,6 +48,7 @@ export const LocalRepositoryForm = ({ form }: Props) => {
<div className="flex items-center gap-2">
<div className="flex-1 text-sm font-mono bg-muted px-3 py-2 rounded-md border">
{field.value || constants.REPOSITORY_BASE}
{!isExistingRepository && <span className="text-muted-foreground">/{"{unique-id}"}</span>}
</div>
<Button type="button" variant="outline" onClick={() => setShowPathWarning(true)} size="sm">
<Pencil className="h-4 w-4 mr-2" />
@ -52,7 +56,11 @@ export const LocalRepositoryForm = ({ form }: Props) => {
</Button>
</div>
</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>
<AlertDialog open={showPathWarning} onOpenChange={setShowPathWarning}>

View file

@ -75,7 +75,7 @@ describe("repositoriesService.createRepository", () => {
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
const { organizationId, user } = await createTestSession();
const explicitPath = `${REPOSITORY_BASE}/custom-${randomUUID()}`;
@ -98,6 +98,37 @@ describe("repositoriesService.createRepository", () => {
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" }>;
expect(savedConfig.path).toBe(explicitPath);
expect(created.status).toBe("healthy");

View file

@ -25,7 +25,6 @@ import { addCommonArgs, buildEnv, buildRepoUrl, cleanupTemporaryKeys, restic } f
import { safeSpawn } from "../../utils/spawn";
import { backupsService } from "../backups/backups.service";
import type { DumpPathKind, UpdateRepositoryBody } from "./repositories.dto";
import { REPOSITORY_BASE } from "~/server/core/constants";
import { findCommonAncestor } from "~/utils/common-ancestor";
import { prepareSnapshotDump } from "./helpers/dump";
import { executeDoctor } from "./helpers/doctor";
@ -131,8 +130,8 @@ const createRepository = async (name: string, config: RepositoryConfig, compress
const id = crypto.randomUUID();
const shortId = generateShortId();
if (config.backend === "local" && config.path === REPOSITORY_BASE) {
config.path = `${REPOSITORY_BASE}/${shortId}`;
if (config.backend === "local" && !config.isExistingRepository) {
config.path = `${config.path}/${shortId}`;
}
const encryptedConfig = await encryptConfig(config);