tlc-claude-code 2.1.0 → 2.2.1
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/agents/builder.md +144 -0
- package/.claude/agents/planner.md +143 -0
- package/.claude/agents/reviewer.md +160 -0
- package/.claude/commands/tlc/build.md +59 -50
- package/.claude/commands/tlc/review-plan.md +363 -0
- package/.claude/commands/tlc/review.md +155 -53
- package/CLAUDE.md +1 -0
- package/bin/install.js +105 -8
- package/bin/postinstall.js +60 -1
- package/bin/setup-autoupdate.js +206 -0
- package/bin/setup-autoupdate.test.js +124 -0
- package/bin/tlc.js +0 -0
- package/package.json +2 -2
- package/scripts/project-docs.js +1 -1
- package/server/lib/cost-tracker.test.js +49 -12
- package/server/lib/orchestration/agent-dispatcher.js +114 -0
- package/server/lib/orchestration/agent-dispatcher.test.js +110 -0
- package/server/lib/orchestration/orchestrator.js +130 -0
- package/server/lib/orchestration/orchestrator.test.js +192 -0
- package/server/lib/orchestration/tmux-manager.js +101 -0
- package/server/lib/orchestration/tmux-manager.test.js +109 -0
- package/server/lib/orchestration/worktree-manager.js +132 -0
- package/server/lib/orchestration/worktree-manager.test.js +129 -0
- package/server/lib/review/plan-reviewer.js +260 -0
- package/server/lib/review/plan-reviewer.test.js +269 -0
- package/server/lib/review/review-schemas.js +173 -0
- package/server/lib/review/review-schemas.test.js +152 -0
- package/server/setup.sh +271 -271
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Tmux Session Manager
|
|
3
|
+
* Manages tmux sessions with panes for agent visibility.
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Sanitizes a session name by stripping special characters and replacing spaces with hyphens.
|
|
8
|
+
* @param {string} name - Raw session name
|
|
9
|
+
* @returns {string} Sanitized session name
|
|
10
|
+
*/
|
|
11
|
+
function sanitizeName(name) {
|
|
12
|
+
return name
|
|
13
|
+
.replace(/\s+/g, '-')
|
|
14
|
+
.replace(/[^a-zA-Z0-9\-_]/g, '');
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* Checks whether tmux is available on the system.
|
|
19
|
+
* @param {{ exec: Function }} deps - Injected exec function
|
|
20
|
+
* @returns {boolean} True if tmux is found, false otherwise
|
|
21
|
+
*/
|
|
22
|
+
export function isAvailable({ exec }) {
|
|
23
|
+
try {
|
|
24
|
+
exec('which tmux');
|
|
25
|
+
return true;
|
|
26
|
+
} catch {
|
|
27
|
+
return false;
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* Creates a new detached tmux session with the given name.
|
|
33
|
+
* @param {string} name - Session name (will be sanitized)
|
|
34
|
+
* @param {{ exec: Function }} deps - Injected exec function
|
|
35
|
+
*/
|
|
36
|
+
export function createSession(name, { exec }) {
|
|
37
|
+
const safe = sanitizeName(name);
|
|
38
|
+
exec(`tmux new-session -d -s ${safe}`);
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* Adds a pane to an existing tmux session and runs a command in it.
|
|
43
|
+
* @param {string} session - Session name
|
|
44
|
+
* @param {string} paneName - Logical pane name (unused by tmux directly)
|
|
45
|
+
* @param {string} command - Command to send to the pane
|
|
46
|
+
* @param {{ exec: Function, paneCount?: number, paneIndex?: number }} deps - Injected deps
|
|
47
|
+
*/
|
|
48
|
+
export function addPane(session, paneName, command, { exec, paneCount = 2, paneIndex = 1 }) {
|
|
49
|
+
// For 4-pane grid: alternate horizontal/vertical splits based on pane index
|
|
50
|
+
// For 2 panes: vertical split (side by side)
|
|
51
|
+
// Default: vertical split
|
|
52
|
+
let splitFlag;
|
|
53
|
+
if (paneCount === 4) {
|
|
54
|
+
// paneIndex 1,3 => horizontal split; paneIndex 2,4 => vertical split
|
|
55
|
+
splitFlag = paneIndex % 2 === 1 ? '-h' : '-v';
|
|
56
|
+
} else {
|
|
57
|
+
// 2 panes: vertical split (splits current pane side-by-side)
|
|
58
|
+
splitFlag = '-h';
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
exec(`tmux split-window ${splitFlag} -t ${session}`);
|
|
62
|
+
// Use single quotes to avoid shell re-tokenizing nested double quotes in the command
|
|
63
|
+
exec(`tmux send-keys -t ${session} '${command.replace(/'/g, "'\\''")}' Enter`);
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/**
|
|
67
|
+
* Captures the current content of a pane and returns it as a string.
|
|
68
|
+
* @param {string} session - Session name
|
|
69
|
+
* @param {number} paneIndex - Zero-based pane index
|
|
70
|
+
* @param {{ exec: Function }} deps - Injected exec function
|
|
71
|
+
* @returns {string} Captured pane content
|
|
72
|
+
*/
|
|
73
|
+
export function capturePane(session, paneIndex, { exec }) {
|
|
74
|
+
// Use .N suffix to target pane N within the session's first window
|
|
75
|
+
return exec(`tmux capture-pane -t ${session}:.${paneIndex} -p`);
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/**
|
|
79
|
+
* Kills a tmux session by name.
|
|
80
|
+
* @param {string} name - Session name
|
|
81
|
+
* @param {{ exec: Function }} deps - Injected exec function
|
|
82
|
+
*/
|
|
83
|
+
export function killSession(name, { exec }) {
|
|
84
|
+
const safe = sanitizeName(name);
|
|
85
|
+
exec(`tmux kill-session -t ${safe}`);
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/**
|
|
89
|
+
* Builds a layout descriptor based on the number of agents.
|
|
90
|
+
* @param {number} agentCount - Number of agents/panes
|
|
91
|
+
* @returns {{ type: string }} Layout descriptor
|
|
92
|
+
*/
|
|
93
|
+
export function buildLayout(agentCount) {
|
|
94
|
+
if (agentCount === 2) {
|
|
95
|
+
return { type: 'vertical' };
|
|
96
|
+
}
|
|
97
|
+
if (agentCount <= 4) {
|
|
98
|
+
return { type: 'grid' };
|
|
99
|
+
}
|
|
100
|
+
return { type: 'tiled' };
|
|
101
|
+
}
|
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
|
2
|
+
import {
|
|
3
|
+
createSession,
|
|
4
|
+
addPane,
|
|
5
|
+
capturePane,
|
|
6
|
+
killSession,
|
|
7
|
+
isAvailable,
|
|
8
|
+
buildLayout,
|
|
9
|
+
} from './tmux-manager.js';
|
|
10
|
+
|
|
11
|
+
describe('tmux-manager', () => {
|
|
12
|
+
let exec;
|
|
13
|
+
|
|
14
|
+
beforeEach(() => {
|
|
15
|
+
exec = vi.fn(() => '');
|
|
16
|
+
});
|
|
17
|
+
|
|
18
|
+
describe('isAvailable', () => {
|
|
19
|
+
it('returns true when tmux is installed', () => {
|
|
20
|
+
exec.mockReturnValue('/usr/bin/tmux');
|
|
21
|
+
expect(isAvailable({ exec })).toBe(true);
|
|
22
|
+
});
|
|
23
|
+
|
|
24
|
+
it('returns false when tmux is missing', () => {
|
|
25
|
+
exec.mockImplementation(() => { throw new Error('not found'); });
|
|
26
|
+
expect(isAvailable({ exec })).toBe(false);
|
|
27
|
+
});
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
describe('createSession', () => {
|
|
31
|
+
it('generates correct tmux new-session command', () => {
|
|
32
|
+
exec.mockReturnValue('');
|
|
33
|
+
createSession('tlc-build-42', { exec });
|
|
34
|
+
const cmd = exec.mock.calls[0][0];
|
|
35
|
+
expect(cmd).toContain('tmux new-session');
|
|
36
|
+
expect(cmd).toContain('tlc-build-42');
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
it('sanitizes session names', () => {
|
|
40
|
+
exec.mockReturnValue('');
|
|
41
|
+
createSession('bad session/name!', { exec });
|
|
42
|
+
const cmd = exec.mock.calls[0][0];
|
|
43
|
+
expect(cmd).not.toContain('/');
|
|
44
|
+
expect(cmd).not.toContain('!');
|
|
45
|
+
});
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
describe('addPane', () => {
|
|
49
|
+
it('splits correctly for 2 panes (vertical)', () => {
|
|
50
|
+
exec.mockReturnValue('');
|
|
51
|
+
addPane('sess', 'agent-1', 'echo hello', { exec, paneCount: 2 });
|
|
52
|
+
const cmd = exec.mock.calls[0][0];
|
|
53
|
+
expect(cmd).toContain('split-window');
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
it('splits correctly for 4 panes (2x2 grid)', () => {
|
|
57
|
+
exec.mockReturnValue('');
|
|
58
|
+
addPane('sess', 'agent-3', 'echo test', { exec, paneCount: 4, paneIndex: 3 });
|
|
59
|
+
const cmd = exec.mock.calls[0][0];
|
|
60
|
+
expect(cmd).toContain('split-window');
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
it('runs command in the new pane', () => {
|
|
64
|
+
exec.mockReturnValue('');
|
|
65
|
+
addPane('sess', 'agent-1', 'codex exec "do stuff"', { exec, paneCount: 2 });
|
|
66
|
+
const cmds = exec.mock.calls.map(c => c[0]);
|
|
67
|
+
expect(cmds.some(c => c.includes('codex exec'))).toBe(true);
|
|
68
|
+
});
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
describe('capturePane', () => {
|
|
72
|
+
it('returns pane content via tmux capture-pane', () => {
|
|
73
|
+
exec.mockReturnValue('Line 1\nLine 2\nLine 3');
|
|
74
|
+
const content = capturePane('sess', 0, { exec });
|
|
75
|
+
expect(content).toContain('Line 1');
|
|
76
|
+
expect(content).toContain('Line 3');
|
|
77
|
+
const cmd = exec.mock.calls[0][0];
|
|
78
|
+
expect(cmd).toContain('capture-pane');
|
|
79
|
+
expect(cmd).toContain(':.'); // pane target uses .N syntax
|
|
80
|
+
});
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
describe('killSession', () => {
|
|
84
|
+
it('runs tmux kill-session', () => {
|
|
85
|
+
exec.mockReturnValue('');
|
|
86
|
+
killSession('tlc-build-42', { exec });
|
|
87
|
+
const cmd = exec.mock.calls[0][0];
|
|
88
|
+
expect(cmd).toContain('kill-session');
|
|
89
|
+
expect(cmd).toContain('tlc-build-42');
|
|
90
|
+
});
|
|
91
|
+
});
|
|
92
|
+
|
|
93
|
+
describe('buildLayout', () => {
|
|
94
|
+
it('returns vertical for 2 agents', () => {
|
|
95
|
+
const layout = buildLayout(2);
|
|
96
|
+
expect(layout.type).toBe('vertical');
|
|
97
|
+
});
|
|
98
|
+
|
|
99
|
+
it('returns grid for 4 agents', () => {
|
|
100
|
+
const layout = buildLayout(4);
|
|
101
|
+
expect(layout.type).toBe('grid');
|
|
102
|
+
});
|
|
103
|
+
|
|
104
|
+
it('returns tiled for >4 agents', () => {
|
|
105
|
+
const layout = buildLayout(6);
|
|
106
|
+
expect(layout.type).toBe('tiled');
|
|
107
|
+
});
|
|
108
|
+
});
|
|
109
|
+
});
|
|
@@ -0,0 +1,132 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Worktree Manager
|
|
3
|
+
* Creates and manages git worktrees for parallel agent work.
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Sanitize a worktree name: spaces become hyphens, all other non-alphanumeric
|
|
8
|
+
* non-hyphen characters are stripped, then multiple hyphens are collapsed.
|
|
9
|
+
* @param {string} name
|
|
10
|
+
* @returns {string}
|
|
11
|
+
*/
|
|
12
|
+
export function sanitizeName(name) {
|
|
13
|
+
return name
|
|
14
|
+
.replace(/ /g, '-')
|
|
15
|
+
.replace(/[^a-zA-Z0-9-]/g, '')
|
|
16
|
+
.replace(/-{2,}/g, '-');
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* Create a git worktree for the given name.
|
|
21
|
+
* @param {string} name - Worktree name (will be sanitized)
|
|
22
|
+
* @param {{ exec: Function, maxConcurrent?: number }} options
|
|
23
|
+
* @returns {{ name: string, branch: string, path: string }}
|
|
24
|
+
*/
|
|
25
|
+
export function createWorktree(name, { exec, maxConcurrent } = {}) {
|
|
26
|
+
const safe = sanitizeName(name);
|
|
27
|
+
const limit = maxConcurrent != null ? maxConcurrent : 4;
|
|
28
|
+
|
|
29
|
+
// Only check the concurrent limit when explicitly requested (maxConcurrent passed)
|
|
30
|
+
if (maxConcurrent != null) {
|
|
31
|
+
const listOutput = exec('git worktree list');
|
|
32
|
+
// Exclude the primary checkout (first line) — only count auxiliary worktrees
|
|
33
|
+
const currentCount = listOutput
|
|
34
|
+
.split('\n')
|
|
35
|
+
.filter((line) => line.trim() !== '').length - 1;
|
|
36
|
+
|
|
37
|
+
if (currentCount >= limit) {
|
|
38
|
+
throw new Error(`Max concurrent worktrees (${limit}) reached`);
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
const branch = `worktree-${safe}`;
|
|
43
|
+
const path = `.claude/worktrees/${safe}`;
|
|
44
|
+
|
|
45
|
+
exec(`git worktree add -b ${branch} ${path}`);
|
|
46
|
+
|
|
47
|
+
return { name: safe, branch, path };
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* List active worktrees under .claude/worktrees/.
|
|
52
|
+
* @param {{ exec: Function }} options
|
|
53
|
+
* @returns {Array<{ name: string, branch: string, path: string }>}
|
|
54
|
+
*/
|
|
55
|
+
export function listWorktrees({ exec } = {}) {
|
|
56
|
+
const output = exec('git worktree list');
|
|
57
|
+
return output
|
|
58
|
+
.split('\n')
|
|
59
|
+
.filter((line) => line.includes('.claude/worktrees/'))
|
|
60
|
+
.map((line) => {
|
|
61
|
+
// Format: /repo/.claude/worktrees/task-1 abc1234 [worktree-task-1]
|
|
62
|
+
const parts = line.trim().split(/\s+/);
|
|
63
|
+
const worktreePath = parts[0];
|
|
64
|
+
const branchMatch = line.match(/\[([^\]]+)\]/);
|
|
65
|
+
const branch = branchMatch ? branchMatch[1] : '';
|
|
66
|
+
const namePart = worktreePath.split('.claude/worktrees/')[1];
|
|
67
|
+
return { name: namePart, branch, path: worktreePath };
|
|
68
|
+
});
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/**
|
|
72
|
+
* Merge a worktree branch into targetBranch, then remove the worktree.
|
|
73
|
+
* On conflict, returns { merged: false, conflict: true } without removing.
|
|
74
|
+
* @param {string} name - Worktree name (already sanitized)
|
|
75
|
+
* @param {string} targetBranch
|
|
76
|
+
* @param {{ exec: Function }} options
|
|
77
|
+
* @returns {{ merged: boolean, conflict?: boolean }}
|
|
78
|
+
*/
|
|
79
|
+
export function mergeWorktree(name, targetBranch, { exec } = {}) {
|
|
80
|
+
const safe = sanitizeName(name);
|
|
81
|
+
const branch = `worktree-${safe}`;
|
|
82
|
+
const path = `.claude/worktrees/${safe}`;
|
|
83
|
+
|
|
84
|
+
try {
|
|
85
|
+
exec(`git checkout ${targetBranch}`);
|
|
86
|
+
exec(`git merge ${branch}`);
|
|
87
|
+
} catch (_err) {
|
|
88
|
+
return { merged: false, conflict: true };
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
exec(`git worktree remove ${path}`);
|
|
92
|
+
exec(`git branch -D ${branch}`);
|
|
93
|
+
|
|
94
|
+
return { merged: true };
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/**
|
|
98
|
+
* Forcefully remove a worktree directory and its tracking branch.
|
|
99
|
+
* @param {string} name - Worktree name (already sanitized)
|
|
100
|
+
* @param {{ exec: Function }} options
|
|
101
|
+
*/
|
|
102
|
+
export function removeWorktree(name, { exec } = {}) {
|
|
103
|
+
const safe = sanitizeName(name);
|
|
104
|
+
const branch = `worktree-${safe}`;
|
|
105
|
+
const path = `.claude/worktrees/${safe}`;
|
|
106
|
+
|
|
107
|
+
exec(`git worktree remove --force ${path}`);
|
|
108
|
+
exec(`git branch -D ${branch}`);
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
/**
|
|
112
|
+
* Remove worktrees whose last commit is older than maxAge milliseconds.
|
|
113
|
+
* @param {{ exec: Function, maxAge?: number }} options
|
|
114
|
+
* @returns {string[]} Names of removed worktrees
|
|
115
|
+
*/
|
|
116
|
+
export function cleanupStale({ exec, maxAge = 2 * 3600_000 } = {}) {
|
|
117
|
+
const worktrees = listWorktrees({ exec });
|
|
118
|
+
const removed = [];
|
|
119
|
+
const now = Date.now();
|
|
120
|
+
|
|
121
|
+
for (const wt of worktrees) {
|
|
122
|
+
const lastCommitStr = exec(`git log -1 --format=%cI ${wt.branch}`).trim();
|
|
123
|
+
const lastCommitTime = new Date(lastCommitStr).getTime();
|
|
124
|
+
|
|
125
|
+
if (now - lastCommitTime > maxAge) {
|
|
126
|
+
removeWorktree(wt.name, { exec });
|
|
127
|
+
removed.push(wt.name);
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
return removed;
|
|
132
|
+
}
|
|
@@ -0,0 +1,129 @@
|
|
|
1
|
+
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
|
2
|
+
import {
|
|
3
|
+
createWorktree,
|
|
4
|
+
listWorktrees,
|
|
5
|
+
mergeWorktree,
|
|
6
|
+
removeWorktree,
|
|
7
|
+
cleanupStale,
|
|
8
|
+
sanitizeName,
|
|
9
|
+
} from './worktree-manager.js';
|
|
10
|
+
|
|
11
|
+
describe('worktree-manager', () => {
|
|
12
|
+
let exec;
|
|
13
|
+
|
|
14
|
+
beforeEach(() => {
|
|
15
|
+
exec = vi.fn(() => '');
|
|
16
|
+
});
|
|
17
|
+
|
|
18
|
+
describe('sanitizeName', () => {
|
|
19
|
+
it('passes through alphanumeric and hyphens', () => {
|
|
20
|
+
expect(sanitizeName('phase-42-task-1')).toBe('phase-42-task-1');
|
|
21
|
+
});
|
|
22
|
+
|
|
23
|
+
it('strips special characters', () => {
|
|
24
|
+
expect(sanitizeName('my task/name@v2!')).toBe('my-tasknamev2');
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
it('collapses multiple hyphens', () => {
|
|
28
|
+
expect(sanitizeName('a--b---c')).toBe('a-b-c');
|
|
29
|
+
});
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
describe('createWorktree', () => {
|
|
33
|
+
it('creates directory and branch', () => {
|
|
34
|
+
exec.mockReturnValue('');
|
|
35
|
+
const result = createWorktree('phase-42-task-1', { exec });
|
|
36
|
+
expect(result.name).toBe('phase-42-task-1');
|
|
37
|
+
expect(result.branch).toBe('worktree-phase-42-task-1');
|
|
38
|
+
expect(result.path).toContain('.claude/worktrees/phase-42-task-1');
|
|
39
|
+
expect(exec).toHaveBeenCalled();
|
|
40
|
+
const cmd = exec.mock.calls[0][0];
|
|
41
|
+
expect(cmd).toContain('git worktree add');
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
it('rejects when at max concurrent limit', () => {
|
|
45
|
+
// 5 lines = 1 primary + 4 auxiliary worktrees → at limit of 4
|
|
46
|
+
exec.mockReturnValue('/repo abc [main]\nworktree-a\nworktree-b\nworktree-c\nworktree-d\n');
|
|
47
|
+
expect(() => createWorktree('task-5', { exec, maxConcurrent: 4 }))
|
|
48
|
+
.toThrow(/max/i);
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
it('sanitizes names', () => {
|
|
52
|
+
exec.mockReturnValue('');
|
|
53
|
+
const result = createWorktree('bad name/here!', { exec });
|
|
54
|
+
expect(result.name).toBe('bad-namehere');
|
|
55
|
+
});
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
describe('listWorktrees', () => {
|
|
59
|
+
it('returns active worktrees with branch and path', () => {
|
|
60
|
+
exec.mockReturnValue(
|
|
61
|
+
'/repo/.claude/worktrees/task-1 abc1234 [worktree-task-1]\n' +
|
|
62
|
+
'/repo/.claude/worktrees/task-2 def5678 [worktree-task-2]\n'
|
|
63
|
+
);
|
|
64
|
+
const list = listWorktrees({ exec });
|
|
65
|
+
expect(list).toHaveLength(2);
|
|
66
|
+
expect(list[0].name).toBe('task-1');
|
|
67
|
+
expect(list[0].branch).toBe('worktree-task-1');
|
|
68
|
+
expect(list[0].path).toContain('task-1');
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
it('returns empty array when no worktrees', () => {
|
|
72
|
+
exec.mockReturnValue('/repo abc1234 [main]\n');
|
|
73
|
+
const list = listWorktrees({ exec });
|
|
74
|
+
expect(list).toHaveLength(0);
|
|
75
|
+
});
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
describe('mergeWorktree', () => {
|
|
79
|
+
it('merges branch and cleans up', () => {
|
|
80
|
+
exec.mockReturnValue('');
|
|
81
|
+
const result = mergeWorktree('task-1', 'main', { exec });
|
|
82
|
+
expect(result.merged).toBe(true);
|
|
83
|
+
const cmds = exec.mock.calls.map(c => c[0]);
|
|
84
|
+
expect(cmds.some(c => c.includes('git merge'))).toBe(true);
|
|
85
|
+
expect(cmds.some(c => c.includes('git worktree remove'))).toBe(true);
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
it('reports conflict without destroying worktree', () => {
|
|
89
|
+
exec.mockImplementation((cmd) => {
|
|
90
|
+
if (cmd.includes('git merge')) throw new Error('CONFLICT');
|
|
91
|
+
return '';
|
|
92
|
+
});
|
|
93
|
+
const result = mergeWorktree('task-1', 'main', { exec });
|
|
94
|
+
expect(result.merged).toBe(false);
|
|
95
|
+
expect(result.conflict).toBe(true);
|
|
96
|
+
// Should NOT have called worktree remove
|
|
97
|
+
const cmds = exec.mock.calls.map(c => c[0]);
|
|
98
|
+
expect(cmds.some(c => c.includes('git worktree remove'))).toBe(false);
|
|
99
|
+
});
|
|
100
|
+
});
|
|
101
|
+
|
|
102
|
+
describe('removeWorktree', () => {
|
|
103
|
+
it('removes both directory and branch', () => {
|
|
104
|
+
exec.mockReturnValue('');
|
|
105
|
+
removeWorktree('task-1', { exec });
|
|
106
|
+
const cmds = exec.mock.calls.map(c => c[0]);
|
|
107
|
+
expect(cmds.some(c => c.includes('git worktree remove'))).toBe(true);
|
|
108
|
+
expect(cmds.some(c => c.includes('git branch -D'))).toBe(true);
|
|
109
|
+
});
|
|
110
|
+
});
|
|
111
|
+
|
|
112
|
+
describe('cleanupStale', () => {
|
|
113
|
+
it('removes only worktrees past timeout', () => {
|
|
114
|
+
const oldDate = new Date(Date.now() - 3 * 3600_000).toISOString(); // 3h ago
|
|
115
|
+
const freshDate = new Date(Date.now() - 30 * 60_000).toISOString(); // 30min ago
|
|
116
|
+
exec.mockImplementation((cmd) => {
|
|
117
|
+
if (cmd.includes('worktree list')) {
|
|
118
|
+
return '/repo/.claude/worktrees/old-task abc [worktree-old-task]\n/repo/.claude/worktrees/new-task def [worktree-new-task]\n';
|
|
119
|
+
}
|
|
120
|
+
if (cmd.includes('log') && cmd.includes('old-task')) return oldDate;
|
|
121
|
+
if (cmd.includes('log') && cmd.includes('new-task')) return freshDate;
|
|
122
|
+
return '';
|
|
123
|
+
});
|
|
124
|
+
const removed = cleanupStale({ exec, maxAge: 2 * 3600_000 });
|
|
125
|
+
expect(removed).toContain('old-task');
|
|
126
|
+
expect(removed).not.toContain('new-task');
|
|
127
|
+
});
|
|
128
|
+
});
|
|
129
|
+
});
|