arbors 0.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.
- package/.claude-plugin/plugin.json +7 -0
- package/.oxlintrc.json +9 -0
- package/README.ja.md +131 -0
- package/README.ko.md +131 -0
- package/README.md +131 -0
- package/bin/arbors.ts +278 -0
- package/dist/arbors.js +1094 -0
- package/dist/arbors.js.map +1 -0
- package/dist/bun-EMN2NS2M.js +48 -0
- package/dist/bun-EMN2NS2M.js.map +1 -0
- package/dist/ja-F4DBSAAZ.js +38 -0
- package/dist/ja-F4DBSAAZ.js.map +1 -0
- package/dist/ko-MTIAHJOR.js +38 -0
- package/dist/ko-MTIAHJOR.js.map +1 -0
- package/dist/node-LCODN3HC.js +56 -0
- package/dist/node-LCODN3HC.js.map +1 -0
- package/package.json +54 -0
- package/pnpm-workspace.yaml +1 -0
- package/shell/arbors-wrapper.sh +21 -0
- package/shell/arbors-wrapper.zsh +21 -0
- package/skills/arbors-usage/SKILL.md +129 -0
- package/src/config.ts +66 -0
- package/src/git/exclude.ts +63 -0
- package/src/git/safety.ts +40 -0
- package/src/git/worktree.ts +171 -0
- package/src/i18n/en.ts +63 -0
- package/src/i18n/index.ts +37 -0
- package/src/i18n/ja.ts +40 -0
- package/src/i18n/ko.ts +40 -0
- package/src/project/registry.ts +108 -0
- package/src/project/setup.ts +74 -0
- package/src/runtime/adapter.ts +16 -0
- package/src/runtime/bun.ts +49 -0
- package/src/runtime/index.ts +17 -0
- package/src/runtime/node.ts +58 -0
- package/src/tui/App.tsx +87 -0
- package/src/tui/FuzzyList.tsx +111 -0
- package/src/tui/ProjectSelector.tsx +48 -0
- package/src/tui/WorktreeSelector.tsx +46 -0
- package/tests/config.test.ts +108 -0
- package/tests/exclude.test.ts +120 -0
- package/tests/i18n.test.ts +75 -0
- package/tests/registry.test.ts +136 -0
- package/tests/safety.test.ts +58 -0
- package/tests/setup-detection.test.ts +105 -0
- package/tests/setup.test.ts +87 -0
- package/tsconfig.json +22 -0
- package/tsup.config.ts +14 -0
- package/vitest.config.ts +8 -0
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
import { describe, it, expect } from "vitest";
|
|
2
|
+
import { mergeConfig, loadConfig } from "../src/config.js";
|
|
3
|
+
import type { ArborConfig } from "../src/config.js";
|
|
4
|
+
|
|
5
|
+
describe("mergeConfig", () => {
|
|
6
|
+
it("should override base values with override values", () => {
|
|
7
|
+
// Given: a base config and an override with different runtime
|
|
8
|
+
const base = { runtime: "bun", language: "ko" } as ArborConfig;
|
|
9
|
+
const override = { runtime: "node" } as Partial<ArborConfig>;
|
|
10
|
+
|
|
11
|
+
// When: merged
|
|
12
|
+
const result = mergeConfig(base, override);
|
|
13
|
+
|
|
14
|
+
// Then: override wins, base-only values preserved
|
|
15
|
+
expect(result.runtime).toBe("node");
|
|
16
|
+
expect(result.language).toBe("ko");
|
|
17
|
+
});
|
|
18
|
+
|
|
19
|
+
it("should not mutate the original base object", () => {
|
|
20
|
+
// Given: a base config
|
|
21
|
+
const base = { runtime: "bun", language: "en" } as ArborConfig;
|
|
22
|
+
const original = structuredClone(base);
|
|
23
|
+
|
|
24
|
+
// When: merged with an override
|
|
25
|
+
mergeConfig(base, { runtime: "node" } as Partial<ArborConfig>);
|
|
26
|
+
|
|
27
|
+
// Then: original is untouched
|
|
28
|
+
expect(base).toEqual(original);
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
it("should return base when override is empty", () => {
|
|
32
|
+
// Given: a base config and an empty override
|
|
33
|
+
const base = { runtime: "node", language: "ja" } as ArborConfig;
|
|
34
|
+
|
|
35
|
+
// When: merged with empty object
|
|
36
|
+
const result = mergeConfig(base, {});
|
|
37
|
+
|
|
38
|
+
// Then: result matches base
|
|
39
|
+
expect(result).toEqual(base);
|
|
40
|
+
});
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
describe("loadConfig", () => {
|
|
44
|
+
const mockReadFile = (files: Record<string, string>) => async (path: string) => {
|
|
45
|
+
if (Object.hasOwn(files, path)) return files[path];
|
|
46
|
+
throw new Error(`File not found: ${path}`);
|
|
47
|
+
};
|
|
48
|
+
|
|
49
|
+
const mockExists = (files: Record<string, string>) => async (path: string) =>
|
|
50
|
+
Object.hasOwn(files, path);
|
|
51
|
+
|
|
52
|
+
it("should return defaults when no config files exist", async () => {
|
|
53
|
+
// Given: no config files on disk
|
|
54
|
+
const readFile = mockReadFile({});
|
|
55
|
+
const exists = mockExists({});
|
|
56
|
+
|
|
57
|
+
// When: config is loaded
|
|
58
|
+
const config = await loadConfig(readFile, exists);
|
|
59
|
+
|
|
60
|
+
// Then: all values are defaults
|
|
61
|
+
expect(config.runtime).toBe("node");
|
|
62
|
+
expect(config.language).toBe("en");
|
|
63
|
+
expect(config.packageManager).toBe("auto");
|
|
64
|
+
expect(config.copyExcludes).toBe(true);
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
it("should merge project config over global config", async () => {
|
|
68
|
+
// Given: global sets runtime=bun, project sets runtime=node
|
|
69
|
+
const home = process.env.HOME ?? "/tmp";
|
|
70
|
+
const globalPath = `${home}/.arbors/config.json`;
|
|
71
|
+
const projectPath = "/project/.arbors/config.json";
|
|
72
|
+
|
|
73
|
+
const files: Record<string, string> = {
|
|
74
|
+
[globalPath]: JSON.stringify({ runtime: "bun", language: "ko" }),
|
|
75
|
+
[projectPath]: JSON.stringify({ runtime: "node" }),
|
|
76
|
+
};
|
|
77
|
+
|
|
78
|
+
const readFile = mockReadFile(files);
|
|
79
|
+
const exists = mockExists(files);
|
|
80
|
+
|
|
81
|
+
// When: config is loaded with a project root
|
|
82
|
+
const config = await loadConfig(readFile, exists, "/project");
|
|
83
|
+
|
|
84
|
+
// Then: project override wins, global-only values preserved
|
|
85
|
+
expect(config.runtime).toBe("node");
|
|
86
|
+
expect(config.language).toBe("ko");
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
it("should handle malformed config files gracefully", async () => {
|
|
90
|
+
// Given: a global config with invalid JSON
|
|
91
|
+
const home = process.env.HOME ?? "/tmp";
|
|
92
|
+
const globalPath = `${home}/.arbors/config.json`;
|
|
93
|
+
|
|
94
|
+
const files: Record<string, string> = {
|
|
95
|
+
[globalPath]: "not valid json{{{",
|
|
96
|
+
};
|
|
97
|
+
|
|
98
|
+
const readFile = mockReadFile(files);
|
|
99
|
+
const exists = mockExists(files);
|
|
100
|
+
|
|
101
|
+
// When: config is loaded
|
|
102
|
+
const config = await loadConfig(readFile, exists);
|
|
103
|
+
|
|
104
|
+
// Then: falls back to defaults
|
|
105
|
+
expect(config.runtime).toBe("node");
|
|
106
|
+
expect(config.language).toBe("en");
|
|
107
|
+
});
|
|
108
|
+
});
|
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
import { describe, it, expect, vi } from "vitest";
|
|
2
|
+
import { getExcludePatterns, findExcludedEntries, copyExcludedFiles } from "../src/git/exclude.js";
|
|
3
|
+
import type { RuntimeAdapter } from "../src/runtime/adapter.js";
|
|
4
|
+
|
|
5
|
+
const createMockAdapter = (overrides: Partial<RuntimeAdapter> = {}): RuntimeAdapter => ({
|
|
6
|
+
exec: vi.fn(async () => ({ stdout: "/repo", stderr: "", exitCode: 0 })),
|
|
7
|
+
glob: vi.fn(async () => []),
|
|
8
|
+
readFile: vi.fn(async () => ""),
|
|
9
|
+
writeFile: vi.fn(),
|
|
10
|
+
exists: vi.fn(async () => true),
|
|
11
|
+
copy: vi.fn(),
|
|
12
|
+
mkdir: vi.fn(),
|
|
13
|
+
...overrides,
|
|
14
|
+
});
|
|
15
|
+
|
|
16
|
+
describe("getExcludePatterns", () => {
|
|
17
|
+
it("should parse patterns from exclude file", async () => {
|
|
18
|
+
// Given: an exclude file with patterns, comments, and blank lines
|
|
19
|
+
const adapter = createMockAdapter({
|
|
20
|
+
readFile: vi.fn(async () => "# comment\n.env\n\nnode_modules\n# another comment\n.secret\n"),
|
|
21
|
+
});
|
|
22
|
+
|
|
23
|
+
// When: patterns are extracted
|
|
24
|
+
const patterns = await getExcludePatterns(adapter);
|
|
25
|
+
|
|
26
|
+
// Then: only non-empty, non-comment lines are returned
|
|
27
|
+
expect(patterns).toEqual([".env", "node_modules", ".secret"]);
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
it("should return empty array when exclude file does not exist", async () => {
|
|
31
|
+
// Given: no exclude file
|
|
32
|
+
const adapter = createMockAdapter({
|
|
33
|
+
exists: vi.fn(async () => false),
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
// When: patterns are extracted
|
|
37
|
+
const patterns = await getExcludePatterns(adapter);
|
|
38
|
+
|
|
39
|
+
// Then: empty array
|
|
40
|
+
expect(patterns).toEqual([]);
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
it("should trim whitespace from patterns", async () => {
|
|
44
|
+
// Given: patterns with extra whitespace
|
|
45
|
+
const adapter = createMockAdapter({
|
|
46
|
+
readFile: vi.fn(async () => " .env \n node_modules \n"),
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
// When: patterns are extracted
|
|
50
|
+
const patterns = await getExcludePatterns(adapter);
|
|
51
|
+
|
|
52
|
+
// Then: patterns are trimmed
|
|
53
|
+
expect(patterns).toEqual([".env", "node_modules"]);
|
|
54
|
+
});
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
describe("findExcludedEntries", () => {
|
|
58
|
+
it("should glob each pattern and deduplicate results", async () => {
|
|
59
|
+
// Given: patterns that match overlapping entries
|
|
60
|
+
const adapter = createMockAdapter({
|
|
61
|
+
glob: vi.fn(async (pattern: string) => {
|
|
62
|
+
if (pattern === ".env") return [".env"];
|
|
63
|
+
if (pattern === ".env*") return [".env", ".env.local"];
|
|
64
|
+
return [];
|
|
65
|
+
}),
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
// When: excluded entries are found
|
|
69
|
+
const entries = await findExcludedEntries(adapter, [".env", ".env*"]);
|
|
70
|
+
|
|
71
|
+
// Then: duplicates are removed and sorted
|
|
72
|
+
expect(entries).toEqual([".env", ".env.local"]);
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
it("should strip leading slashes from patterns", async () => {
|
|
76
|
+
// Given: a pattern with a leading slash
|
|
77
|
+
const globFn = vi.fn(async () => ["secret.key"]);
|
|
78
|
+
const adapter = createMockAdapter({ glob: globFn });
|
|
79
|
+
|
|
80
|
+
// When: excluded entries are found
|
|
81
|
+
await findExcludedEntries(adapter, ["/secret.key"]);
|
|
82
|
+
|
|
83
|
+
// Then: glob is called without the leading slash
|
|
84
|
+
expect(globFn).toHaveBeenCalledWith("secret.key", "/repo");
|
|
85
|
+
});
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
describe("copyExcludedFiles", () => {
|
|
89
|
+
it("should copy each excluded file to the worktree", async () => {
|
|
90
|
+
// Given: exclude patterns that match two files
|
|
91
|
+
const copyFn = vi.fn();
|
|
92
|
+
const mkdirFn = vi.fn();
|
|
93
|
+
const adapter = createMockAdapter({
|
|
94
|
+
readFile: vi.fn(async () => ".env\n"),
|
|
95
|
+
glob: vi.fn(async () => [".env"]),
|
|
96
|
+
copy: copyFn,
|
|
97
|
+
mkdir: mkdirFn,
|
|
98
|
+
});
|
|
99
|
+
|
|
100
|
+
// When: excluded files are copied
|
|
101
|
+
const copied = await copyExcludedFiles(adapter, "/worktree");
|
|
102
|
+
|
|
103
|
+
// Then: files are copied from repo root to worktree
|
|
104
|
+
expect(copied).toEqual([".env"]);
|
|
105
|
+
expect(copyFn).toHaveBeenCalledWith("/repo/.env", "/worktree/.env");
|
|
106
|
+
});
|
|
107
|
+
|
|
108
|
+
it("should return empty array when no exclude patterns exist", async () => {
|
|
109
|
+
// Given: no patterns in exclude file
|
|
110
|
+
const adapter = createMockAdapter({
|
|
111
|
+
readFile: vi.fn(async () => ""),
|
|
112
|
+
});
|
|
113
|
+
|
|
114
|
+
// When: copy is attempted
|
|
115
|
+
const copied = await copyExcludedFiles(adapter, "/worktree");
|
|
116
|
+
|
|
117
|
+
// Then: nothing is copied
|
|
118
|
+
expect(copied).toEqual([]);
|
|
119
|
+
});
|
|
120
|
+
});
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
|
|
2
|
+
import { loadMessages } from "../src/i18n/index.js";
|
|
3
|
+
|
|
4
|
+
describe("i18n", () => {
|
|
5
|
+
beforeEach(() => {
|
|
6
|
+
vi.stubEnv("LANG", "");
|
|
7
|
+
vi.stubEnv("LANGUAGE", "");
|
|
8
|
+
});
|
|
9
|
+
|
|
10
|
+
afterEach(() => {
|
|
11
|
+
vi.unstubAllEnvs();
|
|
12
|
+
});
|
|
13
|
+
|
|
14
|
+
it("should load English messages by default", async () => {
|
|
15
|
+
// Given: no language configured and no LANG env
|
|
16
|
+
// When: messages are loaded without config
|
|
17
|
+
const messages = await loadMessages();
|
|
18
|
+
|
|
19
|
+
// Then: English messages are returned
|
|
20
|
+
expect(messages.selectProject).toBe("Select a project:");
|
|
21
|
+
});
|
|
22
|
+
|
|
23
|
+
it("should load Korean messages when configured", async () => {
|
|
24
|
+
// Given: language is set to ko
|
|
25
|
+
// When: messages are loaded with ko config
|
|
26
|
+
const messages = await loadMessages("ko");
|
|
27
|
+
|
|
28
|
+
// Then: Korean messages are returned
|
|
29
|
+
expect(messages.selectProject).toBe("프로젝트를 선택하세요:");
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
it("should load Japanese messages when configured", async () => {
|
|
33
|
+
// Given: language is set to ja
|
|
34
|
+
// When: messages are loaded with ja config
|
|
35
|
+
const messages = await loadMessages("ja");
|
|
36
|
+
|
|
37
|
+
// Then: Japanese messages are returned
|
|
38
|
+
expect(messages.selectProject).toBe("プロジェクトを選択してください:");
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
it("should detect language from LANG env variable", async () => {
|
|
42
|
+
// Given: LANG is set to ko_KR.UTF-8
|
|
43
|
+
vi.stubEnv("LANG", "ko_KR.UTF-8");
|
|
44
|
+
|
|
45
|
+
// When: messages are loaded without explicit config
|
|
46
|
+
const messages = await loadMessages();
|
|
47
|
+
|
|
48
|
+
// Then: Korean is detected and loaded
|
|
49
|
+
expect(messages.selectProject).toBe("프로젝트를 선택하세요:");
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
it("should handle resultsFound with correct pluralization", async () => {
|
|
53
|
+
// Given: English messages
|
|
54
|
+
const messages = await loadMessages("en");
|
|
55
|
+
|
|
56
|
+
// When: resultsFound is called with 1 and 3
|
|
57
|
+
const singular = messages.resultsFound(1);
|
|
58
|
+
const plural = messages.resultsFound(3);
|
|
59
|
+
|
|
60
|
+
// Then: pluralization is correct
|
|
61
|
+
expect(singular).toBe("1 result found");
|
|
62
|
+
expect(plural).toBe("3 results found");
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
it("should fall back to English for unknown locale", async () => {
|
|
66
|
+
// Given: LANG is set to an unsupported language
|
|
67
|
+
vi.stubEnv("LANG", "fr_FR.UTF-8");
|
|
68
|
+
|
|
69
|
+
// When: messages are loaded
|
|
70
|
+
const messages = await loadMessages();
|
|
71
|
+
|
|
72
|
+
// Then: falls back to English
|
|
73
|
+
expect(messages.selectProject).toBe("Select a project:");
|
|
74
|
+
});
|
|
75
|
+
});
|
|
@@ -0,0 +1,136 @@
|
|
|
1
|
+
import { describe, it, expect, vi, beforeEach } from "vitest";
|
|
2
|
+
import {
|
|
3
|
+
getProjects,
|
|
4
|
+
getWorktrees,
|
|
5
|
+
registerProject,
|
|
6
|
+
registerWorktree,
|
|
7
|
+
removeProject,
|
|
8
|
+
unregisterWorktree,
|
|
9
|
+
} from "../src/project/registry.js";
|
|
10
|
+
import type { RuntimeAdapter } from "../src/runtime/adapter.js";
|
|
11
|
+
|
|
12
|
+
const createMockAdapter = (): RuntimeAdapter & { files: Map<string, string> } => {
|
|
13
|
+
const files = new Map<string, string>();
|
|
14
|
+
|
|
15
|
+
return {
|
|
16
|
+
files,
|
|
17
|
+
exec: vi.fn(),
|
|
18
|
+
glob: vi.fn(),
|
|
19
|
+
readFile: vi.fn(async (path: string) => {
|
|
20
|
+
const content = files.get(path);
|
|
21
|
+
if (!content) throw new Error("Not found");
|
|
22
|
+
return content;
|
|
23
|
+
}),
|
|
24
|
+
writeFile: vi.fn(async (path: string, content: string) => {
|
|
25
|
+
files.set(path, content);
|
|
26
|
+
}),
|
|
27
|
+
exists: vi.fn(async (path: string) => files.has(path)),
|
|
28
|
+
copy: vi.fn(),
|
|
29
|
+
mkdir: vi.fn(),
|
|
30
|
+
};
|
|
31
|
+
};
|
|
32
|
+
|
|
33
|
+
describe("Project Registry", () => {
|
|
34
|
+
let adapter: ReturnType<typeof createMockAdapter>;
|
|
35
|
+
|
|
36
|
+
beforeEach(() => {
|
|
37
|
+
adapter = createMockAdapter();
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
it("should return empty list when no projects registered", async () => {
|
|
41
|
+
// Given: an empty registry (no db.json)
|
|
42
|
+
// When: projects are fetched
|
|
43
|
+
const projects = await getProjects(adapter);
|
|
44
|
+
|
|
45
|
+
// Then: empty array returned
|
|
46
|
+
expect(projects).toEqual([]);
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
it("should register and retrieve a project", async () => {
|
|
50
|
+
// Given: an empty registry
|
|
51
|
+
// When: a project is registered
|
|
52
|
+
await registerProject(adapter, "my-app", "/home/user/my-app");
|
|
53
|
+
|
|
54
|
+
// Then: it appears in the project list
|
|
55
|
+
const projects = await getProjects(adapter);
|
|
56
|
+
expect(projects).toHaveLength(1);
|
|
57
|
+
expect(projects[0].name).toBe("my-app");
|
|
58
|
+
expect(projects[0].path).toBe("/home/user/my-app");
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
it("should update lastAccessed when registering an existing project", async () => {
|
|
62
|
+
// Given: a registered project
|
|
63
|
+
await registerProject(adapter, "my-app", "/home/user/my-app");
|
|
64
|
+
const firstAccess = (await getProjects(adapter))[0].lastAccessed;
|
|
65
|
+
|
|
66
|
+
// When: the same path is registered again after a delay
|
|
67
|
+
await new Promise((r) => setTimeout(r, 10));
|
|
68
|
+
await registerProject(adapter, "my-app", "/home/user/my-app");
|
|
69
|
+
|
|
70
|
+
// Then: lastAccessed is updated
|
|
71
|
+
const projects = await getProjects(adapter);
|
|
72
|
+
expect(projects).toHaveLength(1);
|
|
73
|
+
expect(projects[0].lastAccessed).not.toBe(firstAccess);
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
it("should sort projects by most recently accessed", async () => {
|
|
77
|
+
// Given: two projects registered at different times
|
|
78
|
+
await registerProject(adapter, "old-app", "/old");
|
|
79
|
+
await new Promise((r) => setTimeout(r, 10));
|
|
80
|
+
await registerProject(adapter, "new-app", "/new");
|
|
81
|
+
|
|
82
|
+
// When: projects are fetched
|
|
83
|
+
const projects = await getProjects(adapter);
|
|
84
|
+
|
|
85
|
+
// Then: most recent first
|
|
86
|
+
expect(projects[0].name).toBe("new-app");
|
|
87
|
+
expect(projects[1].name).toBe("old-app");
|
|
88
|
+
});
|
|
89
|
+
|
|
90
|
+
it("should remove a project by path", async () => {
|
|
91
|
+
// Given: a registered project
|
|
92
|
+
await registerProject(adapter, "my-app", "/home/user/my-app");
|
|
93
|
+
|
|
94
|
+
// When: removed by path
|
|
95
|
+
await removeProject(adapter, "/home/user/my-app");
|
|
96
|
+
|
|
97
|
+
// Then: no projects remain
|
|
98
|
+
const projects = await getProjects(adapter);
|
|
99
|
+
expect(projects).toEqual([]);
|
|
100
|
+
});
|
|
101
|
+
});
|
|
102
|
+
|
|
103
|
+
describe("Worktree Registry", () => {
|
|
104
|
+
let adapter: ReturnType<typeof createMockAdapter>;
|
|
105
|
+
|
|
106
|
+
beforeEach(() => {
|
|
107
|
+
adapter = createMockAdapter();
|
|
108
|
+
});
|
|
109
|
+
|
|
110
|
+
it("should register and retrieve worktrees by project", async () => {
|
|
111
|
+
await registerWorktree(adapter, "/wt/feature-login", "feature/login", "/repo");
|
|
112
|
+
await registerWorktree(adapter, "/wt/fix-bug", "fix/bug", "/repo");
|
|
113
|
+
await registerWorktree(adapter, "/wt/other", "other", "/other-repo");
|
|
114
|
+
|
|
115
|
+
const worktrees = await getWorktrees(adapter, "/repo");
|
|
116
|
+
expect(worktrees).toHaveLength(2);
|
|
117
|
+
expect(worktrees[0].branch).toBe("feature/login");
|
|
118
|
+
expect(worktrees[1].branch).toBe("fix/bug");
|
|
119
|
+
});
|
|
120
|
+
|
|
121
|
+
it("should not duplicate worktrees with the same path", async () => {
|
|
122
|
+
await registerWorktree(adapter, "/wt/feature-login", "feature/login", "/repo");
|
|
123
|
+
await registerWorktree(adapter, "/wt/feature-login", "feature/login", "/repo");
|
|
124
|
+
|
|
125
|
+
const worktrees = await getWorktrees(adapter, "/repo");
|
|
126
|
+
expect(worktrees).toHaveLength(1);
|
|
127
|
+
});
|
|
128
|
+
|
|
129
|
+
it("should unregister a worktree by path", async () => {
|
|
130
|
+
await registerWorktree(adapter, "/wt/feature-login", "feature/login", "/repo");
|
|
131
|
+
await unregisterWorktree(adapter, "/wt/feature-login");
|
|
132
|
+
|
|
133
|
+
const worktrees = await getWorktrees(adapter, "/repo");
|
|
134
|
+
expect(worktrees).toEqual([]);
|
|
135
|
+
});
|
|
136
|
+
});
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
import { describe, it, expect } from "vitest";
|
|
2
|
+
import { validateWorktreeName } from "../src/git/safety.js";
|
|
3
|
+
|
|
4
|
+
describe("validateWorktreeName", () => {
|
|
5
|
+
it("should accept valid alphanumeric names", () => {
|
|
6
|
+
// Given: valid worktree names
|
|
7
|
+
const names = ["feature-auth", "fix.login", "release_1.0", "v2"];
|
|
8
|
+
|
|
9
|
+
// When/Then: all should be valid
|
|
10
|
+
names.forEach((name) => {
|
|
11
|
+
expect(validateWorktreeName(name)).toBe(true);
|
|
12
|
+
});
|
|
13
|
+
});
|
|
14
|
+
|
|
15
|
+
it("should reject names starting with special characters", () => {
|
|
16
|
+
// Given: names starting with dot, dash, or underscore
|
|
17
|
+
const names = [".hidden", "-flag", "_private"];
|
|
18
|
+
|
|
19
|
+
// When/Then: all should be invalid
|
|
20
|
+
names.forEach((name) => {
|
|
21
|
+
expect(validateWorktreeName(name)).toBe(false);
|
|
22
|
+
});
|
|
23
|
+
});
|
|
24
|
+
|
|
25
|
+
it("should reject empty strings", () => {
|
|
26
|
+
// Given: an empty string
|
|
27
|
+
// When: validated
|
|
28
|
+
// Then: should be invalid
|
|
29
|
+
expect(validateWorktreeName("")).toBe(false);
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
it("should reject names with path traversal", () => {
|
|
33
|
+
// Given: a name containing ".."
|
|
34
|
+
// When: validated
|
|
35
|
+
// Then: should be invalid
|
|
36
|
+
expect(validateWorktreeName("foo..bar")).toBe(false);
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
it("should accept names with slashes for branch conventions", () => {
|
|
40
|
+
// Given: names with slashes (feature/xxx, fix/xxx patterns)
|
|
41
|
+
const names = ["feature/login", "fix/ACD-123", "release/1.0"];
|
|
42
|
+
|
|
43
|
+
// When/Then: all should be valid
|
|
44
|
+
names.forEach((name) => {
|
|
45
|
+
expect(validateWorktreeName(name)).toBe(true);
|
|
46
|
+
});
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
it("should reject names with spaces or special chars", () => {
|
|
50
|
+
// Given: names with spaces or special characters
|
|
51
|
+
const names = ["has space", "has@at"];
|
|
52
|
+
|
|
53
|
+
// When/Then: all should be invalid
|
|
54
|
+
names.forEach((name) => {
|
|
55
|
+
expect(validateWorktreeName(name)).toBe(false);
|
|
56
|
+
});
|
|
57
|
+
});
|
|
58
|
+
});
|
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
import { describe, it, expect, vi } from "vitest";
|
|
2
|
+
import { detectPackageManager, detectRuntimeManager } from "../src/project/setup.js";
|
|
3
|
+
import type { RuntimeAdapter } from "../src/runtime/adapter.js";
|
|
4
|
+
|
|
5
|
+
const createMockAdapter = (existingFiles: string[]): RuntimeAdapter => ({
|
|
6
|
+
exec: vi.fn(),
|
|
7
|
+
glob: vi.fn(),
|
|
8
|
+
readFile: vi.fn(),
|
|
9
|
+
writeFile: vi.fn(),
|
|
10
|
+
exists: vi.fn(async (path: string) => existingFiles.some((f) => path.endsWith(f))),
|
|
11
|
+
copy: vi.fn(),
|
|
12
|
+
mkdir: vi.fn(),
|
|
13
|
+
});
|
|
14
|
+
|
|
15
|
+
describe("detectPackageManager", () => {
|
|
16
|
+
it("should detect pnpm from lock file", async () => {
|
|
17
|
+
// Given: a directory with pnpm-lock.yaml
|
|
18
|
+
const adapter = createMockAdapter(["pnpm-lock.yaml"]);
|
|
19
|
+
|
|
20
|
+
// When: package manager is detected
|
|
21
|
+
const result = await detectPackageManager(adapter, "/project");
|
|
22
|
+
|
|
23
|
+
// Then: pnpm is detected
|
|
24
|
+
expect(result).toBe("pnpm");
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
it("should detect yarn from lock file", async () => {
|
|
28
|
+
// Given: a directory with yarn.lock
|
|
29
|
+
const adapter = createMockAdapter(["yarn.lock"]);
|
|
30
|
+
|
|
31
|
+
// When: detected
|
|
32
|
+
const result = await detectPackageManager(adapter, "/project");
|
|
33
|
+
|
|
34
|
+
// Then: yarn
|
|
35
|
+
expect(result).toBe("yarn");
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
it("should detect npm from lock file", async () => {
|
|
39
|
+
// Given: a directory with package-lock.json
|
|
40
|
+
const adapter = createMockAdapter(["package-lock.json"]);
|
|
41
|
+
|
|
42
|
+
// When: detected
|
|
43
|
+
const result = await detectPackageManager(adapter, "/project");
|
|
44
|
+
|
|
45
|
+
// Then: npm
|
|
46
|
+
expect(result).toBe("npm");
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
it("should return null when no lock file found", async () => {
|
|
50
|
+
// Given: a directory with no lock files
|
|
51
|
+
const adapter = createMockAdapter([]);
|
|
52
|
+
|
|
53
|
+
// When: detected
|
|
54
|
+
const result = await detectPackageManager(adapter, "/project");
|
|
55
|
+
|
|
56
|
+
// Then: null
|
|
57
|
+
expect(result).toBeNull();
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
it("should prioritize pnpm over yarn when both exist", async () => {
|
|
61
|
+
// Given: a directory with both pnpm and yarn lock files
|
|
62
|
+
const adapter = createMockAdapter(["pnpm-lock.yaml", "yarn.lock"]);
|
|
63
|
+
|
|
64
|
+
// When: detected
|
|
65
|
+
const result = await detectPackageManager(adapter, "/project");
|
|
66
|
+
|
|
67
|
+
// Then: pnpm wins (first in priority order)
|
|
68
|
+
expect(result).toBe("pnpm");
|
|
69
|
+
});
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
describe("detectRuntimeManager", () => {
|
|
73
|
+
it("should detect mise from mise.toml", async () => {
|
|
74
|
+
// Given: a directory with mise.toml
|
|
75
|
+
const adapter = createMockAdapter(["mise.toml"]);
|
|
76
|
+
|
|
77
|
+
// When: detected
|
|
78
|
+
const result = await detectRuntimeManager(adapter, "/project");
|
|
79
|
+
|
|
80
|
+
// Then: mise
|
|
81
|
+
expect(result).toBe("mise");
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
it("should detect nvm from .nvmrc", async () => {
|
|
85
|
+
// Given: a directory with .nvmrc
|
|
86
|
+
const adapter = createMockAdapter([".nvmrc"]);
|
|
87
|
+
|
|
88
|
+
// When: detected
|
|
89
|
+
const result = await detectRuntimeManager(adapter, "/project");
|
|
90
|
+
|
|
91
|
+
// Then: nvm
|
|
92
|
+
expect(result).toBe("nvm");
|
|
93
|
+
});
|
|
94
|
+
|
|
95
|
+
it("should return null when no runtime manager found", async () => {
|
|
96
|
+
// Given: a directory with no runtime config files
|
|
97
|
+
const adapter = createMockAdapter([]);
|
|
98
|
+
|
|
99
|
+
// When: detected
|
|
100
|
+
const result = await detectRuntimeManager(adapter, "/project");
|
|
101
|
+
|
|
102
|
+
// Then: null
|
|
103
|
+
expect(result).toBeNull();
|
|
104
|
+
});
|
|
105
|
+
});
|
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
import { describe, it, expect, vi } from "vitest";
|
|
2
|
+
import { runSetup } from "../src/project/setup.js";
|
|
3
|
+
import type { RuntimeAdapter } from "../src/runtime/adapter.js";
|
|
4
|
+
|
|
5
|
+
const createMockAdapter = (existingFiles: string[], execLog: string[][] = []): RuntimeAdapter => ({
|
|
6
|
+
exec: vi.fn(async (cmd: string, args: string[]) => {
|
|
7
|
+
execLog.push([cmd, ...args]);
|
|
8
|
+
return { stdout: "", stderr: "", exitCode: 0 };
|
|
9
|
+
}),
|
|
10
|
+
glob: vi.fn(),
|
|
11
|
+
readFile: vi.fn(),
|
|
12
|
+
writeFile: vi.fn(),
|
|
13
|
+
exists: vi.fn(async (path: string) => existingFiles.some((f) => path.endsWith(f))),
|
|
14
|
+
copy: vi.fn(),
|
|
15
|
+
mkdir: vi.fn(),
|
|
16
|
+
});
|
|
17
|
+
|
|
18
|
+
describe("runSetup", () => {
|
|
19
|
+
it("should detect and run pnpm install", async () => {
|
|
20
|
+
// Given: a project with pnpm-lock.yaml
|
|
21
|
+
const execLog: string[][] = [];
|
|
22
|
+
const adapter = createMockAdapter(["pnpm-lock.yaml"], execLog);
|
|
23
|
+
|
|
24
|
+
// When: setup runs with auto detection
|
|
25
|
+
const result = await runSetup(adapter, "/project");
|
|
26
|
+
|
|
27
|
+
// Then: pnpm install is executed
|
|
28
|
+
expect(result.packageManager).toBe("pnpm");
|
|
29
|
+
expect(execLog).toContainEqual(["pnpm", "install"]);
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
it("should use config-specified package manager over auto-detect", async () => {
|
|
33
|
+
// Given: a project with pnpm lock but config says yarn
|
|
34
|
+
const execLog: string[][] = [];
|
|
35
|
+
const adapter = createMockAdapter(["pnpm-lock.yaml"], execLog);
|
|
36
|
+
|
|
37
|
+
// When: setup runs with yarn override
|
|
38
|
+
const result = await runSetup(adapter, "/project", "yarn");
|
|
39
|
+
|
|
40
|
+
// Then: yarn is used instead of pnpm
|
|
41
|
+
expect(result.packageManager).toBe("yarn");
|
|
42
|
+
expect(execLog).toContainEqual(["yarn", "install"]);
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
it("should detect and run mise install before package manager", async () => {
|
|
46
|
+
// Given: a project with mise.toml and pnpm-lock.yaml
|
|
47
|
+
const execLog: string[][] = [];
|
|
48
|
+
const adapter = createMockAdapter(["mise.toml", "pnpm-lock.yaml"], execLog);
|
|
49
|
+
|
|
50
|
+
// When: setup runs
|
|
51
|
+
const result = await runSetup(adapter, "/project");
|
|
52
|
+
|
|
53
|
+
// Then: mise runs first, then pnpm
|
|
54
|
+
expect(result.runtimeManager).toBe("mise");
|
|
55
|
+
expect(result.packageManager).toBe("pnpm");
|
|
56
|
+
const miseIndex = execLog.findIndex((c) => c[0] === "mise");
|
|
57
|
+
const pnpmIndex = execLog.findIndex((c) => c[0] === "pnpm");
|
|
58
|
+
expect(miseIndex).toBeLessThan(pnpmIndex);
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
it("should skip package manager when no lock file and auto mode", async () => {
|
|
62
|
+
// Given: a project with no lock files
|
|
63
|
+
const execLog: string[][] = [];
|
|
64
|
+
const adapter = createMockAdapter([], execLog);
|
|
65
|
+
|
|
66
|
+
// When: setup runs
|
|
67
|
+
const result = await runSetup(adapter, "/project");
|
|
68
|
+
|
|
69
|
+
// Then: no package manager is detected or run
|
|
70
|
+
expect(result.packageManager).toBeNull();
|
|
71
|
+
expect(execLog).toHaveLength(0);
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
it("should detect nvm and source nvm.sh before nvm install", async () => {
|
|
75
|
+
// Given: a project with .nvmrc
|
|
76
|
+
const execLog: string[][] = [];
|
|
77
|
+
const adapter = createMockAdapter([".nvmrc"], execLog);
|
|
78
|
+
|
|
79
|
+
// When: setup runs
|
|
80
|
+
const result = await runSetup(adapter, "/project");
|
|
81
|
+
|
|
82
|
+
// Then: nvm is detected and invoked via bash -c
|
|
83
|
+
expect(result.runtimeManager).toBe("nvm");
|
|
84
|
+
expect(execLog[0][0]).toBe("bash");
|
|
85
|
+
expect(execLog[0][1]).toBe("-c");
|
|
86
|
+
});
|
|
87
|
+
});
|