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.
@@ -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.0",
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.0",
45
- "@kodaikabasawa/ccmanager-darwin-x64": "4.3.0",
46
- "@kodaikabasawa/ccmanager-linux-arm64": "4.3.0",
47
- "@kodaikabasawa/ccmanager-linux-x64": "4.3.0",
48
- "@kodaikabasawa/ccmanager-win32-x64": "4.3.0"
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",