create-claude-workspace 1.1.151 → 2.0.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.
Files changed (56) hide show
  1. package/README.md +33 -1
  2. package/dist/index.js +29 -56
  3. package/dist/scheduler/agents/health-checker.mjs +98 -0
  4. package/dist/scheduler/agents/health-checker.spec.js +143 -0
  5. package/dist/scheduler/agents/orchestrator.mjs +149 -0
  6. package/dist/scheduler/agents/orchestrator.spec.js +87 -0
  7. package/dist/scheduler/agents/prompt-builder.mjs +204 -0
  8. package/dist/scheduler/agents/prompt-builder.spec.js +240 -0
  9. package/dist/scheduler/agents/worker-pool.mjs +137 -0
  10. package/dist/scheduler/agents/worker-pool.spec.js +45 -0
  11. package/dist/scheduler/git/ci-watcher.mjs +93 -0
  12. package/dist/scheduler/git/ci-watcher.spec.js +35 -0
  13. package/dist/scheduler/git/manager.mjs +228 -0
  14. package/dist/scheduler/git/manager.spec.js +198 -0
  15. package/dist/scheduler/git/release.mjs +117 -0
  16. package/dist/scheduler/git/release.spec.js +175 -0
  17. package/dist/scheduler/index.mjs +309 -0
  18. package/dist/scheduler/index.spec.js +72 -0
  19. package/dist/scheduler/integration.spec.js +289 -0
  20. package/dist/scheduler/loop.mjs +435 -0
  21. package/dist/scheduler/loop.spec.js +139 -0
  22. package/dist/scheduler/state/session.mjs +14 -0
  23. package/dist/scheduler/state/session.spec.js +36 -0
  24. package/dist/scheduler/state/state.mjs +102 -0
  25. package/dist/scheduler/state/state.spec.js +175 -0
  26. package/dist/scheduler/tasks/inbox.mjs +98 -0
  27. package/dist/scheduler/tasks/inbox.spec.js +168 -0
  28. package/dist/scheduler/tasks/parser.mjs +228 -0
  29. package/dist/scheduler/tasks/parser.spec.js +303 -0
  30. package/dist/scheduler/tasks/queue.mjs +152 -0
  31. package/dist/scheduler/tasks/queue.spec.js +223 -0
  32. package/dist/scheduler/types.mjs +20 -0
  33. package/dist/{scripts/lib → scheduler/ui}/tui.mjs +84 -41
  34. package/dist/{scripts/lib → scheduler/ui}/tui.spec.js +56 -0
  35. package/dist/scheduler/util/memory.mjs +126 -0
  36. package/dist/scheduler/util/memory.spec.js +165 -0
  37. package/dist/template/.claude/{profiles/angular.md → agents/angular-engineer.md} +9 -4
  38. package/dist/template/.claude/{profiles/react.md → agents/react-engineer.md} +9 -4
  39. package/dist/template/.claude/{profiles/svelte.md → agents/svelte-engineer.md} +9 -4
  40. package/dist/template/.claude/{profiles/vue.md → agents/vue-engineer.md} +9 -4
  41. package/package.json +3 -4
  42. package/dist/scripts/autonomous.mjs +0 -492
  43. package/dist/scripts/autonomous.spec.js +0 -46
  44. package/dist/scripts/docker-run.mjs +0 -462
  45. package/dist/scripts/integration.spec.js +0 -108
  46. package/dist/scripts/lib/formatter.mjs +0 -309
  47. package/dist/scripts/lib/formatter.spec.js +0 -262
  48. package/dist/scripts/lib/state.mjs +0 -44
  49. package/dist/scripts/lib/state.spec.js +0 -59
  50. package/dist/template/.claude/docker/.dockerignore +0 -8
  51. package/dist/template/.claude/docker/Dockerfile +0 -54
  52. package/dist/template/.claude/docker/docker-compose.yml +0 -22
  53. package/dist/template/.claude/docker/docker-entrypoint.sh +0 -101
  54. /package/dist/{scripts/lib/types.mjs → scheduler/shared-types.mjs} +0 -0
  55. /package/dist/{scripts/lib → scheduler/util}/idle-poll.mjs +0 -0
  56. /package/dist/{scripts/lib → scheduler/util}/idle-poll.spec.js +0 -0
