ccmanager 4.3.0 → 4.3.2
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/README.md +12 -0
- package/dist/components/App.js +46 -9
- package/dist/components/DeleteWorktree.js +2 -13
- package/dist/components/Menu.js +22 -16
- package/dist/components/Menu.test.js +37 -1
- package/dist/components/NewWorktree.js +66 -10
- package/dist/components/NewWorktree.test.js +37 -0
- package/dist/components/SessionActions.d.ts +18 -2
- package/dist/components/SessionActions.js +19 -17
- package/dist/components/SessionActions.test.d.ts +1 -0
- package/dist/components/SessionActions.test.js +94 -0
- package/dist/services/worktreeService.d.ts +28 -1
- package/dist/services/worktreeService.js +110 -1
- package/dist/services/worktreeService.test.js +227 -4
- package/dist/types/index.d.ts +29 -2
- package/dist/utils/worktreeInclude.d.ts +33 -0
- package/dist/utils/worktreeInclude.js +100 -0
- package/dist/utils/worktreeInclude.test.d.ts +1 -0
- package/dist/utils/worktreeInclude.test.js +91 -0
- package/dist/utils/worktreeUtils.d.ts +13 -0
- package/dist/utils/worktreeUtils.js +23 -0
- package/dist/utils/worktreeUtils.test.js +20 -1
- package/package.json +6 -6
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
import { execSync } from 'child_process';
|
|
2
|
+
import { existsSync, statSync, mkdirSync, cpSync } from 'fs';
|
|
3
|
+
import path from 'path';
|
|
4
|
+
import { logger } from './logger.js';
|
|
5
|
+
/**
|
|
6
|
+
* Filename for the convention shared across worktree-aware tools (Claude Code,
|
|
7
|
+
* Conductor, OpenAI Codex, git-worktreeinclude, worktrunk): a gitignore-syntax
|
|
8
|
+
* file at the repository root that lists gitignored files to carry into every
|
|
9
|
+
* new worktree.
|
|
10
|
+
*/
|
|
11
|
+
export const WORKTREE_INCLUDE_FILENAME = '.worktreeinclude';
|
|
12
|
+
/**
|
|
13
|
+
* Resolves which files a `.worktreeinclude` file selects, relative to `gitRoot`.
|
|
14
|
+
*
|
|
15
|
+
* A file is selected only when both hold:
|
|
16
|
+
* - it matches a pattern in `.worktreeinclude` (gitignore syntax: comments,
|
|
17
|
+
* negation with `!`, anchoring with `/`, `**` globs)
|
|
18
|
+
* - Git already ignores it (nested `.gitignore` files, `.git/info/exclude`,
|
|
19
|
+
* and `core.excludesfile` all apply)
|
|
20
|
+
*
|
|
21
|
+
* This mirrors the safety rule every tool that supports `.worktreeinclude`
|
|
22
|
+
* documents: listing a pattern never makes a tracked file eligible, and it
|
|
23
|
+
* never makes an otherwise-untracked-but-not-ignored file eligible either.
|
|
24
|
+
*
|
|
25
|
+
* @param gitRoot - Absolute path to the main checkout (repository root)
|
|
26
|
+
* @returns Repository-relative paths (forward-slash separated, as Git reports them)
|
|
27
|
+
*/
|
|
28
|
+
export function resolveWorktreeIncludeFiles(gitRoot) {
|
|
29
|
+
const includeFilePath = path.join(gitRoot, WORKTREE_INCLUDE_FILENAME);
|
|
30
|
+
if (!existsSync(includeFilePath) || !statSync(includeFilePath).isFile()) {
|
|
31
|
+
return [];
|
|
32
|
+
}
|
|
33
|
+
let candidatesOutput;
|
|
34
|
+
try {
|
|
35
|
+
// --exclude-from applies ONLY .worktreeinclude's own patterns (no
|
|
36
|
+
// --exclude-standard), so this lists untracked files matching those
|
|
37
|
+
// patterns regardless of whether the repository's real .gitignore
|
|
38
|
+
// covers them.
|
|
39
|
+
candidatesOutput = execSync(`git ls-files --others --ignored --exclude-from="${WORKTREE_INCLUDE_FILENAME}" -z`, { cwd: gitRoot, encoding: 'utf8', stdio: ['pipe', 'pipe', 'pipe'] });
|
|
40
|
+
}
|
|
41
|
+
catch (error) {
|
|
42
|
+
logger.error('Failed to resolve .worktreeinclude candidates', {
|
|
43
|
+
gitRoot,
|
|
44
|
+
error: String(error),
|
|
45
|
+
});
|
|
46
|
+
return [];
|
|
47
|
+
}
|
|
48
|
+
const candidates = candidatesOutput
|
|
49
|
+
.split('\0')
|
|
50
|
+
.filter(entry => entry.length > 0);
|
|
51
|
+
if (candidates.length === 0) {
|
|
52
|
+
return [];
|
|
53
|
+
}
|
|
54
|
+
// Confirm each candidate against the repository's real ignore rules.
|
|
55
|
+
// git check-ignore --stdin echoes back only the paths it is asked about
|
|
56
|
+
// that ARE ignored, so this is the second half of the intersection.
|
|
57
|
+
let ignoredOutput;
|
|
58
|
+
try {
|
|
59
|
+
ignoredOutput = execSync('git check-ignore --stdin -z', {
|
|
60
|
+
cwd: gitRoot,
|
|
61
|
+
encoding: 'utf8',
|
|
62
|
+
stdio: ['pipe', 'pipe', 'pipe'],
|
|
63
|
+
input: candidates.join('\0') + '\0',
|
|
64
|
+
});
|
|
65
|
+
}
|
|
66
|
+
catch (error) {
|
|
67
|
+
// Exit code 1 (no matches) surfaces as a thrown error; anything already
|
|
68
|
+
// written to stdout before that is still the correct partial result.
|
|
69
|
+
const execError = error;
|
|
70
|
+
ignoredOutput = execError.stdout ?? '';
|
|
71
|
+
}
|
|
72
|
+
return ignoredOutput.split('\0').filter(entry => entry.length > 0);
|
|
73
|
+
}
|
|
74
|
+
/**
|
|
75
|
+
* Copies the files a `.worktreeinclude` file selects from the main checkout
|
|
76
|
+
* into a freshly created worktree. No-ops when no `.worktreeinclude` file
|
|
77
|
+
* exists. Never overwrites a file that already exists at the destination.
|
|
78
|
+
*
|
|
79
|
+
* @param gitRoot - Absolute path to the main checkout (repository root)
|
|
80
|
+
* @param targetWorktreePath - Absolute path to the newly created worktree
|
|
81
|
+
*/
|
|
82
|
+
export function copyWorktreeIncludeFiles(gitRoot, targetWorktreePath) {
|
|
83
|
+
const relativePaths = resolveWorktreeIncludeFiles(gitRoot);
|
|
84
|
+
for (const relativePath of relativePaths) {
|
|
85
|
+
const sourcePath = path.join(gitRoot, relativePath);
|
|
86
|
+
const targetPath = path.join(targetWorktreePath, relativePath);
|
|
87
|
+
if (!existsSync(sourcePath)) {
|
|
88
|
+
continue;
|
|
89
|
+
}
|
|
90
|
+
if (existsSync(targetPath)) {
|
|
91
|
+
logger.warn('Skipping .worktreeinclude copy, destination already exists', {
|
|
92
|
+
relativePath,
|
|
93
|
+
targetPath,
|
|
94
|
+
});
|
|
95
|
+
continue;
|
|
96
|
+
}
|
|
97
|
+
mkdirSync(path.dirname(targetPath), { recursive: true });
|
|
98
|
+
cpSync(sourcePath, targetPath, { recursive: true, preserveTimestamps: true });
|
|
99
|
+
}
|
|
100
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
import { describe, it, expect, beforeEach, afterAll } from 'vitest';
|
|
2
|
+
import { execSync } from 'child_process';
|
|
3
|
+
import path from 'path';
|
|
4
|
+
import fs from 'fs';
|
|
5
|
+
import os from 'os';
|
|
6
|
+
import { resolveWorktreeIncludeFiles, copyWorktreeIncludeFiles, } from './worktreeInclude.js';
|
|
7
|
+
describe('worktreeInclude', () => {
|
|
8
|
+
const testDir = fs.realpathSync(fs.mkdtempSync(path.join(os.tmpdir(), 'ccmanager-worktreeinclude-test-')));
|
|
9
|
+
let repoCount = 0;
|
|
10
|
+
let gitRoot;
|
|
11
|
+
beforeEach(() => {
|
|
12
|
+
repoCount += 1;
|
|
13
|
+
gitRoot = path.join(testDir, `repo-${repoCount}`);
|
|
14
|
+
fs.mkdirSync(gitRoot, { recursive: true });
|
|
15
|
+
execSync('git init', { cwd: gitRoot });
|
|
16
|
+
execSync('git config user.email "test@test.com"', { cwd: gitRoot });
|
|
17
|
+
execSync('git config user.name "Test User"', { cwd: gitRoot });
|
|
18
|
+
fs.writeFileSync(path.join(gitRoot, 'README.md'), '# repo');
|
|
19
|
+
execSync('git add README.md', { cwd: gitRoot });
|
|
20
|
+
execSync('git commit -m "initial commit"', { cwd: gitRoot });
|
|
21
|
+
});
|
|
22
|
+
afterAll(() => {
|
|
23
|
+
fs.rmSync(testDir, { recursive: true, force: true });
|
|
24
|
+
});
|
|
25
|
+
it('returns an empty list when no .worktreeinclude file exists', () => {
|
|
26
|
+
fs.writeFileSync(path.join(gitRoot, '.env'), 'SECRET=1');
|
|
27
|
+
expect(resolveWorktreeIncludeFiles(gitRoot)).toEqual([]);
|
|
28
|
+
});
|
|
29
|
+
it('selects a file that matches .worktreeinclude and is gitignored', () => {
|
|
30
|
+
fs.writeFileSync(path.join(gitRoot, '.gitignore'), '.env\n');
|
|
31
|
+
fs.writeFileSync(path.join(gitRoot, '.worktreeinclude'), '.env\n');
|
|
32
|
+
fs.writeFileSync(path.join(gitRoot, '.env'), 'SECRET=1');
|
|
33
|
+
expect(resolveWorktreeIncludeFiles(gitRoot)).toEqual(['.env']);
|
|
34
|
+
});
|
|
35
|
+
it('excludes a file that matches .worktreeinclude but is not gitignored', () => {
|
|
36
|
+
fs.writeFileSync(path.join(gitRoot, '.worktreeinclude'), 'notes.txt\n');
|
|
37
|
+
fs.writeFileSync(path.join(gitRoot, 'notes.txt'), 'not ignored');
|
|
38
|
+
expect(resolveWorktreeIncludeFiles(gitRoot)).toEqual([]);
|
|
39
|
+
});
|
|
40
|
+
it('excludes a tracked file even when it matches .worktreeinclude', () => {
|
|
41
|
+
fs.writeFileSync(path.join(gitRoot, '.gitignore'), 'tracked.env\n');
|
|
42
|
+
fs.writeFileSync(path.join(gitRoot, '.worktreeinclude'), 'tracked.env\n');
|
|
43
|
+
fs.writeFileSync(path.join(gitRoot, 'tracked.env'), 'SECRET=1');
|
|
44
|
+
execSync('git add -f tracked.env', { cwd: gitRoot });
|
|
45
|
+
execSync('git commit -m "track tracked.env"', { cwd: gitRoot });
|
|
46
|
+
expect(resolveWorktreeIncludeFiles(gitRoot)).toEqual([]);
|
|
47
|
+
});
|
|
48
|
+
it('resolves every file under a directory glob pattern', () => {
|
|
49
|
+
fs.writeFileSync(path.join(gitRoot, '.gitignore'), 'certs/\n');
|
|
50
|
+
fs.writeFileSync(path.join(gitRoot, '.worktreeinclude'), 'certs/local/**\n');
|
|
51
|
+
fs.mkdirSync(path.join(gitRoot, 'certs', 'local', 'nested'), {
|
|
52
|
+
recursive: true,
|
|
53
|
+
});
|
|
54
|
+
fs.writeFileSync(path.join(gitRoot, 'certs', 'local', 'cert.pem'), 'cert');
|
|
55
|
+
fs.writeFileSync(path.join(gitRoot, 'certs', 'local', 'nested', 'key.pem'), 'key');
|
|
56
|
+
expect(resolveWorktreeIncludeFiles(gitRoot).sort()).toEqual([
|
|
57
|
+
'certs/local/cert.pem',
|
|
58
|
+
'certs/local/nested/key.pem',
|
|
59
|
+
]);
|
|
60
|
+
});
|
|
61
|
+
describe('copyWorktreeIncludeFiles', () => {
|
|
62
|
+
it('copies selected files into the target worktree, recreating nested directories', () => {
|
|
63
|
+
fs.writeFileSync(path.join(gitRoot, '.gitignore'), '.env\ncerts/\n');
|
|
64
|
+
fs.writeFileSync(path.join(gitRoot, '.worktreeinclude'), '.env\ncerts/local/**\n');
|
|
65
|
+
fs.writeFileSync(path.join(gitRoot, '.env'), 'SECRET=1');
|
|
66
|
+
fs.mkdirSync(path.join(gitRoot, 'certs', 'local'), { recursive: true });
|
|
67
|
+
fs.writeFileSync(path.join(gitRoot, 'certs', 'local', 'cert.pem'), 'cert');
|
|
68
|
+
const targetWorktreePath = path.join(testDir, `target-${repoCount}`);
|
|
69
|
+
fs.mkdirSync(targetWorktreePath, { recursive: true });
|
|
70
|
+
copyWorktreeIncludeFiles(gitRoot, targetWorktreePath);
|
|
71
|
+
expect(fs.readFileSync(path.join(targetWorktreePath, '.env'), 'utf8')).toBe('SECRET=1');
|
|
72
|
+
expect(fs.readFileSync(path.join(targetWorktreePath, 'certs', 'local', 'cert.pem'), 'utf8')).toBe('cert');
|
|
73
|
+
});
|
|
74
|
+
it('does not overwrite a file that already exists at the destination', () => {
|
|
75
|
+
fs.writeFileSync(path.join(gitRoot, '.gitignore'), '.env\n');
|
|
76
|
+
fs.writeFileSync(path.join(gitRoot, '.worktreeinclude'), '.env\n');
|
|
77
|
+
fs.writeFileSync(path.join(gitRoot, '.env'), 'SOURCE');
|
|
78
|
+
const targetWorktreePath = path.join(testDir, `target-${repoCount}`);
|
|
79
|
+
fs.mkdirSync(targetWorktreePath, { recursive: true });
|
|
80
|
+
fs.writeFileSync(path.join(targetWorktreePath, '.env'), 'EXISTING');
|
|
81
|
+
copyWorktreeIncludeFiles(gitRoot, targetWorktreePath);
|
|
82
|
+
expect(fs.readFileSync(path.join(targetWorktreePath, '.env'), 'utf8')).toBe('EXISTING');
|
|
83
|
+
});
|
|
84
|
+
it('is a no-op when no .worktreeinclude file exists', () => {
|
|
85
|
+
const targetWorktreePath = path.join(testDir, `target-${repoCount}`);
|
|
86
|
+
fs.mkdirSync(targetWorktreePath, { recursive: true });
|
|
87
|
+
expect(() => copyWorktreeIncludeFiles(gitRoot, targetWorktreePath)).not.toThrow();
|
|
88
|
+
expect(fs.readdirSync(targetWorktreePath)).toEqual([]);
|
|
89
|
+
});
|
|
90
|
+
});
|
|
91
|
+
});
|
|
@@ -78,3 +78,16 @@ export declare function calculateColumnPositions(items: SessionItem[]): {
|
|
|
78
78
|
* Assembles the final worktree label with proper column alignment
|
|
79
79
|
*/
|
|
80
80
|
export declare function assembleSessionLabel(item: SessionItem, columns: ReturnType<typeof calculateColumnPositions>): string;
|
|
81
|
+
/**
|
|
82
|
+
* Whether a worktree may be deleted by CCManager.
|
|
83
|
+
*
|
|
84
|
+
* Two worktrees are off limits:
|
|
85
|
+
* - the main worktree, because git refuses to remove it and the repository
|
|
86
|
+
* would be left without a checkout;
|
|
87
|
+
* - the worktree that contains the current working directory, because removing
|
|
88
|
+
* the directory CCManager is running in breaks the running process.
|
|
89
|
+
*
|
|
90
|
+
* Single source of truth for the rule, shared by the multi-select delete screen
|
|
91
|
+
* and the per-row delete action.
|
|
92
|
+
*/
|
|
93
|
+
export declare function isDeletableWorktree(worktree: Pick<Worktree, 'path' | 'isMainWorktree'>, cwd?: string): boolean;
|
|
@@ -340,3 +340,26 @@ export function assembleSessionLabel(item, columns) {
|
|
|
340
340
|
}
|
|
341
341
|
return label;
|
|
342
342
|
}
|
|
343
|
+
/**
|
|
344
|
+
* Whether a worktree may be deleted by CCManager.
|
|
345
|
+
*
|
|
346
|
+
* Two worktrees are off limits:
|
|
347
|
+
* - the main worktree, because git refuses to remove it and the repository
|
|
348
|
+
* would be left without a checkout;
|
|
349
|
+
* - the worktree that contains the current working directory, because removing
|
|
350
|
+
* the directory CCManager is running in breaks the running process.
|
|
351
|
+
*
|
|
352
|
+
* Single source of truth for the rule, shared by the multi-select delete screen
|
|
353
|
+
* and the per-row delete action.
|
|
354
|
+
*/
|
|
355
|
+
export function isDeletableWorktree(worktree, cwd = process.cwd()) {
|
|
356
|
+
if (worktree.isMainWorktree)
|
|
357
|
+
return false;
|
|
358
|
+
const resolvedCwd = path.resolve(cwd);
|
|
359
|
+
const resolvedPath = path.resolve(worktree.path);
|
|
360
|
+
if (resolvedCwd === resolvedPath ||
|
|
361
|
+
resolvedCwd.startsWith(resolvedPath + path.sep)) {
|
|
362
|
+
return false;
|
|
363
|
+
}
|
|
364
|
+
return true;
|
|
365
|
+
}
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
|
2
|
-
import { generateWorktreeDirectory, extractBranchParts, truncateString, prepareSessionItems, calculateColumnPositions, assembleSessionLabel, } from './worktreeUtils.js';
|
|
2
|
+
import { generateWorktreeDirectory, extractBranchParts, truncateString, prepareSessionItems, calculateColumnPositions, assembleSessionLabel, isDeletableWorktree, } from './worktreeUtils.js';
|
|
3
3
|
import { execSync } from 'child_process';
|
|
4
4
|
import { Mutex, createInitialSessionStateData } from './mutex.js';
|
|
5
5
|
import { createStateDetector } from '../services/stateDetector/index.js';
|
|
@@ -325,3 +325,22 @@ describe('column alignment', () => {
|
|
|
325
325
|
expect(plain.indexOf('+10 -5')).toBe(21); // Should start at column 21
|
|
326
326
|
});
|
|
327
327
|
});
|
|
328
|
+
describe('isDeletableWorktree', () => {
|
|
329
|
+
it('should reject the main worktree', () => {
|
|
330
|
+
expect(isDeletableWorktree({ path: '/repo', isMainWorktree: true }, '/somewhere/else')).toBe(false);
|
|
331
|
+
});
|
|
332
|
+
it('should reject the worktree holding the current working directory', () => {
|
|
333
|
+
expect(isDeletableWorktree({ path: '/repo/worktrees/feature', isMainWorktree: false }, '/repo/worktrees/feature')).toBe(false);
|
|
334
|
+
});
|
|
335
|
+
it('should reject a worktree that is an ancestor of the current working directory', () => {
|
|
336
|
+
expect(isDeletableWorktree({ path: '/repo/worktrees/feature', isMainWorktree: false }, '/repo/worktrees/feature/src/components')).toBe(false);
|
|
337
|
+
});
|
|
338
|
+
it('should accept a sibling worktree with a shared path prefix', () => {
|
|
339
|
+
// '/repo/worktrees/feature-2' starts with the '/repo/worktrees/feature'
|
|
340
|
+
// string but is a different directory, so it stays deletable.
|
|
341
|
+
expect(isDeletableWorktree({ path: '/repo/worktrees/feature', isMainWorktree: false }, '/repo/worktrees/feature-2')).toBe(true);
|
|
342
|
+
});
|
|
343
|
+
it('should accept an unrelated linked worktree', () => {
|
|
344
|
+
expect(isDeletableWorktree({ path: '/repo/worktrees/feature', isMainWorktree: false }, '/repo')).toBe(true);
|
|
345
|
+
});
|
|
346
|
+
});
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "ccmanager",
|
|
3
|
-
"version": "4.3.
|
|
3
|
+
"version": "4.3.2",
|
|
4
4
|
"description": "TUI application for managing multiple Claude Code sessions across Git worktrees",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"author": "Kodai Kabasawa",
|
|
@@ -41,11 +41,11 @@
|
|
|
41
41
|
"bin"
|
|
42
42
|
],
|
|
43
43
|
"optionalDependencies": {
|
|
44
|
-
"@kodaikabasawa/ccmanager-darwin-arm64": "4.3.
|
|
45
|
-
"@kodaikabasawa/ccmanager-darwin-x64": "4.3.
|
|
46
|
-
"@kodaikabasawa/ccmanager-linux-arm64": "4.3.
|
|
47
|
-
"@kodaikabasawa/ccmanager-linux-x64": "4.3.
|
|
48
|
-
"@kodaikabasawa/ccmanager-win32-x64": "4.3.
|
|
44
|
+
"@kodaikabasawa/ccmanager-darwin-arm64": "4.3.2",
|
|
45
|
+
"@kodaikabasawa/ccmanager-darwin-x64": "4.3.2",
|
|
46
|
+
"@kodaikabasawa/ccmanager-linux-arm64": "4.3.2",
|
|
47
|
+
"@kodaikabasawa/ccmanager-linux-x64": "4.3.2",
|
|
48
|
+
"@kodaikabasawa/ccmanager-win32-x64": "4.3.2"
|
|
49
49
|
},
|
|
50
50
|
"devDependencies": {
|
|
51
51
|
"@eslint/js": "^9.28.0",
|