@tinyrack/devsync 1.0.0 → 1.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,161 @@
1
+ import { describe, expect, it } from "vitest";
2
+
3
+ import type {
4
+ ResolvedSyncConfig,
5
+ ResolvedSyncConfigEntry,
6
+ ResolvedSyncOverride,
7
+ SyncConfig,
8
+ } from "#app/config/sync.ts";
9
+ import {
10
+ countConfiguredRules,
11
+ createSyncConfigDocument,
12
+ createSyncConfigDocumentEntry,
13
+ sortSyncConfigEntries,
14
+ sortSyncOverrides,
15
+ } from "#app/services/config-file.ts";
16
+
17
+ const baseOverride = (
18
+ value: Pick<ResolvedSyncOverride, "match" | "mode" | "path">,
19
+ ): ResolvedSyncOverride => {
20
+ return value;
21
+ };
22
+
23
+ const baseEntry = (
24
+ value: Pick<
25
+ ResolvedSyncConfigEntry,
26
+ | "configuredLocalPath"
27
+ | "kind"
28
+ | "localPath"
29
+ | "mode"
30
+ | "name"
31
+ | "overrides"
32
+ | "repoPath"
33
+ >,
34
+ ): ResolvedSyncConfigEntry => {
35
+ return value;
36
+ };
37
+
38
+ describe("config file helpers", () => {
39
+ it("sorts overrides by their formatted selector", () => {
40
+ const overrides = sortSyncOverrides([
41
+ baseOverride({ match: "exact", mode: "secret", path: "z.txt" }),
42
+ baseOverride({ match: "subtree", mode: "ignore", path: "cache" }),
43
+ baseOverride({ match: "exact", mode: "normal", path: "a.txt" }),
44
+ ]);
45
+
46
+ expect(overrides).toEqual([
47
+ { match: "exact", mode: "normal", path: "a.txt" },
48
+ { match: "subtree", mode: "ignore", path: "cache" },
49
+ { match: "exact", mode: "secret", path: "z.txt" },
50
+ ]);
51
+ });
52
+
53
+ it("creates config document entries without empty overrides", () => {
54
+ expect(
55
+ createSyncConfigDocumentEntry(
56
+ baseEntry({
57
+ configuredLocalPath: "~/.zshrc",
58
+ kind: "file",
59
+ localPath: "/tmp/home/.zshrc",
60
+ mode: "normal",
61
+ name: ".zshrc",
62
+ overrides: [],
63
+ repoPath: ".zshrc",
64
+ }),
65
+ ),
66
+ ).toEqual({
67
+ kind: "file",
68
+ localPath: "~/.zshrc",
69
+ mode: "normal",
70
+ name: ".zshrc",
71
+ repoPath: ".zshrc",
72
+ });
73
+ });
74
+
75
+ it("creates sorted override maps for config document entries", () => {
76
+ expect(
77
+ createSyncConfigDocumentEntry(
78
+ baseEntry({
79
+ configuredLocalPath: "~/bundle",
80
+ kind: "directory",
81
+ localPath: "/tmp/home/bundle",
82
+ mode: "normal",
83
+ name: "bundle",
84
+ overrides: [
85
+ baseOverride({ match: "exact", mode: "secret", path: "z.txt" }),
86
+ baseOverride({ match: "subtree", mode: "ignore", path: "cache" }),
87
+ baseOverride({ match: "exact", mode: "normal", path: "a.txt" }),
88
+ ],
89
+ repoPath: "bundle",
90
+ }),
91
+ ),
92
+ ).toEqual({
93
+ kind: "directory",
94
+ localPath: "~/bundle",
95
+ mode: "normal",
96
+ name: "bundle",
97
+ overrides: {
98
+ "a.txt": "normal",
99
+ "cache/": "ignore",
100
+ "z.txt": "secret",
101
+ },
102
+ repoPath: "bundle",
103
+ });
104
+ });
105
+
106
+ it("creates and sorts config documents from resolved config", () => {
107
+ const config: ResolvedSyncConfig = {
108
+ version: 1,
109
+ age: {
110
+ configuredIdentityFile: "$XDG_CONFIG_HOME/devsync/age/keys.txt",
111
+ identityFile: "/tmp/xdg/devsync/age/keys.txt",
112
+ recipients: ["age1a", "age1b"],
113
+ },
114
+ entries: [
115
+ baseEntry({
116
+ configuredLocalPath: "~/bundle",
117
+ kind: "directory",
118
+ localPath: "/tmp/home/bundle",
119
+ mode: "secret",
120
+ name: "bundle",
121
+ overrides: [
122
+ baseOverride({ match: "subtree", mode: "ignore", path: "cache" }),
123
+ ],
124
+ repoPath: "bundle",
125
+ }),
126
+ baseEntry({
127
+ configuredLocalPath: "~/.zshrc",
128
+ kind: "file",
129
+ localPath: "/tmp/home/.zshrc",
130
+ mode: "normal",
131
+ name: ".zshrc",
132
+ overrides: [],
133
+ repoPath: ".zshrc",
134
+ }),
135
+ ],
136
+ };
137
+
138
+ expect(
139
+ sortSyncConfigEntries(createSyncConfigDocument(config).entries),
140
+ ).toEqual([
141
+ {
142
+ kind: "file",
143
+ localPath: "~/.zshrc",
144
+ mode: "normal",
145
+ name: ".zshrc",
146
+ repoPath: ".zshrc",
147
+ },
148
+ {
149
+ kind: "directory",
150
+ localPath: "~/bundle",
151
+ mode: "secret",
152
+ name: "bundle",
153
+ overrides: {
154
+ "cache/": "ignore",
155
+ },
156
+ repoPath: "bundle",
157
+ },
158
+ ] satisfies SyncConfig["entries"]);
159
+ expect(countConfiguredRules(config)).toBe(1);
160
+ });
161
+ });
@@ -0,0 +1,132 @@
1
+ import { readFile, rm, writeFile } from "node:fs/promises";
2
+ import { join } from "node:path";
3
+
4
+ import { afterEach, describe, expect, it } from "vitest";
5
+
6
+ import {
7
+ createAgeIdentityFile,
8
+ decryptSecretFile,
9
+ encryptSecretFile,
10
+ readAgeIdentityLines,
11
+ readAgeRecipientsFromIdentityFile,
12
+ } from "#app/services/crypto.ts";
13
+ import {
14
+ createAgeKeyPair,
15
+ createTemporaryDirectory,
16
+ } from "../test/helpers/sync-fixture.ts";
17
+
18
+ const temporaryDirectories: string[] = [];
19
+
20
+ const createWorkspace = async () => {
21
+ const directory = await createTemporaryDirectory("devsync-sync-crypto-");
22
+
23
+ temporaryDirectories.push(directory);
24
+
25
+ return directory;
26
+ };
27
+
28
+ afterEach(async () => {
29
+ while (temporaryDirectories.length > 0) {
30
+ const directory = temporaryDirectories.pop();
31
+
32
+ if (directory !== undefined) {
33
+ await rm(directory, { force: true, recursive: true });
34
+ }
35
+ }
36
+ });
37
+
38
+ describe("sync crypto helpers", () => {
39
+ it("reads identities while ignoring blank lines and comments", async () => {
40
+ const workspace = await createWorkspace();
41
+ const keyPair = await createAgeKeyPair();
42
+ const identityFile = join(workspace, "keys.txt");
43
+
44
+ await writeFile(
45
+ identityFile,
46
+ `\n# first\n${keyPair.identity}\n\n# second\n${keyPair.identity}\n`,
47
+ "utf8",
48
+ );
49
+
50
+ expect(await readAgeIdentityLines(identityFile)).toEqual([
51
+ keyPair.identity,
52
+ keyPair.identity,
53
+ ]);
54
+ });
55
+
56
+ it("fails when no usable identities are present", async () => {
57
+ const workspace = await createWorkspace();
58
+ const identityFile = join(workspace, "keys.txt");
59
+
60
+ await writeFile(identityFile, "\n# comment only\n\n", "utf8");
61
+
62
+ await expect(readAgeIdentityLines(identityFile)).rejects.toThrowError(
63
+ /No age identities found/u,
64
+ );
65
+ });
66
+
67
+ it("deduplicates recipients derived from repeated identities", async () => {
68
+ const workspace = await createWorkspace();
69
+ const keyPair = await createAgeKeyPair();
70
+ const identityFile = join(workspace, "keys.txt");
71
+
72
+ await writeFile(
73
+ identityFile,
74
+ `${keyPair.identity}\n${keyPair.identity}\n`,
75
+ "utf8",
76
+ );
77
+
78
+ expect(await readAgeRecipientsFromIdentityFile(identityFile)).toEqual([
79
+ keyPair.recipient,
80
+ ]);
81
+ });
82
+
83
+ it("creates a new identity file with a trailing newline", async () => {
84
+ const workspace = await createWorkspace();
85
+ const identityFile = join(workspace, "nested", "keys.txt");
86
+
87
+ const result = await createAgeIdentityFile(identityFile);
88
+ const contents = await readFile(identityFile, "utf8");
89
+
90
+ expect(contents.endsWith("\n")).toBe(true);
91
+ expect(await readAgeRecipientsFromIdentityFile(identityFile)).toEqual([
92
+ result.recipient,
93
+ ]);
94
+ });
95
+
96
+ it("round-trips secret payloads through age encryption", async () => {
97
+ const workspace = await createWorkspace();
98
+ const keyPair = await createAgeKeyPair();
99
+ const identityFile = join(workspace, "keys.txt");
100
+ const payload = new TextEncoder().encode("super secret payload");
101
+
102
+ await writeFile(identityFile, `${keyPair.identity}\n`, "utf8");
103
+
104
+ const ciphertext = await encryptSecretFile(payload, [keyPair.recipient]);
105
+ const plaintext = await decryptSecretFile(ciphertext, identityFile);
106
+
107
+ expect(new TextDecoder().decode(plaintext)).toBe("super secret payload");
108
+ });
109
+
110
+ it("fails to decrypt with the wrong identity or malformed ciphertext", async () => {
111
+ const workspace = await createWorkspace();
112
+ const sender = await createAgeKeyPair();
113
+ const wrongIdentity = await createAgeKeyPair();
114
+ const senderIdentityFile = join(workspace, "sender.txt");
115
+ const wrongIdentityFile = join(workspace, "wrong.txt");
116
+
117
+ await writeFile(senderIdentityFile, `${sender.identity}\n`, "utf8");
118
+ await writeFile(wrongIdentityFile, `${wrongIdentity.identity}\n`, "utf8");
119
+
120
+ const ciphertext = await encryptSecretFile(
121
+ new TextEncoder().encode("secret"),
122
+ [sender.recipient],
123
+ );
124
+
125
+ await expect(
126
+ decryptSecretFile(ciphertext, wrongIdentityFile),
127
+ ).rejects.toThrowError();
128
+ await expect(
129
+ decryptSecretFile("not a valid age payload", senderIdentityFile),
130
+ ).rejects.toThrowError();
131
+ });
132
+ });
@@ -0,0 +1,142 @@
1
+ import { readSyncConfig } from "#app/config/sync.ts";
2
+
3
+ import { countConfiguredRules } from "./config-file.ts";
4
+ import { pathExists } from "./filesystem.ts";
5
+ import { ensureRepository } from "./git.ts";
6
+ import type { SyncContext } from "./runtime.ts";
7
+
8
+ export type DoctorCheckLevel = "fail" | "ok" | "warn";
9
+
10
+ export type DoctorCheck = Readonly<{
11
+ detail: string;
12
+ level: DoctorCheckLevel;
13
+ name: string;
14
+ }>;
15
+
16
+ export type SyncDoctorResult = Readonly<{
17
+ checks: readonly DoctorCheck[];
18
+ configPath: string;
19
+ hasFailures: boolean;
20
+ hasWarnings: boolean;
21
+ syncDirectory: string;
22
+ }>;
23
+
24
+ const ok = (name: string, detail: string): DoctorCheck => ({
25
+ detail,
26
+ level: "ok",
27
+ name,
28
+ });
29
+
30
+ const warn = (name: string, detail: string): DoctorCheck => ({
31
+ detail,
32
+ level: "warn",
33
+ name,
34
+ });
35
+
36
+ const fail = (name: string, detail: string): DoctorCheck => ({
37
+ detail,
38
+ level: "fail",
39
+ name,
40
+ });
41
+
42
+ export const runSyncDoctor = async (
43
+ context: SyncContext,
44
+ ): Promise<SyncDoctorResult> => {
45
+ const checks: DoctorCheck[] = [];
46
+
47
+ try {
48
+ await ensureRepository(context.paths.syncDirectory);
49
+ checks.push(ok("git", "Sync directory is a git repository."));
50
+ } catch (error: unknown) {
51
+ checks.push(
52
+ fail(
53
+ "git",
54
+ error instanceof Error ? error.message : "Git repository check failed.",
55
+ ),
56
+ );
57
+
58
+ return {
59
+ checks,
60
+ configPath: context.paths.configPath,
61
+ hasFailures: true,
62
+ hasWarnings: false,
63
+ syncDirectory: context.paths.syncDirectory,
64
+ };
65
+ }
66
+
67
+ let config: Awaited<ReturnType<typeof readSyncConfig>>;
68
+
69
+ try {
70
+ config = await readSyncConfig(
71
+ context.paths.syncDirectory,
72
+ context.environment,
73
+ );
74
+ checks.push(
75
+ ok(
76
+ "config",
77
+ `Loaded config with ${config.entries.length} entries, ${countConfiguredRules(config)} rules, and ${config.age.recipients.length} recipients.`,
78
+ ),
79
+ );
80
+ } catch (error: unknown) {
81
+ checks.push(
82
+ fail(
83
+ "config",
84
+ error instanceof Error
85
+ ? error.message
86
+ : "Sync configuration could not be read.",
87
+ ),
88
+ );
89
+
90
+ return {
91
+ checks,
92
+ configPath: context.paths.configPath,
93
+ hasFailures: true,
94
+ hasWarnings: false,
95
+ syncDirectory: context.paths.syncDirectory,
96
+ };
97
+ }
98
+
99
+ checks.push(
100
+ (await pathExists(config.age.identityFile))
101
+ ? ok("age", `Age identity file exists at ${config.age.identityFile}.`)
102
+ : fail("age", `Age identity file is missing: ${config.age.identityFile}`),
103
+ );
104
+
105
+ checks.push(
106
+ config.entries.length === 0
107
+ ? warn("entries", "No sync entries are configured yet.")
108
+ : ok("entries", `Tracked ${config.entries.length} sync entries.`),
109
+ );
110
+
111
+ const missingEntries = config.entries.filter((entry) => {
112
+ return !context.environment || entry.localPath.length > 0;
113
+ });
114
+
115
+ let missingCount = 0;
116
+
117
+ for (const entry of missingEntries) {
118
+ if (!(await pathExists(entry.localPath))) {
119
+ missingCount += 1;
120
+ }
121
+ }
122
+
123
+ checks.push(
124
+ missingCount === 0
125
+ ? ok("local-paths", "All tracked local paths currently exist.")
126
+ : warn(
127
+ "local-paths",
128
+ `${missingCount} tracked local path${missingCount === 1 ? " is" : "s are"} missing.`,
129
+ ),
130
+ );
131
+
132
+ const hasFailures = checks.some((check) => check.level === "fail");
133
+ const hasWarnings = checks.some((check) => check.level === "warn");
134
+
135
+ return {
136
+ checks,
137
+ configPath: context.paths.configPath,
138
+ hasFailures,
139
+ hasWarnings,
140
+ syncDirectory: context.paths.syncDirectory,
141
+ };
142
+ };
@@ -0,0 +1,171 @@
1
+ import {
2
+ chmod,
3
+ lstat,
4
+ mkdir,
5
+ readFile,
6
+ readlink,
7
+ rm,
8
+ symlink,
9
+ writeFile,
10
+ } from "node:fs/promises";
11
+ import { join } from "node:path";
12
+
13
+ import { afterEach, describe, expect, it } from "vitest";
14
+
15
+ import {
16
+ buildExecutableMode,
17
+ copyFilesystemNode,
18
+ getPathStats,
19
+ isExecutableMode,
20
+ listDirectoryEntries,
21
+ pathExists,
22
+ removePathAtomically,
23
+ replacePathAtomically,
24
+ writeFileNode,
25
+ writeSymlinkNode,
26
+ writeTextFileAtomically,
27
+ } from "#app/services/filesystem.ts";
28
+ import { createTemporaryDirectory } from "../test/helpers/sync-fixture.ts";
29
+
30
+ const temporaryDirectories: string[] = [];
31
+
32
+ const createWorkspace = async () => {
33
+ const directory = await createTemporaryDirectory("devsync-filesystem-");
34
+
35
+ temporaryDirectories.push(directory);
36
+
37
+ return directory;
38
+ };
39
+
40
+ afterEach(async () => {
41
+ while (temporaryDirectories.length > 0) {
42
+ const directory = temporaryDirectories.pop();
43
+
44
+ if (directory !== undefined) {
45
+ await rm(directory, { force: true, recursive: true });
46
+ }
47
+ }
48
+ });
49
+
50
+ describe("filesystem helpers", () => {
51
+ it("checks path existence and missing stats", async () => {
52
+ const workspace = await createWorkspace();
53
+ const filePath = join(workspace, "value.txt");
54
+
55
+ expect(await pathExists(filePath)).toBe(false);
56
+ expect(await getPathStats(filePath)).toBeUndefined();
57
+
58
+ await writeFile(filePath, "value\n", "utf8");
59
+
60
+ expect(await pathExists(filePath)).toBe(true);
61
+ expect((await getPathStats(filePath))?.isFile()).toBe(true);
62
+ });
63
+
64
+ it("lists directory entries in sorted order", async () => {
65
+ const workspace = await createWorkspace();
66
+
67
+ await mkdir(join(workspace, "b"), { recursive: true });
68
+ await writeFile(join(workspace, "c.txt"), "c\n", "utf8");
69
+ await writeFile(join(workspace, "a.txt"), "a\n", "utf8");
70
+
71
+ const entries = await listDirectoryEntries(workspace);
72
+
73
+ expect(entries.map((entry) => entry.name)).toEqual(["a.txt", "b", "c.txt"]);
74
+ });
75
+
76
+ it("builds and detects executable modes", async () => {
77
+ expect(buildExecutableMode(true)).toBe(0o755);
78
+ expect(buildExecutableMode(false)).toBe(0o644);
79
+ expect(isExecutableMode(0o100755)).toBe(true);
80
+ expect(isExecutableMode(0o100644)).toBe(false);
81
+ });
82
+
83
+ it("writes regular files and preserves executable bits", async () => {
84
+ if (process.platform === "win32") {
85
+ return;
86
+ }
87
+
88
+ const workspace = await createWorkspace();
89
+ const filePath = join(workspace, "bin", "tool.sh");
90
+
91
+ await writeFileNode(filePath, {
92
+ contents: "#!/bin/sh\nexit 0\n",
93
+ executable: true,
94
+ });
95
+
96
+ expect(await readFile(filePath, "utf8")).toContain("#!/bin/sh");
97
+ expect(isExecutableMode((await lstat(filePath)).mode)).toBe(true);
98
+ });
99
+
100
+ it("writes symlinks after removing existing content", async () => {
101
+ if (process.platform === "win32") {
102
+ return;
103
+ }
104
+
105
+ const workspace = await createWorkspace();
106
+ const linkPath = join(workspace, "links", "current");
107
+
108
+ await mkdir(join(workspace, "links"), { recursive: true });
109
+ await writeFile(linkPath, "old\n", "utf8");
110
+ await writeSymlinkNode(linkPath, "../target.txt");
111
+
112
+ expect(await readlink(linkPath)).toBe("../target.txt");
113
+ });
114
+
115
+ it("copies regular files and symlinks", async () => {
116
+ if (process.platform === "win32") {
117
+ return;
118
+ }
119
+
120
+ const workspace = await createWorkspace();
121
+ const sourceDirectory = join(workspace, "source");
122
+ const targetDirectory = join(workspace, "target");
123
+ const filePath = join(sourceDirectory, "nested", "value.txt");
124
+ const linkPath = join(sourceDirectory, "nested", "value-link");
125
+
126
+ await mkdir(join(sourceDirectory, "nested"), { recursive: true });
127
+ await writeFile(filePath, "payload\n", "utf8");
128
+ await chmod(filePath, 0o755);
129
+ await symlink("value.txt", linkPath);
130
+
131
+ await copyFilesystemNode(sourceDirectory, targetDirectory);
132
+
133
+ expect(
134
+ await readFile(join(targetDirectory, "nested", "value.txt"), "utf8"),
135
+ ).toBe("payload\n");
136
+ expect(await readlink(join(targetDirectory, "nested", "value-link"))).toBe(
137
+ "value.txt",
138
+ );
139
+ });
140
+
141
+ it("replaces and removes paths atomically", async () => {
142
+ const workspace = await createWorkspace();
143
+ const targetPath = join(workspace, "config.json");
144
+ const stagedPath = join(workspace, "next.json");
145
+
146
+ await writeFile(targetPath, "old\n", "utf8");
147
+ await writeFile(stagedPath, "new\n", "utf8");
148
+
149
+ await replacePathAtomically(targetPath, stagedPath);
150
+
151
+ expect(await readFile(targetPath, "utf8")).toBe("new\n");
152
+ expect(await pathExists(stagedPath)).toBe(false);
153
+
154
+ await removePathAtomically(targetPath);
155
+
156
+ expect(await pathExists(targetPath)).toBe(false);
157
+ await removePathAtomically(targetPath);
158
+ expect(await pathExists(targetPath)).toBe(false);
159
+ });
160
+
161
+ it("writes text files atomically for create and overwrite flows", async () => {
162
+ const workspace = await createWorkspace();
163
+ const targetPath = join(workspace, "nested", "config.json");
164
+
165
+ await writeTextFileAtomically(targetPath, "first\n");
166
+ expect(await readFile(targetPath, "utf8")).toBe("first\n");
167
+
168
+ await writeTextFileAtomically(targetPath, "second\n");
169
+ expect(await readFile(targetPath, "utf8")).toBe("second\n");
170
+ });
171
+ });
@@ -0,0 +1,83 @@
1
+ import { rm } from "node:fs/promises";
2
+ import { join } from "node:path";
3
+
4
+ import { afterEach, describe, expect, it } from "vitest";
5
+
6
+ import { DevsyncError } from "#app/services/error.ts";
7
+ import {
8
+ ensureGitRepository,
9
+ ensureRepository,
10
+ initializeRepository,
11
+ } from "#app/services/git.ts";
12
+ import {
13
+ createTemporaryDirectory,
14
+ runGit,
15
+ } from "../test/helpers/sync-fixture.ts";
16
+
17
+ const temporaryDirectories: string[] = [];
18
+
19
+ const createWorkspace = async () => {
20
+ const directory = await createTemporaryDirectory("devsync-git-");
21
+
22
+ temporaryDirectories.push(directory);
23
+
24
+ return directory;
25
+ };
26
+
27
+ afterEach(async () => {
28
+ while (temporaryDirectories.length > 0) {
29
+ const directory = temporaryDirectories.pop();
30
+
31
+ if (directory !== undefined) {
32
+ await rm(directory, { force: true, recursive: true });
33
+ }
34
+ }
35
+ });
36
+
37
+ describe("git helpers", () => {
38
+ it("initializes a repository with a main branch", async () => {
39
+ const workspace = await createWorkspace();
40
+ const repositoryPath = join(workspace, "sync");
41
+
42
+ await expect(initializeRepository(repositoryPath)).resolves.toEqual({
43
+ action: "initialized",
44
+ });
45
+ await expect(ensureRepository(repositoryPath)).resolves.toBeUndefined();
46
+ await expect(
47
+ runGit(["-C", repositoryPath, "symbolic-ref", "--short", "HEAD"]),
48
+ ).resolves.toMatchObject({
49
+ stdout: "main\n",
50
+ });
51
+ });
52
+
53
+ it("clones an existing repository and reports the source", async () => {
54
+ const workspace = await createWorkspace();
55
+ const sourcePath = join(workspace, "source");
56
+ const targetPath = join(workspace, "clone");
57
+
58
+ await runGit(["init", "-b", "main", sourcePath], workspace);
59
+
60
+ await expect(initializeRepository(targetPath, sourcePath)).resolves.toEqual(
61
+ {
62
+ action: "cloned",
63
+ source: sourcePath,
64
+ },
65
+ );
66
+ await expect(ensureRepository(targetPath)).resolves.toBeUndefined();
67
+ });
68
+
69
+ it("wraps missing git repositories in a DevsyncError", async () => {
70
+ const workspace = await createWorkspace();
71
+ const missingRepositoryPath = join(workspace, "not-a-repo");
72
+
73
+ await expect(
74
+ ensureRepository(missingRepositoryPath),
75
+ ).rejects.toThrowError();
76
+ await expect(
77
+ ensureGitRepository(missingRepositoryPath),
78
+ ).rejects.toThrowError(DevsyncError);
79
+ await expect(
80
+ ensureGitRepository(missingRepositoryPath),
81
+ ).rejects.toThrowError(/Sync directory is not a git repository/u);
82
+ });
83
+ });