@@ -0,0 +1,45 @@
1
+ import { describe, it, expect, vi } from 'vitest';
2
+ import { WorkerPool } from './worker-pool.mjs';
3
+ const mockLogger = {
4
+ info: vi.fn(),
5
+ warn: vi.fn(),
6
+ error: vi.fn(),
7
+ debug: vi.fn(),
8
+ };
9
+ // ─── Unit tests (no real SDK calls) ───
10
+ describe('WorkerPool', () => {
11
+ it('initializes with correct number of workers', () => {
12
+ const pool = new WorkerPool({ concurrency: 3, maxTurns: 50, skipPermissions: false, logger: mockLogger });
13
+ expect(pool.slots).toHaveLength(3);
14
+ expect(pool.activeCount()).toBe(0);
15
+ });
16
+ it('all workers start idle', () => {
17
+ const pool = new WorkerPool({ concurrency: 2, maxTurns: 50, skipPermissions: false, logger: mockLogger });
18
+ for (const slot of pool.slots) {
19
+ expect(slot.status).toBe('idle');
20
+ expect(slot.taskId).toBeNull();
21
+ }
22
+ });
23
+ it('finds idle slot', () => {
24
+ const pool = new WorkerPool({ concurrency: 2, maxTurns: 50, skipPermissions: false, logger: mockLogger });
25
+ const idle = pool.idleSlot();
26
+ expect(idle).not.toBeNull();
27
+ expect(idle.id).toBe(0);
28
+ });
29
+ it('gets slot by id', () => {
30
+ const pool = new WorkerPool({ concurrency: 3, maxTurns: 50, skipPermissions: false, logger: mockLogger });
31
+ expect(pool.getSlot(0)?.id).toBe(0);
32
+ expect(pool.getSlot(2)?.id).toBe(2);
33
+ expect(pool.getSlot(99)).toBeNull();
34
+ });
35
+ it('rejects spawn on invalid slot', async () => {
36
+ const pool = new WorkerPool({ concurrency: 1, maxTurns: 50, skipPermissions: false, logger: mockLogger });
37
+ await expect(pool.spawn(99, { cwd: '/tmp', prompt: 'test' })).rejects.toThrow('Invalid worker slot');
38
+ });
39
+ it('resets all workers', () => {
40
+ const pool = new WorkerPool({ concurrency: 2, maxTurns: 50, skipPermissions: false, logger: mockLogger });
41
+ pool.reset();
42
+ expect(pool.slots).toHaveLength(2);
43
+ expect(pool.activeCount()).toBe(0);
44
+ });
45
+ });
@@ -0,0 +1,93 @@
1
+ // ─── CI pipeline status polling ───
2
+ // Uses gh/glab CLI to watch pipeline status after push.
3
+ import { execSync } from 'node:child_process';
4
+ const POLL_INTERVAL = 15_000;
5
+ const MAX_POLL_TIME = 30 * 60_000; // 30 minutes
6
+ export function detectCIPlatform(projectDir) {
7
+ try {
8
+ const remote = execSync('git remote get-url origin', { cwd: projectDir, encoding: 'utf-8', stdio: 'pipe' }).trim();
9
+ if (remote.includes('github.com') || remote.includes('github:'))
10
+ return 'github';
11
+ if (remote.includes('gitlab.com') || remote.includes('gitlab:') || remote.includes('gitlab'))
12
+ return 'gitlab';
13
+ }
14
+ catch { /* ignore */ }
15
+ return 'none';
16
+ }
17
+ export async function watchPipeline(branch, platform, projectDir, logger, signal) {
18
+ if (platform === 'none') {
19
+ return { status: 'not-found' };
20
+ }
21
+ const startTime = Date.now();
22
+ while (Date.now() - startTime < MAX_POLL_TIME) {
23
+ if (signal?.stopped)
24
+ return { status: 'canceled' };
25
+ const status = platform === 'github'
26
+ ? pollGitHub(branch, projectDir)
27
+ : pollGitLab(branch, projectDir);
28
+ if (status === 'passed' || status === 'failed' || status === 'canceled') {
29
+ const logs = status === 'failed'
30
+ ? fetchFailureLogs(branch, platform, projectDir)
31
+ : undefined;
32
+ return { status, logs };
33
+ }
34
+ logger.info(`CI: ${status}, waiting ${POLL_INTERVAL / 1000}s...`);
35
+ await sleep(POLL_INTERVAL);
36
+ }
37
+ return { status: 'timeout' };
38
+ }
39
+ function pollGitHub(branch, cwd) {
40
+ try {
41
+ const output = execSync(`gh run list --branch "${branch}" --limit 1 --json status,conclusion`, { cwd, encoding: 'utf-8', stdio: 'pipe', timeout: 15_000 }).trim();
42
+ const runs = JSON.parse(output);
43
+ if (!runs.length)
44
+ return 'not-found';
45
+ const run = runs[0];
46
+ if (run.status === 'completed') {
47
+ return run.conclusion === 'success' ? 'passed' : 'failed';
48
+ }
49
+ return 'pending';
50
+ }
51
+ catch {
52
+ return 'not-found';
53
+ }
54
+ }
55
+ function pollGitLab(branch, cwd) {
56
+ try {
57
+ const output = execSync(`glab ci list --branch "${branch}" --output json`, { cwd, encoding: 'utf-8', stdio: 'pipe', timeout: 15_000 }).trim();
58
+ const pipelines = JSON.parse(output);
59
+ if (!pipelines.length)
60
+ return 'not-found';
61
+ const pipeline = pipelines[0];
62
+ switch (pipeline.status) {
63
+ case 'success': return 'passed';
64
+ case 'failed': return 'failed';
65
+ case 'canceled': return 'canceled';
66
+ default: return 'pending';
67
+ }
68
+ }
69
+ catch {
70
+ return 'not-found';
71
+ }
72
+ }
73
+ export function fetchFailureLogs(branch, platform, cwd) {
74
+ try {
75
+ if (platform === 'github') {
76
+ // Get the latest failed run ID and fetch logs
77
+ const runsOutput = execSync(`gh run list --branch "${branch}" --limit 1 --json databaseId,conclusion`, { cwd, encoding: 'utf-8', stdio: 'pipe', timeout: 15_000 }).trim();
78
+ const runs = JSON.parse(runsOutput);
79
+ if (!runs.length)
80
+ return undefined;
81
+ const runId = runs[0].databaseId;
82
+ return execSync(`gh run view ${runId} --log-failed`, { cwd, encoding: 'utf-8', stdio: 'pipe', timeout: 30_000 }).trim();
83
+ }
84
+ if (platform === 'gitlab') {
85
+ return execSync(`glab ci trace --branch "${branch}"`, { cwd, encoding: 'utf-8', stdio: 'pipe', timeout: 30_000 }).trim();
86
+ }
87
+ }
88
+ catch { /* ignore */ }
89
+ return undefined;
90
+ }
91
+ function sleep(ms) {
92
+ return new Promise(resolve => setTimeout(resolve, ms));
93
+ }
@@ -0,0 +1,35 @@
1
+ import { describe, it, expect, beforeEach, afterEach } from 'vitest';
2
+ import { execSync } from 'node:child_process';
3
+ import { mkdtempSync, writeFileSync, rmSync } from 'node:fs';
4
+ import { resolve, join } from 'node:path';
5
+ import { tmpdir } from 'node:os';
6
+ import { detectCIPlatform } from './ci-watcher.mjs';
7
+ let testDir;
8
+ beforeEach(() => {
9
+ testDir = mkdtempSync(join(tmpdir(), 'ci-watcher-test-'));
10
+ execSync('git init -b main', { cwd: testDir, stdio: 'pipe' });
11
+ execSync('git config user.name "Test"', { cwd: testDir, stdio: 'pipe' });
12
+ execSync('git config user.email "test@test.com"', { cwd: testDir, stdio: 'pipe' });
13
+ writeFileSync(resolve(testDir, 'file.txt'), 'init');
14
+ execSync('git add . && git commit -m "init"', { cwd: testDir, stdio: 'pipe' });
15
+ });
16
+ afterEach(() => {
17
+ rmSync(testDir, { recursive: true, force: true });
18
+ });
19
+ describe('detectCIPlatform', () => {
20
+ it('returns none for repo without remote', () => {
21
+ expect(detectCIPlatform(testDir)).toBe('none');
22
+ });
23
+ it('detects github from remote URL', () => {
24
+ execSync('git remote add origin https://github.com/user/repo.git', { cwd: testDir, stdio: 'pipe' });
25
+ expect(detectCIPlatform(testDir)).toBe('github');
26
+ });
27
+ it('detects gitlab from remote URL', () => {
28
+ execSync('git remote add origin https://gitlab.com/user/repo.git', { cwd: testDir, stdio: 'pipe' });
29
+ expect(detectCIPlatform(testDir)).toBe('gitlab');
30
+ });
31
+ it('detects github from SSH URL', () => {
32
+ execSync('git remote add origin git@github.com:user/repo.git', { cwd: testDir, stdio: 'pipe' });
33
+ expect(detectCIPlatform(testDir)).toBe('github');
34
+ });
35
+ });
@@ -0,0 +1,228 @@
1
+ // ─── Deterministic git operations ───
2
+ // All operations use execSync — git is fast and must complete before the next step.
3
+ import { execSync } from 'node:child_process';
4
+ import { existsSync } from 'node:fs';
5
+ import { resolve } from 'node:path';
6
+ const GIT_TIMEOUT = 30_000;
7
+ const PUSH_TIMEOUT = 60_000;
8
+ function git(args, cwd, timeout = GIT_TIMEOUT) {
9
+ return execSync(`git ${args}`, { cwd, timeout, stdio: 'pipe', encoding: 'utf-8' }).trim();
10
+ }
11
+ // ─── Worktree operations ───
12
+ export function createWorktree(projectDir, branchSlug, baseBranch) {
13
+ const worktreePath = resolve(projectDir, '.worktrees', branchSlug);
14
+ const base = baseBranch ?? getMainBranch(projectDir);
15
+ if (existsSync(worktreePath)) {
16
+ return worktreePath;
17
+ }
18
+ git(`worktree add "${worktreePath}" -b "${branchSlug}" "${base}"`, projectDir);
19
+ return worktreePath;
20
+ }
21
+ export function cleanupWorktree(projectDir, worktreePath, branch) {
22
+ try {
23
+ git(`worktree remove "${worktreePath}" --force`, projectDir);
24
+ }
25
+ catch { /* may already be removed */ }
26
+ try {
27
+ git(`branch -d "${branch}"`, projectDir);
28
+ }
29
+ catch { /* may already be deleted */ }
30
+ }
31
+ export function listWorktrees(projectDir) {
32
+ const output = git('worktree list --porcelain', projectDir);
33
+ const mainNorm = normalizePath(resolve(projectDir));
34
+ const paths = [];
35
+ for (const line of output.split('\n')) {
36
+ if (line.startsWith('worktree ')) {
37
+ const path = line.slice('worktree '.length).trim();
38
+ // Skip the main worktree (normalize for Windows path comparison)
39
+ if (normalizePath(path) !== mainNorm) {
40
+ paths.push(path);
41
+ }
42
+ }
43
+ }
44
+ return paths;
45
+ }
46
+ export function listOrphanedWorktrees(projectDir, knownWorktrees) {
47
+ const actual = listWorktrees(projectDir);
48
+ const knownSet = new Set(knownWorktrees.map(p => normalizePath(resolve(p))));
49
+ return actual.filter(p => !knownSet.has(normalizePath(resolve(p))));
50
+ }
51
+ // ─── Commit operations ───
52
+ export function commitInWorktree(worktreePath, message) {
53
+ git('add -A', worktreePath);
54
+ // Check if there's anything to commit
55
+ const status = git('status --porcelain', worktreePath);
56
+ if (!status)
57
+ return '';
58
+ git(`commit -m "${escapeMsg(message)}"`, worktreePath);
59
+ return git('rev-parse HEAD', worktreePath);
60
+ }
61
+ export function getChangedFiles(worktreePath) {
62
+ try {
63
+ const mainBranch = getMainBranch(worktreePath);
64
+ const output = git(`diff --name-only "${mainBranch}"...HEAD`, worktreePath);
65
+ return output ? output.split('\n').filter(f => f.length > 0) : [];
66
+ }
67
+ catch {
68
+ // Fallback: diff against parent
69
+ try {
70
+ const output = git('diff --name-only HEAD~1', worktreePath);
71
+ return output ? output.split('\n').filter(f => f.length > 0) : [];
72
+ }
73
+ catch {
74
+ return [];
75
+ }
76
+ }
77
+ }
78
+ export function hasUncommittedChanges(dir) {
79
+ const status = git('status --porcelain', dir);
80
+ return status.length > 0;
81
+ }
82
+ // ─── Push operations ───
83
+ export function pushWorktree(worktreePath) {
84
+ try {
85
+ const branch = git('rev-parse --abbrev-ref HEAD', worktreePath);
86
+ git(`push -u origin "${branch}"`, worktreePath, PUSH_TIMEOUT);
87
+ return true;
88
+ }
89
+ catch {
90
+ return false;
91
+ }
92
+ }
93
+ export function forcePushWorktree(worktreePath) {
94
+ try {
95
+ const branch = git('rev-parse --abbrev-ref HEAD', worktreePath);
96
+ git(`push --force-with-lease -u origin "${branch}"`, worktreePath, PUSH_TIMEOUT);
97
+ return true;
98
+ }
99
+ catch {
100
+ return false;
101
+ }
102
+ }
103
+ export function mergeToMain(projectDir, branch) {
104
+ const main = getMainBranch(projectDir);
105
+ try {
106
+ git(`merge "${branch}" --no-ff -m "Merge ${branch}"`, projectDir);
107
+ const sha = git('rev-parse HEAD', projectDir);
108
+ return { success: true, sha, conflict: false };
109
+ }
110
+ catch (err) {
111
+ const isConflict = hasConflicts(projectDir);
112
+ if (isConflict) {
113
+ git('merge --abort', projectDir);
114
+ }
115
+ return {
116
+ success: false,
117
+ sha: null,
118
+ conflict: isConflict,
119
+ error: err.message,
120
+ };
121
+ }
122
+ }
123
+ function hasConflicts(dir) {
124
+ try {
125
+ const status = git('status --porcelain', dir);
126
+ return status.includes('UU ') || status.includes('AA ') || status.includes('DD ');
127
+ }
128
+ catch {
129
+ return false;
130
+ }
131
+ }
132
+ // ─── Branch / sync operations ───
133
+ export function syncMain(projectDir) {
134
+ try {
135
+ git('fetch --quiet', projectDir);
136
+ git('pull --ff-only --quiet', projectDir, PUSH_TIMEOUT);
137
+ return true;
138
+ }
139
+ catch {
140
+ return false;
141
+ }
142
+ }
143
+ export function getMainBranch(projectDir) {
144
+ try {
145
+ // Check for remote HEAD
146
+ const ref = git('symbolic-ref refs/remotes/origin/HEAD', projectDir);
147
+ return ref.replace('refs/remotes/origin/', '');
148
+ }
149
+ catch {
150
+ // Fallback: check common names
151
+ try {
152
+ git('rev-parse --verify main', projectDir);
153
+ return 'main';
154
+ }
155
+ catch {
156
+ return 'master';
157
+ }
158
+ }
159
+ }
160
+ export function getCurrentBranch(dir) {
161
+ return git('rev-parse --abbrev-ref HEAD', dir);
162
+ }
163
+ export function branchExists(projectDir, branch) {
164
+ try {
165
+ git(`rev-parse --verify "${branch}"`, projectDir);
166
+ return true;
167
+ }
168
+ catch {
169
+ return false;
170
+ }
171
+ }
172
+ export function isBranchMerged(projectDir, branch) {
173
+ try {
174
+ const main = getMainBranch(projectDir);
175
+ // Check if branch tip is an ancestor of main (i.e., was merged)
176
+ git(`merge-base --is-ancestor "${branch}" "${main}"`, projectDir);
177
+ return true;
178
+ }
179
+ catch {
180
+ return false;
181
+ }
182
+ }
183
+ // ─── Git identity ───
184
+ export function hasGitIdentity(projectDir) {
185
+ try {
186
+ git('config user.name', projectDir);
187
+ git('config user.email', projectDir);
188
+ return true;
189
+ }
190
+ catch {
191
+ return false;
192
+ }
193
+ }
194
+ // ─── Tag operations ───
195
+ export function createTag(projectDir, tag, message) {
196
+ try {
197
+ const msgArg = message ? `-m "${escapeMsg(message)}"` : '';
198
+ git(`tag -a "${tag}" ${msgArg}`, projectDir);
199
+ return true;
200
+ }
201
+ catch {
202
+ return false;
203
+ }
204
+ }
205
+ export function getLatestTag(projectDir) {
206
+ try {
207
+ return git('describe --tags --abbrev=0', projectDir);
208
+ }
209
+ catch {
210
+ return null;
211
+ }
212
+ }
213
+ export function pushTags(projectDir) {
214
+ try {
215
+ git('push --tags', projectDir, PUSH_TIMEOUT);
216
+ return true;
217
+ }
218
+ catch {
219
+ return false;
220
+ }
221
+ }
222
+ // ─── Helpers ───
223
+ function escapeMsg(msg) {
224
+ return msg.replace(/"/g, '\\"').replace(/\n/g, '\\n');
225
+ }
226
+ function normalizePath(p) {
227
+ return p.replace(/\\/g, '/').toLowerCase();
228
+ }
@@ -0,0 +1,198 @@
1
+ import { describe, it, expect, beforeEach, afterEach } from 'vitest';
2
+ import { execSync } from 'node:child_process';
3
+ import { mkdtempSync, writeFileSync, existsSync } from 'node:fs';
4
+ import { resolve, join } from 'node:path';
5
+ import { tmpdir } from 'node:os';
6
+ import { rmSync } from 'node:fs';
7
+ import { createWorktree, cleanupWorktree, listWorktrees, listOrphanedWorktrees, commitInWorktree, getChangedFiles, hasUncommittedChanges, mergeToMain, getMainBranch, getCurrentBranch, branchExists, isBranchMerged, hasGitIdentity, createTag, getLatestTag, } from './manager.mjs';
8
+ let repoDir;
9
+ function gitCmd(args, cwd) {
10
+ return execSync(`git ${args}`, { cwd: cwd ?? repoDir, encoding: 'utf-8', stdio: 'pipe' }).trim();
11
+ }
12
+ beforeEach(() => {
13
+ repoDir = mkdtempSync(join(tmpdir(), 'git-manager-test-'));
14
+ // Initialize a git repo with an initial commit
15
+ gitCmd('init -b main');
16
+ gitCmd('config user.name "Test User"');
17
+ gitCmd('config user.email "test@example.com"');
18
+ writeFileSync(resolve(repoDir, 'README.md'), '# Test');
19
+ gitCmd('add .');
20
+ gitCmd('commit -m "initial commit"');
21
+ });
22
+ afterEach(() => {
23
+ // Clean up worktrees before removing the directory
24
+ try {
25
+ const worktrees = listWorktrees(repoDir);
26
+ for (const wt of worktrees) {
27
+ try {
28
+ gitCmd(`worktree remove "${wt}" --force`);
29
+ }
30
+ catch { /* */ }
31
+ }
32
+ }
33
+ catch { /* */ }
34
+ rmSync(repoDir, { recursive: true, force: true });
35
+ });
36
+ // ─── Main branch detection ───
37
+ describe('getMainBranch', () => {
38
+ it('detects main branch', () => {
39
+ expect(getMainBranch(repoDir)).toBe('main');
40
+ });
41
+ });
42
+ // ─── getCurrentBranch ───
43
+ describe('getCurrentBranch', () => {
44
+ it('returns current branch name', () => {
45
+ expect(getCurrentBranch(repoDir)).toBe('main');
46
+ });
47
+ });
48
+ // ─── Git identity ───
49
+ describe('hasGitIdentity', () => {
50
+ it('returns true when identity is configured', () => {
51
+ expect(hasGitIdentity(repoDir)).toBe(true);
52
+ });
53
+ });
54
+ // ─── Worktree operations ───
55
+ describe('createWorktree', () => {
56
+ it('creates a worktree with a new branch', () => {
57
+ const wtPath = createWorktree(repoDir, 'feat/test-feature');
58
+ expect(existsSync(wtPath)).toBe(true);
59
+ expect(getCurrentBranch(wtPath)).toBe('feat/test-feature');
60
+ });
61
+ it('returns existing path if worktree already exists', () => {
62
+ const path1 = createWorktree(repoDir, 'feat/existing');
63
+ const path2 = createWorktree(repoDir, 'feat/existing');
64
+ expect(path1).toBe(path2);
65
+ });
66
+ });
67
+ describe('listWorktrees', () => {
68
+ it('lists only non-main worktrees', () => {
69
+ createWorktree(repoDir, 'feat/a');
70
+ createWorktree(repoDir, 'feat/b');
71
+ const wts = listWorktrees(repoDir);
72
+ expect(wts).toHaveLength(2);
73
+ });
74
+ it('returns empty when no worktrees', () => {
75
+ expect(listWorktrees(repoDir)).toEqual([]);
76
+ });
77
+ });
78
+ describe('listOrphanedWorktrees', () => {
79
+ it('identifies orphaned worktrees', () => {
80
+ const wt1 = createWorktree(repoDir, 'feat/known');
81
+ createWorktree(repoDir, 'feat/orphan');
82
+ const orphans = listOrphanedWorktrees(repoDir, [wt1]);
83
+ expect(orphans).toHaveLength(1);
84
+ });
85
+ });
86
+ describe('cleanupWorktree', () => {
87
+ it('removes worktree and branch', () => {
88
+ const wtPath = createWorktree(repoDir, 'feat/to-remove');
89
+ cleanupWorktree(repoDir, wtPath, 'feat/to-remove');
90
+ expect(existsSync(wtPath)).toBe(false);
91
+ expect(branchExists(repoDir, 'feat/to-remove')).toBe(false);
92
+ });
93
+ it('handles already-removed worktree gracefully', () => {
94
+ expect(() => cleanupWorktree(repoDir, '/nonexistent', 'nonexistent')).not.toThrow();
95
+ });
96
+ });
97
+ // ─── Commit operations ───
98
+ describe('commitInWorktree', () => {
99
+ it('commits changes and returns SHA', () => {
100
+ const wtPath = createWorktree(repoDir, 'feat/commit-test');
101
+ writeFileSync(resolve(wtPath, 'new-file.ts'), 'export const x = 1;');
102
+ const sha = commitInWorktree(wtPath, 'feat: add new file');
103
+ expect(sha).toMatch(/^[0-9a-f]{40}$/);
104
+ });
105
+ it('returns empty string when nothing to commit', () => {
106
+ const wtPath = createWorktree(repoDir, 'feat/empty-commit');
107
+ const sha = commitInWorktree(wtPath, 'empty');
108
+ expect(sha).toBe('');
109
+ });
110
+ });
111
+ describe('getChangedFiles', () => {
112
+ it('lists changed files relative to main', () => {
113
+ const wtPath = createWorktree(repoDir, 'feat/changes');
114
+ writeFileSync(resolve(wtPath, 'a.ts'), 'a');
115
+ writeFileSync(resolve(wtPath, 'b.ts'), 'b');
116
+ commitInWorktree(wtPath, 'add files');
117
+ const changed = getChangedFiles(wtPath);
118
+ expect(changed).toContain('a.ts');
119
+ expect(changed).toContain('b.ts');
120
+ expect(changed).not.toContain('README.md');
121
+ });
122
+ });
123
+ describe('hasUncommittedChanges', () => {
124
+ it('returns false for clean repo', () => {
125
+ expect(hasUncommittedChanges(repoDir)).toBe(false);
126
+ });
127
+ it('returns true when files are modified', () => {
128
+ writeFileSync(resolve(repoDir, 'dirty.txt'), 'dirty');
129
+ expect(hasUncommittedChanges(repoDir)).toBe(true);
130
+ });
131
+ });
132
+ // ─── Merge operations ───
133
+ describe('mergeToMain', () => {
134
+ it('merges a branch into main', () => {
135
+ const wtPath = createWorktree(repoDir, 'feat/to-merge');
136
+ writeFileSync(resolve(wtPath, 'feature.ts'), 'export const feature = true;');
137
+ commitInWorktree(wtPath, 'feat: add feature');
138
+ const result = mergeToMain(repoDir, 'feat/to-merge');
139
+ expect(result.success).toBe(true);
140
+ expect(result.sha).toMatch(/^[0-9a-f]{40}$/);
141
+ expect(result.conflict).toBe(false);
142
+ });
143
+ it('detects merge conflicts', () => {
144
+ // Create conflicting changes
145
+ const wtPath = createWorktree(repoDir, 'feat/conflict');
146
+ writeFileSync(resolve(wtPath, 'README.md'), '# Conflicting change');
147
+ commitInWorktree(wtPath, 'feat: conflict branch');
148
+ // Modify same file on main
149
+ writeFileSync(resolve(repoDir, 'README.md'), '# Different change on main');
150
+ gitCmd('add .', repoDir);
151
+ gitCmd('commit -m "main change"', repoDir);
152
+ const result = mergeToMain(repoDir, 'feat/conflict');
153
+ expect(result.success).toBe(false);
154
+ expect(result.conflict).toBe(true);
155
+ });
156
+ });
157
+ // ─── Branch operations ───
158
+ describe('branchExists', () => {
159
+ it('returns true for existing branch', () => {
160
+ expect(branchExists(repoDir, 'main')).toBe(true);
161
+ });
162
+ it('returns false for non-existing branch', () => {
163
+ expect(branchExists(repoDir, 'nonexistent')).toBe(false);
164
+ });
165
+ });
166
+ describe('isBranchMerged', () => {
167
+ it('returns true for merged branch', () => {
168
+ const wtPath = createWorktree(repoDir, 'feat/merged');
169
+ writeFileSync(resolve(wtPath, 'merged.ts'), 'done');
170
+ commitInWorktree(wtPath, 'feat: merged');
171
+ mergeToMain(repoDir, 'feat/merged');
172
+ expect(isBranchMerged(repoDir, 'feat/merged')).toBe(true);
173
+ });
174
+ it('returns false for unmerged branch', () => {
175
+ const wtPath = createWorktree(repoDir, 'feat/unmerged');
176
+ writeFileSync(resolve(wtPath, 'unmerged.ts'), 'pending');
177
+ commitInWorktree(wtPath, 'feat: unmerged');
178
+ expect(isBranchMerged(repoDir, 'feat/unmerged')).toBe(false);
179
+ });
180
+ });
181
+ // ─── Tags ───
182
+ describe('createTag / getLatestTag', () => {
183
+ it('creates and retrieves a tag', () => {
184
+ expect(createTag(repoDir, 'v1.0.0', 'Release 1.0.0')).toBe(true);
185
+ expect(getLatestTag(repoDir)).toBe('v1.0.0');
186
+ });
187
+ it('returns null when no tags exist', () => {
188
+ expect(getLatestTag(repoDir)).toBeNull();
189
+ });
190
+ it('returns the latest tag', () => {
191
+ createTag(repoDir, 'v0.1.0', 'first');
192
+ writeFileSync(resolve(repoDir, 'after-tag.txt'), 'x');
193
+ gitCmd('add .', repoDir);
194
+ gitCmd('commit -m "after"', repoDir);
195
+ createTag(repoDir, 'v0.2.0', 'second');
196
+ expect(getLatestTag(repoDir)).toBe('v0.2.0');
197
+ });
198
+ });