ccmanager 4.3.1 → 4.4.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 (51) hide show
  1. package/README.md +54 -0
  2. package/dist/components/App.js +92 -30
  3. package/dist/components/App.test.js +86 -0
  4. package/dist/components/Dashboard.js +10 -3
  5. package/dist/components/DeleteWorktree.js +2 -13
  6. package/dist/components/Menu.js +28 -17
  7. package/dist/components/Menu.test.js +37 -1
  8. package/dist/components/RestoreSessions.d.ts +19 -0
  9. package/dist/components/RestoreSessions.js +28 -0
  10. package/dist/components/SessionActions.d.ts +17 -2
  11. package/dist/components/SessionActions.js +19 -17
  12. package/dist/components/SessionActions.test.d.ts +1 -0
  13. package/dist/components/SessionActions.test.js +94 -0
  14. package/dist/hooks/useAvailableLabelWidth.d.ts +6 -0
  15. package/dist/hooks/useAvailableLabelWidth.js +15 -0
  16. package/dist/services/config/globalConfigManager.js +3 -12
  17. package/dist/services/globalSessionOrchestrator.d.ts +7 -0
  18. package/dist/services/globalSessionOrchestrator.js +40 -0
  19. package/dist/services/globalSessionOrchestrator.restore.test.d.ts +1 -0
  20. package/dist/services/globalSessionOrchestrator.restore.test.js +76 -0
  21. package/dist/services/projectManager.js +3 -11
  22. package/dist/services/sessionManager.d.ts +6 -0
  23. package/dist/services/sessionManager.js +16 -0
  24. package/dist/services/sessionRestoreStore.d.ts +75 -0
  25. package/dist/services/sessionRestoreStore.js +138 -0
  26. package/dist/services/sessionRestoreStore.test.d.ts +1 -0
  27. package/dist/services/sessionRestoreStore.test.js +92 -0
  28. package/dist/services/sessionRestorer.d.ts +46 -0
  29. package/dist/services/sessionRestorer.js +123 -0
  30. package/dist/services/sessionRestorer.test.d.ts +1 -0
  31. package/dist/services/sessionRestorer.test.js +163 -0
  32. package/dist/services/worktreeService.d.ts +12 -0
  33. package/dist/services/worktreeService.js +30 -0
  34. package/dist/services/worktreeService.test.js +55 -1
  35. package/dist/types/index.d.ts +3 -2
  36. package/dist/utils/configDir.d.ts +10 -0
  37. package/dist/utils/configDir.js +31 -0
  38. package/dist/utils/errorMessage.d.ts +7 -0
  39. package/dist/utils/errorMessage.js +19 -0
  40. package/dist/utils/filterByQuery.test.js +2 -0
  41. package/dist/utils/gitUtils.d.ts +1 -0
  42. package/dist/utils/gitUtils.js +15 -0
  43. package/dist/utils/hookExecutor.test.js +4 -0
  44. package/dist/utils/worktreeInclude.d.ts +33 -0
  45. package/dist/utils/worktreeInclude.js +100 -0
  46. package/dist/utils/worktreeInclude.test.d.ts +1 -0
  47. package/dist/utils/worktreeInclude.test.js +91 -0
  48. package/dist/utils/worktreeUtils.d.ts +38 -4
  49. package/dist/utils/worktreeUtils.js +76 -17
  50. package/dist/utils/worktreeUtils.test.js +82 -5
  51. package/package.json +6 -6
@@ -1,7 +1,8 @@
1
1
  import { describe, it, expect, beforeEach, vi } from 'vitest';
2
2
  import { WorktreeService } from './worktreeService.js';
3
3
  import { execSync } from 'child_process';
4
- import { existsSync, statSync } from 'fs';
4
+ import { existsSync, statSync, cpSync, mkdirSync } from 'fs';
5
+ import path from 'path';
5
6
  import { configReader } from './config/configReader.js';
6
7
  import { Effect } from 'effect';
7
8
  import { GitError, ProcessError } from '../types/errors.js';
