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.
- package/README.md +54 -0
- package/dist/components/App.js +92 -30
- package/dist/components/App.test.js +86 -0
- package/dist/components/Dashboard.js +10 -3
- package/dist/components/DeleteWorktree.js +2 -13
- package/dist/components/Menu.js +28 -17
- package/dist/components/Menu.test.js +37 -1
- package/dist/components/RestoreSessions.d.ts +19 -0
- package/dist/components/RestoreSessions.js +28 -0
- package/dist/components/SessionActions.d.ts +17 -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/hooks/useAvailableLabelWidth.d.ts +6 -0
- package/dist/hooks/useAvailableLabelWidth.js +15 -0
- package/dist/services/config/globalConfigManager.js +3 -12
- package/dist/services/globalSessionOrchestrator.d.ts +7 -0
- package/dist/services/globalSessionOrchestrator.js +40 -0
- package/dist/services/globalSessionOrchestrator.restore.test.d.ts +1 -0
- package/dist/services/globalSessionOrchestrator.restore.test.js +76 -0
- package/dist/services/projectManager.js +3 -11
- package/dist/services/sessionManager.d.ts +6 -0
- package/dist/services/sessionManager.js +16 -0
- package/dist/services/sessionRestoreStore.d.ts +75 -0
- package/dist/services/sessionRestoreStore.js +138 -0
- package/dist/services/sessionRestoreStore.test.d.ts +1 -0
- package/dist/services/sessionRestoreStore.test.js +92 -0
- package/dist/services/sessionRestorer.d.ts +46 -0
- package/dist/services/sessionRestorer.js +123 -0
- package/dist/services/sessionRestorer.test.d.ts +1 -0
- package/dist/services/sessionRestorer.test.js +163 -0
- package/dist/services/worktreeService.d.ts +12 -0
- package/dist/services/worktreeService.js +30 -0
- package/dist/services/worktreeService.test.js +55 -1
- package/dist/types/index.d.ts +3 -2
- package/dist/utils/configDir.d.ts +10 -0
- package/dist/utils/configDir.js +31 -0
- package/dist/utils/errorMessage.d.ts +7 -0
- package/dist/utils/errorMessage.js +19 -0
- package/dist/utils/filterByQuery.test.js +2 -0
- package/dist/utils/gitUtils.d.ts +1 -0
- package/dist/utils/gitUtils.js +15 -0
- package/dist/utils/hookExecutor.test.js +4 -0
- 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 +38 -4
- package/dist/utils/worktreeUtils.js +76 -17
- package/dist/utils/worktreeUtils.test.js +82 -5
- 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) => {
|
package/dist/types/index.d.ts
CHANGED
|
@@ -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
|
-
|
|
79
|
-
|
|
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,
|
package/dist/utils/gitUtils.d.ts
CHANGED
|
@@ -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;
|
package/dist/utils/gitUtils.js
CHANGED
|
@@ -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
|
-
*
|
|
71
|
+
* Column start positions for one rendered list, plus how the session state tag
|
|
72
|
+
* (e.g. "[○ Idle]") is placed.
|
|
70
73
|
*/
|
|
71
|
-
export
|
|
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:
|
|
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;
|