clawt 2.18.0 → 2.19.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 +10 -0
- package/dist/index.js +281 -28
- package/dist/postinstall.js +27 -0
- package/docs/spec.md +141 -0
- package/package.json +1 -1
- package/src/commands/projects.ts +324 -0
- package/src/constants/messages/index.ts +2 -0
- package/src/constants/messages/projects.ts +25 -0
- package/src/index.ts +2 -0
- package/src/types/command.ts +8 -0
- package/src/types/index.ts +2 -1
- package/src/types/project.ts +45 -0
- package/src/utils/formatter.ts +46 -0
- package/src/utils/fs.ts +32 -1
- package/src/utils/index.ts +2 -2
- package/tests/unit/utils/formatter.test.ts +91 -1
- package/tests/unit/utils/fs.test.ts +125 -2
package/src/utils/fs.ts
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
|
-
import { existsSync, mkdirSync, readdirSync, rmdirSync } from 'node:fs';
|
|
1
|
+
import { existsSync, mkdirSync, readdirSync, rmdirSync, statSync } from 'node:fs';
|
|
2
|
+
import { join } from 'node:path';
|
|
2
3
|
|
|
3
4
|
/**
|
|
4
5
|
* 确保目录存在,不存在则递归创建
|
|
@@ -26,3 +27,33 @@ export function removeEmptyDir(dirPath: string): boolean {
|
|
|
26
27
|
}
|
|
27
28
|
return false;
|
|
28
29
|
}
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* 递归计算目录占用的磁盘大小(字节)
|
|
33
|
+
* @param {string} dirPath - 目录路径
|
|
34
|
+
* @returns {number} 目录总大小(字节)
|
|
35
|
+
*/
|
|
36
|
+
export function calculateDirSize(dirPath: string): number {
|
|
37
|
+
let totalSize = 0;
|
|
38
|
+
|
|
39
|
+
try {
|
|
40
|
+
const entries = readdirSync(dirPath, { withFileTypes: true });
|
|
41
|
+
|
|
42
|
+
for (const entry of entries) {
|
|
43
|
+
const fullPath = join(dirPath, entry.name);
|
|
44
|
+
try {
|
|
45
|
+
if (entry.isDirectory()) {
|
|
46
|
+
totalSize += calculateDirSize(fullPath);
|
|
47
|
+
} else if (entry.isFile()) {
|
|
48
|
+
totalSize += statSync(fullPath).size;
|
|
49
|
+
}
|
|
50
|
+
} catch {
|
|
51
|
+
// 忽略无法访问的文件
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
} catch {
|
|
55
|
+
// 忽略无法读取的目录
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
return totalSize;
|
|
59
|
+
}
|
package/src/utils/index.ts
CHANGED
|
@@ -51,8 +51,8 @@ export { sanitizeBranchName, generateBranchNames, validateBranchesNotExist } fro
|
|
|
51
51
|
export { validateMainWorktree, validateGitInstalled, validateClaudeCodeInstalled } from './validation.js';
|
|
52
52
|
export { createWorktrees, getProjectWorktrees, getProjectWorktreeDir, cleanupWorktrees, getWorktreeStatus, createWorktreesByBranches } from './worktree.js';
|
|
53
53
|
export { loadConfig, writeDefaultConfig, writeConfig, saveConfig, getConfigValue, ensureClawtDirs, parseConcurrency } from './config.js';
|
|
54
|
-
export { printSuccess, printError, printWarning, printInfo, printHint, printSeparator, printDoubleSeparator, confirmAction, confirmDestructiveAction, formatWorktreeStatus, isWorktreeIdle, formatDuration, formatRelativeTime } from './formatter.js';
|
|
55
|
-
export { ensureDir, removeEmptyDir } from './fs.js';
|
|
54
|
+
export { printSuccess, printError, printWarning, printInfo, printHint, printSeparator, printDoubleSeparator, confirmAction, confirmDestructiveAction, formatWorktreeStatus, isWorktreeIdle, formatDuration, formatRelativeTime, formatDiskSize, formatLocalISOString } from './formatter.js';
|
|
55
|
+
export { ensureDir, removeEmptyDir, calculateDirSize } from './fs.js';
|
|
56
56
|
export { multilineInput } from './prompt.js';
|
|
57
57
|
export { launchInteractiveClaude, hasClaudeSessionHistory, launchInteractiveClaudeInNewTerminal } from './claude.js';
|
|
58
58
|
export { getSnapshotPath, hasSnapshot, getSnapshotModifiedTime, readSnapshotTreeHash, readSnapshot, writeSnapshot, removeSnapshot, removeProjectSnapshots, getProjectSnapshotBranches } from './validate-snapshot.js';
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { describe, it, expect, vi } from 'vitest';
|
|
2
|
-
import { formatWorktreeStatus, printSuccess, printError, printWarning, printInfo, printSeparator, printDoubleSeparator, isWorktreeIdle, formatDuration, formatRelativeTime } from '../../../src/utils/formatter.js';
|
|
2
|
+
import { formatWorktreeStatus, printSuccess, printError, printWarning, printInfo, printSeparator, printDoubleSeparator, isWorktreeIdle, formatDuration, formatRelativeTime, formatDiskSize, formatLocalISOString } from '../../../src/utils/formatter.js';
|
|
3
3
|
import { createWorktreeStatus } from '../../helpers/fixtures.js';
|
|
4
4
|
|
|
5
5
|
describe('formatWorktreeStatus', () => {
|
|
@@ -177,3 +177,93 @@ describe('formatRelativeTime', () => {
|
|
|
177
177
|
expect(formatRelativeTime(future.toISOString())).toBe('刚刚');
|
|
178
178
|
});
|
|
179
179
|
});
|
|
180
|
+
|
|
181
|
+
describe('formatDiskSize', () => {
|
|
182
|
+
it('0 字节时返回 "0 B"', () => {
|
|
183
|
+
expect(formatDiskSize(0)).toBe('0 B');
|
|
184
|
+
});
|
|
185
|
+
|
|
186
|
+
it('小于 1 KB 时以 B 为单位', () => {
|
|
187
|
+
expect(formatDiskSize(1)).toBe('1 B');
|
|
188
|
+
expect(formatDiskSize(512)).toBe('512 B');
|
|
189
|
+
expect(formatDiskSize(1023)).toBe('1023 B');
|
|
190
|
+
});
|
|
191
|
+
|
|
192
|
+
it('恰好 1 KB 时返回 "1.0 KB"', () => {
|
|
193
|
+
expect(formatDiskSize(1024)).toBe('1.0 KB');
|
|
194
|
+
});
|
|
195
|
+
|
|
196
|
+
it('KB 范围内保留一位小数', () => {
|
|
197
|
+
expect(formatDiskSize(1536)).toBe('1.5 KB');
|
|
198
|
+
expect(formatDiskSize(10240)).toBe('10.0 KB');
|
|
199
|
+
expect(formatDiskSize(1024 * 1024 - 1)).toBe('1024.0 KB');
|
|
200
|
+
});
|
|
201
|
+
|
|
202
|
+
it('恰好 1 MB 时返回 "1.0 MB"', () => {
|
|
203
|
+
expect(formatDiskSize(1024 * 1024)).toBe('1.0 MB');
|
|
204
|
+
});
|
|
205
|
+
|
|
206
|
+
it('MB 范围内保留一位小数', () => {
|
|
207
|
+
expect(formatDiskSize(1.5 * 1024 * 1024)).toBe('1.5 MB');
|
|
208
|
+
expect(formatDiskSize(256 * 1024 * 1024)).toBe('256.0 MB');
|
|
209
|
+
expect(formatDiskSize(1024 * 1024 * 1024 - 1)).toBe('1024.0 MB');
|
|
210
|
+
});
|
|
211
|
+
|
|
212
|
+
it('恰好 1 GB 时返回 "1.0 GB"', () => {
|
|
213
|
+
expect(formatDiskSize(1024 * 1024 * 1024)).toBe('1.0 GB');
|
|
214
|
+
});
|
|
215
|
+
|
|
216
|
+
it('GB 范围内保留一位小数', () => {
|
|
217
|
+
expect(formatDiskSize(1.5 * 1024 * 1024 * 1024)).toBe('1.5 GB');
|
|
218
|
+
expect(formatDiskSize(10 * 1024 * 1024 * 1024)).toBe('10.0 GB');
|
|
219
|
+
});
|
|
220
|
+
});
|
|
221
|
+
|
|
222
|
+
describe('formatLocalISOString', () => {
|
|
223
|
+
it('返回值包含日期和时间部分', () => {
|
|
224
|
+
const date = new Date('2025-06-15T10:30:00Z');
|
|
225
|
+
const result = formatLocalISOString(date);
|
|
226
|
+
// 结果应符合 ISO 8601 带时区偏移的格式
|
|
227
|
+
expect(result).toMatch(/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.\d{3}[+-]\d{2}:\d{2}$/);
|
|
228
|
+
});
|
|
229
|
+
|
|
230
|
+
it('返回值不以 Z 结尾(应包含时区偏移)', () => {
|
|
231
|
+
const date = new Date();
|
|
232
|
+
const result = formatLocalISOString(date);
|
|
233
|
+
expect(result).not.toMatch(/Z$/);
|
|
234
|
+
});
|
|
235
|
+
|
|
236
|
+
it('时区偏移格式为 +HH:MM 或 -HH:MM', () => {
|
|
237
|
+
const date = new Date();
|
|
238
|
+
const result = formatLocalISOString(date);
|
|
239
|
+
const tzPart = result.slice(-6);
|
|
240
|
+
expect(tzPart).toMatch(/^[+-]\d{2}:\d{2}$/);
|
|
241
|
+
});
|
|
242
|
+
|
|
243
|
+
it('时区偏移量与本机 getTimezoneOffset 一致', () => {
|
|
244
|
+
const date = new Date('2025-01-01T12:00:00Z');
|
|
245
|
+
const result = formatLocalISOString(date);
|
|
246
|
+
const tzPart = result.slice(-6);
|
|
247
|
+
const sign = tzPart[0];
|
|
248
|
+
const hours = parseInt(tzPart.slice(1, 3), 10);
|
|
249
|
+
const minutes = parseInt(tzPart.slice(4, 6), 10);
|
|
250
|
+
|
|
251
|
+
// 从偏移字符串反推总分钟数
|
|
252
|
+
const totalMinutesFromResult = (sign === '+' ? 1 : -1) * (hours * 60 + minutes);
|
|
253
|
+
const expectedMinutes = -date.getTimezoneOffset();
|
|
254
|
+
expect(totalMinutesFromResult).toBe(expectedMinutes);
|
|
255
|
+
});
|
|
256
|
+
|
|
257
|
+
it('不同日期对象产生不同的输出', () => {
|
|
258
|
+
const date1 = new Date('2025-01-01T00:00:00Z');
|
|
259
|
+
const date2 = new Date('2025-06-15T12:30:00Z');
|
|
260
|
+
expect(formatLocalISOString(date1)).not.toBe(formatLocalISOString(date2));
|
|
261
|
+
});
|
|
262
|
+
|
|
263
|
+
it('毫秒部分被保留', () => {
|
|
264
|
+
const date = new Date('2025-03-20T08:15:30.123Z');
|
|
265
|
+
const result = formatLocalISOString(date);
|
|
266
|
+
// 毫秒值应出现在结果中
|
|
267
|
+
expect(result).toContain('.123');
|
|
268
|
+
});
|
|
269
|
+
});
|
|
@@ -6,15 +6,17 @@ vi.mock('node:fs', () => ({
|
|
|
6
6
|
mkdirSync: vi.fn(),
|
|
7
7
|
readdirSync: vi.fn(),
|
|
8
8
|
rmdirSync: vi.fn(),
|
|
9
|
+
statSync: vi.fn(),
|
|
9
10
|
}));
|
|
10
11
|
|
|
11
|
-
import { existsSync, mkdirSync, readdirSync, rmdirSync } from 'node:fs';
|
|
12
|
-
import { ensureDir, removeEmptyDir } from '../../../src/utils/fs.js';
|
|
12
|
+
import { existsSync, mkdirSync, readdirSync, rmdirSync, statSync } from 'node:fs';
|
|
13
|
+
import { ensureDir, removeEmptyDir, calculateDirSize } from '../../../src/utils/fs.js';
|
|
13
14
|
|
|
14
15
|
const mockedExistsSync = vi.mocked(existsSync);
|
|
15
16
|
const mockedMkdirSync = vi.mocked(mkdirSync);
|
|
16
17
|
const mockedReaddirSync = vi.mocked(readdirSync);
|
|
17
18
|
const mockedRmdirSync = vi.mocked(rmdirSync);
|
|
19
|
+
const mockedStatSync = vi.mocked(statSync);
|
|
18
20
|
|
|
19
21
|
describe('ensureDir', () => {
|
|
20
22
|
it('目录不存在时创建', () => {
|
|
@@ -52,3 +54,124 @@ describe('removeEmptyDir', () => {
|
|
|
52
54
|
expect(mockedReaddirSync).not.toHaveBeenCalled();
|
|
53
55
|
});
|
|
54
56
|
});
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* 创建模拟的 Dirent 对象
|
|
60
|
+
* @param {string} name - 文件/目录名
|
|
61
|
+
* @param {'file' | 'directory'} type - 类型
|
|
62
|
+
* @returns {import('node:fs').Dirent} 模拟的 Dirent 对象
|
|
63
|
+
*/
|
|
64
|
+
function createMockDirent(name: string, type: 'file' | 'directory') {
|
|
65
|
+
return {
|
|
66
|
+
name,
|
|
67
|
+
isFile: () => type === 'file',
|
|
68
|
+
isDirectory: () => type === 'directory',
|
|
69
|
+
isBlockDevice: () => false,
|
|
70
|
+
isCharacterDevice: () => false,
|
|
71
|
+
isSymbolicLink: () => false,
|
|
72
|
+
isFIFO: () => false,
|
|
73
|
+
isSocket: () => false,
|
|
74
|
+
parentPath: '',
|
|
75
|
+
path: '',
|
|
76
|
+
} as import('node:fs').Dirent;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
describe('calculateDirSize', () => {
|
|
80
|
+
beforeEach(() => {
|
|
81
|
+
vi.clearAllMocks();
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
it('空目录返回 0', () => {
|
|
85
|
+
mockedReaddirSync.mockReturnValue([]);
|
|
86
|
+
expect(calculateDirSize('/tmp/empty')).toBe(0);
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
it('只有文件时累加所有文件大小', () => {
|
|
90
|
+
mockedReaddirSync.mockReturnValue([
|
|
91
|
+
createMockDirent('a.txt', 'file'),
|
|
92
|
+
createMockDirent('b.txt', 'file'),
|
|
93
|
+
]);
|
|
94
|
+
mockedStatSync
|
|
95
|
+
.mockReturnValueOnce({ size: 100 } as import('node:fs').Stats)
|
|
96
|
+
.mockReturnValueOnce({ size: 200 } as import('node:fs').Stats);
|
|
97
|
+
|
|
98
|
+
expect(calculateDirSize('/tmp/dir')).toBe(300);
|
|
99
|
+
});
|
|
100
|
+
|
|
101
|
+
it('递归计算子目录中的文件大小', () => {
|
|
102
|
+
// 第一次调用:顶层目录包含一个文件和一个子目录
|
|
103
|
+
mockedReaddirSync
|
|
104
|
+
.mockReturnValueOnce([
|
|
105
|
+
createMockDirent('file.txt', 'file'),
|
|
106
|
+
createMockDirent('subdir', 'directory'),
|
|
107
|
+
])
|
|
108
|
+
// 第二次调用:子目录包含一个文件
|
|
109
|
+
.mockReturnValueOnce([
|
|
110
|
+
createMockDirent('nested.txt', 'file'),
|
|
111
|
+
]);
|
|
112
|
+
|
|
113
|
+
mockedStatSync
|
|
114
|
+
.mockReturnValueOnce({ size: 500 } as import('node:fs').Stats)
|
|
115
|
+
.mockReturnValueOnce({ size: 300 } as import('node:fs').Stats);
|
|
116
|
+
|
|
117
|
+
expect(calculateDirSize('/tmp/dir')).toBe(800);
|
|
118
|
+
});
|
|
119
|
+
|
|
120
|
+
it('readdirSync 抛出异常时返回 0', () => {
|
|
121
|
+
mockedReaddirSync.mockImplementation(() => {
|
|
122
|
+
throw new Error('权限不足');
|
|
123
|
+
});
|
|
124
|
+
expect(calculateDirSize('/tmp/no-access')).toBe(0);
|
|
125
|
+
});
|
|
126
|
+
|
|
127
|
+
it('statSync 对个别文件抛出异常时跳过该文件继续计算', () => {
|
|
128
|
+
mockedReaddirSync.mockReturnValue([
|
|
129
|
+
createMockDirent('good.txt', 'file'),
|
|
130
|
+
createMockDirent('bad.txt', 'file'),
|
|
131
|
+
createMockDirent('ok.txt', 'file'),
|
|
132
|
+
]);
|
|
133
|
+
mockedStatSync
|
|
134
|
+
.mockReturnValueOnce({ size: 100 } as import('node:fs').Stats)
|
|
135
|
+
.mockImplementationOnce(() => { throw new Error('无法访问'); })
|
|
136
|
+
.mockReturnValueOnce({ size: 200 } as import('node:fs').Stats);
|
|
137
|
+
|
|
138
|
+
expect(calculateDirSize('/tmp/dir')).toBe(300);
|
|
139
|
+
});
|
|
140
|
+
|
|
141
|
+
it('多层嵌套目录正确累加', () => {
|
|
142
|
+
// 顶层:子目录 a
|
|
143
|
+
mockedReaddirSync
|
|
144
|
+
.mockReturnValueOnce([
|
|
145
|
+
createMockDirent('a', 'directory'),
|
|
146
|
+
])
|
|
147
|
+
// a 目录:文件 + 子目录 b
|
|
148
|
+
.mockReturnValueOnce([
|
|
149
|
+
createMockDirent('x.txt', 'file'),
|
|
150
|
+
createMockDirent('b', 'directory'),
|
|
151
|
+
])
|
|
152
|
+
// b 目录:一个文件
|
|
153
|
+
.mockReturnValueOnce([
|
|
154
|
+
createMockDirent('y.txt', 'file'),
|
|
155
|
+
]);
|
|
156
|
+
|
|
157
|
+
mockedStatSync
|
|
158
|
+
.mockReturnValueOnce({ size: 1000 } as import('node:fs').Stats)
|
|
159
|
+
.mockReturnValueOnce({ size: 2000 } as import('node:fs').Stats);
|
|
160
|
+
|
|
161
|
+
expect(calculateDirSize('/tmp/root')).toBe(3000);
|
|
162
|
+
});
|
|
163
|
+
|
|
164
|
+
it('只包含子目录(无直接文件)时正确递归', () => {
|
|
165
|
+
mockedReaddirSync
|
|
166
|
+
.mockReturnValueOnce([
|
|
167
|
+
createMockDirent('sub', 'directory'),
|
|
168
|
+
])
|
|
169
|
+
.mockReturnValueOnce([
|
|
170
|
+
createMockDirent('inner.txt', 'file'),
|
|
171
|
+
]);
|
|
172
|
+
|
|
173
|
+
mockedStatSync.mockReturnValueOnce({ size: 42 } as import('node:fs').Stats);
|
|
174
|
+
|
|
175
|
+
expect(calculateDirSize('/tmp/dir')).toBe(42);
|
|
176
|
+
});
|
|
177
|
+
});
|