@@ -33,11 +34,20 @@ vi.mock('../utils/hookExecutor.js', () => ({
33
34
  const mockedExecSync = vi.mocked(execSync);
34
35
  const mockedExistsSync = vi.mocked(existsSync);
35
36
  const mockedStatSync = vi.mocked(statSync);
37
+ const mockedCpSync = vi.mocked(cpSync);
38
+ const mockedMkdirSync = vi.mocked(mkdirSync);
36
39
  const mockedGetWorktreeHooks = vi.mocked(configReader.getWorktreeHooks);
37
40
  describe('WorktreeService', () => {
38
41
  let service;
39
42
  beforeEach(() => {
40
43
  vi.clearAllMocks();
44
+ // vi.clearAllMocks() clears call history but not custom implementations,
45
+ // so a test-specific existsSync/statSync mock (e.g. in
46
+ // hasClaudeDirectoryInBranchEffect below) would otherwise leak into
47
+ // every later test in this file. Reset them to the automock default
48
+ // (returns undefined) so each test starts from a clean slate.
49
+ mockedExistsSync.mockReset();
50
+ mockedStatSync.mockReset();
41
51
  // Mock git rev-parse --git-common-dir to return a predictable path
42
52
  mockedExecSync.mockImplementation((cmd, _options) => {
43
53
  if (typeof cmd === 'string' && cmd === 'git rev-parse --git-common-dir') {
@@ -837,6 +847,50 @@ branch refs/heads/feature
837
847
  isMainWorktree: false,
838
848
  });
839
849
  });
850
+ it('should copy .worktreeinclude files from the main checkout into the new worktree', async () => {
851
+ mockedExistsSync.mockImplementation(filePath => {
852
+ const target = String(filePath);
853
+ if (target.endsWith('.worktreeinclude'))
854
+ return true;
855
+ // The source .env lives in the main checkout (gitRoot); the same
856
+ // relative path under the new worktree must not exist yet.
857
+ if (target === path.join('/fake/path', '.env'))
858
+ return true;
859
+ return false;
860
+ });
861
+ mockedStatSync.mockImplementation(() => ({ isFile: () => true }));
862
+ mockedExecSync.mockImplementation((cmd, _options) => {
863
+ if (typeof cmd === 'string') {
864
+ if (cmd === 'git rev-parse --git-common-dir') {
865
+ return '/fake/path/.git\n';
866
+ }
867
+ if (cmd.includes('show-ref --verify --quiet refs/heads/')) {
868
+ throw new Error('Branch not found');
869
+ }
870
+ if (cmd === 'git remote') {
871
+ return 'origin\n';
872
+ }
873
+ if (cmd.includes('show-ref --verify --quiet refs/remotes/')) {
874
+ throw new Error('Remote branch not found');
875
+ }
876
+ if (cmd.includes('git worktree add')) {
877
+ return '';
878
+ }
879
+ if (cmd.includes('git ls-files --others --ignored')) {
880
+ return '.env\0';
881
+ }
882
+ if (cmd === 'git check-ignore --stdin -z') {
883
+ return '.env\0';
884
+ }
885
+ }
886
+ return '';
887
+ });
888
+ const effect = service.createWorktreeEffect('/path/to/worktree', 'new-feature', 'main');
889
+ const result = await Effect.runPromise(effect);
890
+ expect(result.worktree.path).toBe('/path/to/worktree');
891
+ expect(mockedMkdirSync).toHaveBeenCalledWith(path.dirname(path.join('/path/to/worktree', '.env')), { recursive: true });
892
+ expect(mockedCpSync).toHaveBeenCalledWith(path.join('/fake/path', '.env'), path.join('/path/to/worktree', '.env'), { recursive: true, preserveTimestamps: true });
893
+ });
840
894
  it('should create local branch from remote ref when only remote branch exists', async () => {
841
895
  const executedCommands = [];
842
896
  mockedExecSync.mockImplementation((cmd, _options) => {
@@ -40,6 +40,7 @@ export interface Session {
40
40
  stateCheckInterval: NodeJS.Timeout | undefined;
41
41
  isPrimaryCommand: boolean;
42
42
  presetName: string | undefined;
43
+ presetId: string | undefined;
43
44
  detectionStrategy: StateDetectionStrategy | undefined;
44
45
  devcontainerConfig: DevcontainerConfig | undefined;
45
46
  /**
@@ -75,8 +76,8 @@ export type MenuAction = {
75
76
  sessionId: string;
76
77
  } | {
77
78
  type: 'sessionActions';
78
- session: Session;
79
- worktreePath: string;
79
+ worktree: Worktree;
80
+ session?: Session;
80
81
  } | {
81
82
  type: 'deleteWorktree';
82
83
  } | {
@@ -0,0 +1,10 @@
1
+ /**
2
+ * Path of ccmanager's configuration/state directory. Does not touch the
3
+ * filesystem.
4
+ */
5
+ export declare function getConfigDir(): string;
6
+ /**
7
+ * Path of ccmanager's configuration/state directory, creating it first if it
8
+ * does not exist yet.
9
+ */
10
+ export declare function ensureConfigDir(): string;
@@ -0,0 +1,31 @@
1
+ /**
2
+ * @fileoverview Single source of truth for the location of ccmanager's own
3
+ * configuration/state directory (`~/.config/ccmanager`, or the Windows
4
+ * equivalent under APPDATA). Every module that needs to read or write a file
5
+ * belonging to ccmanager itself resolves the directory through here so the
6
+ * location is defined in exactly one place.
7
+ */
8
+ import { homedir } from 'os';
9
+ import path from 'path';
10
+ import { existsSync, mkdirSync } from 'fs';
11
+ /**
12
+ * Path of ccmanager's configuration/state directory. Does not touch the
13
+ * filesystem.
14
+ */
15
+ export function getConfigDir() {
16
+ const homeDir = homedir();
17
+ return process.platform === 'win32'
18
+ ? path.join(process.env['APPDATA'] || path.join(homeDir, 'AppData', 'Roaming'), 'ccmanager')
19
+ : path.join(homeDir, '.config', 'ccmanager');
20
+ }
21
+ /**
22
+ * Path of ccmanager's configuration/state directory, creating it first if it
23
+ * does not exist yet.
24
+ */
25
+ export function ensureConfigDir() {
26
+ const configDir = getConfigDir();
27
+ if (!existsSync(configDir)) {
28
+ mkdirSync(configDir, { recursive: true });
29
+ }
30
+ return configDir;
31
+ }
@@ -0,0 +1,7 @@
1
+ import { type AppError } from '../types/errors.js';
2
+ /**
3
+ * Human-readable one-line rendering of an application error, for display in the
4
+ * TUI and for log lines. Each error type carries different fields, so the text
5
+ * is composed per type via the `_tag` discriminator.
6
+ */
7
+ export declare function formatErrorMessage(error: AppError): string;
@@ -0,0 +1,19 @@
1
+ /**
2
+ * Human-readable one-line rendering of an application error, for display in the
3
+ * TUI and for log lines. Each error type carries different fields, so the text
4
+ * is composed per type via the `_tag` discriminator.
5
+ */
6
+ export function formatErrorMessage(error) {
7
+ switch (error._tag) {
8
+ case 'ProcessError':
9
+ return `Process error: ${error.message}`;
10
+ case 'ConfigError':
11
+ return `Configuration error (${error.reason}): ${error.details}`;
12
+ case 'GitError':
13
+ return `Git command failed: ${error.command} (exit ${error.exitCode})\n${error.stderr}`;
14
+ case 'FileSystemError':
15
+ return `File ${error.operation} failed for ${error.path}: ${error.cause}`;
16
+ case 'ValidationError':
17
+ return `Validation failed for ${error.field}: ${error.constraint}`;
18
+ }
19
+ }
@@ -8,6 +8,7 @@ const makeItem = (searchableName, path) => ({
8
8
  hasSession: false,
9
9
  },
10
10
  baseLabel: searchableName,
11
+ status: '',
11
12
  searchableName,
12
13
  fileChanges: '',
13
14
  aheadBehind: '',
@@ -15,6 +16,7 @@ const makeItem = (searchableName, path) => ({
15
16
  lastCommitDate: '',
16
17
  lengths: {
17
18
  base: 0,
19
+ status: 0,
18
20
  fileChanges: 0,
19
21
  aheadBehind: 0,
20
22
  parentBranch: 0,
@@ -15,3 +15,4 @@ export declare function hasUncommittedChanges(worktreePath: string): boolean;
15
15
  * @returns The absolute path to the git repository root, or null if not in a git repo
16
16
  */
17
17
  export declare function getGitRepositoryRoot(cwd: string): string | null;
18
+ export declare function getCurrentRepositoryRoot(): string;
@@ -62,3 +62,18 @@ export function getGitRepositoryRoot(cwd) {
62
62
  return null;
63
63
  }
64
64
  }
65
+ /**
66
+ * Repository root of the directory ccmanager was started in.
67
+ *
68
+ * Cached after the first call: it cannot change while the process runs, and
69
+ * both the code that records sessions for restore and the code that looks them
70
+ * up need the exact same value to agree on which project a session belongs to.
71
+ */
72
+ let currentRepositoryRoot;
73
+ export function getCurrentRepositoryRoot() {
74
+ if (currentRepositoryRoot === undefined) {
75
+ const cwd = process.cwd();
76
+ currentRepositoryRoot = getGitRepositoryRoot(cwd) ?? path.resolve(cwd);
77
+ }
78
+ return currentRepositoryRoot;
79
+ }
@@ -393,6 +393,7 @@ describe('hookExecutor Integration Tests', () => {
393
393
  stateCheckInterval: undefined,
394
394
  isPrimaryCommand: true,
395
395
  presetName: undefined,
396
+ presetId: undefined,
396
397
  detectionStrategy: 'claude',
397
398
  devcontainerConfig: undefined,
398
399
  lastActivity: new Date(),
@@ -453,6 +454,7 @@ describe('hookExecutor Integration Tests', () => {
453
454
  stateCheckInterval: undefined,
454
455
  isPrimaryCommand: true,
455
456
  presetName: undefined,
457
+ presetId: undefined,
456
458
  detectionStrategy: 'claude',
457
459
  devcontainerConfig: undefined,
458
460
  lastActivity: new Date(),
@@ -511,6 +513,7 @@ describe('hookExecutor Integration Tests', () => {
511
513
  stateCheckInterval: undefined,
512
514
  isPrimaryCommand: true,
513
515
  presetName: undefined,
516
+ presetId: undefined,
514
517
  detectionStrategy: 'claude',
515
518
  devcontainerConfig: undefined,
516
519
  lastActivity: new Date(),
@@ -571,6 +574,7 @@ describe('hookExecutor Integration Tests', () => {
571
574
  stateCheckInterval: undefined,
572
575
  isPrimaryCommand: true,
573
576
  presetName: undefined,
577
+ presetId: undefined,
574
578
  detectionStrategy: 'claude',
575
579
  devcontainerConfig: undefined,
576
580
  lastActivity: new Date(),
@@ -0,0 +1,33 @@
1
+ /**
2
+ * Filename for the convention shared across worktree-aware tools (Claude Code,
3
+ * Conductor, OpenAI Codex, git-worktreeinclude, worktrunk): a gitignore-syntax
4
+ * file at the repository root that lists gitignored files to carry into every
5
+ * new worktree.
6
+ */
7
+ export declare const WORKTREE_INCLUDE_FILENAME = ".worktreeinclude";
8
+ /**
9
+ * Resolves which files a `.worktreeinclude` file selects, relative to `gitRoot`.
10
+ *
11
+ * A file is selected only when both hold:
12
+ * - it matches a pattern in `.worktreeinclude` (gitignore syntax: comments,
13
+ * negation with `!`, anchoring with `/`, `**` globs)
14
+ * - Git already ignores it (nested `.gitignore` files, `.git/info/exclude`,
15
+ * and `core.excludesfile` all apply)
16
+ *
17
+ * This mirrors the safety rule every tool that supports `.worktreeinclude`
18
+ * documents: listing a pattern never makes a tracked file eligible, and it
19
+ * never makes an otherwise-untracked-but-not-ignored file eligible either.
20
+ *
21
+ * @param gitRoot - Absolute path to the main checkout (repository root)
22
+ * @returns Repository-relative paths (forward-slash separated, as Git reports them)
23
+ */
24
+ export declare function resolveWorktreeIncludeFiles(gitRoot: string): string[];
25
+ /**
26
+ * Copies the files a `.worktreeinclude` file selects from the main checkout
27
+ * into a freshly created worktree. No-ops when no `.worktreeinclude` file
28
+ * exists. Never overwrites a file that already exists at the destination.
29
+ *
30
+ * @param gitRoot - Absolute path to the main checkout (repository root)
31
+ * @param targetWorktreePath - Absolute path to the newly created worktree
32
+ */
33
+ export declare function copyWorktreeIncludeFiles(gitRoot: string, targetWorktreePath: string): void;
@@ -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
+ });
@@ -6,6 +6,7 @@ export interface SessionItem {
6
6
  worktree: Worktree;
7
7
  session?: Session;
8
8
  baseLabel: string;
9
+ status: string;
9
10
  searchableName: string;
10
11
  fileChanges: string;
11
12
  aheadBehind: string;
@@ -14,6 +15,7 @@ export interface SessionItem {
14
15
  error?: string;
15
16
  lengths: {
16
17
  base: number;
18
+ status: number;
17
19
  fileChanges: number;
18
20
  aheadBehind: number;
19
21
  parentBranch: number;
@@ -66,15 +68,47 @@ export declare function prepareSessionItems(worktrees: Worktree[], sessions: Ses
66
68
  sortByLastSession?: boolean;
67
69
  }): SessionItem[];
68
70
  /**
69
- * Calculates column positions based on content widths.
71
+ * Column start positions for one rendered list, plus how the session state tag
72
+ * (e.g. "[○ Idle]") is placed.
70
73
  */
71
- export declare function calculateColumnPositions(items: SessionItem[]): {
74
+ export interface ColumnPositions {
72
75
  fileChanges: number;
73
76
  aheadBehind: number;
74
77
  parentBranch: number;
78
+ status: number;
75
79
  lastCommitDate: number;
76
- };
80
+ /**
81
+ * true: the state tag gets its own column at `status`, so every row's tag
82
+ * starts at the same horizontal position, directly left of the commit date.
83
+ * false: the state tag is appended right after the branch name (the layout
84
+ * used before this column existed), because the aligned layout would not fit
85
+ * the terminal width given to calculateColumnPositions.
86
+ */
87
+ alignStatus: boolean;
88
+ }
89
+ /**
90
+ * Calculates column positions based on content widths.
91
+ *
92
+ * `availableWidth` is the number of terminal columns the label may occupy
93
+ * (i.e. terminal width minus whatever prefix the caller prepends). When the
94
+ * aligned-status layout would not fit in it, the layout falls back to appending
95
+ * the state tag to the name, which is narrower. Omit it to always align.
96
+ */
97
+ export declare function calculateColumnPositions(items: SessionItem[], availableWidth?: number): ColumnPositions;
77
98
  /**
78
99
  * Assembles the final worktree label with proper column alignment
79
100
  */
80
- export declare function assembleSessionLabel(item: SessionItem, columns: ReturnType<typeof calculateColumnPositions>): string;
101
+ export declare function assembleSessionLabel(item: SessionItem, columns: ColumnPositions): string;
102
+ /**
103
+ * Whether a worktree may be deleted by CCManager.
104
+ *
105
+ * Two worktrees are off limits:
106
+ * - the main worktree, because git refuses to remove it and the repository
107
+ * would be left without a checkout;
108
+ * - the worktree that contains the current working directory, because removing
109
+ * the directory CCManager is running in breaks the running process.
110
+ *
111
+ * Single source of truth for the rule, shared by the multi-select delete screen
112
+ * and the per-row delete action.
113
+ */
114
+ export declare function isDeletableWorktree(worktree: Pick<Worktree, 'path' | 'isMainWorktree'>, cwd?: string): boolean;