@zoebuildsai/trace 1.5.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 (130) hide show
  1. package/.gitignore +115 -0
  2. package/.trace/progress.json +22 -0
  3. package/README.md +466 -0
  4. package/RELEASE-NOTES-1.5.0.md +410 -0
  5. package/STATUS.md +245 -0
  6. package/dist/auto-commit.d.ts +66 -0
  7. package/dist/auto-commit.d.ts.map +1 -0
  8. package/dist/auto-commit.js +180 -0
  9. package/dist/auto-commit.js.map +1 -0
  10. package/dist/cli.d.ts +7 -0
  11. package/dist/cli.d.ts.map +1 -0
  12. package/dist/cli.js +246 -0
  13. package/dist/cli.js.map +1 -0
  14. package/dist/commands.d.ts +46 -0
  15. package/dist/commands.d.ts.map +1 -0
  16. package/dist/commands.js +256 -0
  17. package/dist/commands.js.map +1 -0
  18. package/dist/diff.d.ts +23 -0
  19. package/dist/diff.d.ts.map +1 -0
  20. package/dist/diff.js +106 -0
  21. package/dist/diff.js.map +1 -0
  22. package/dist/github.d.ts.map +1 -0
  23. package/dist/github.js.map +1 -0
  24. package/dist/index-cache.d.ts +35 -0
  25. package/dist/index-cache.d.ts.map +1 -0
  26. package/dist/index-cache.js +114 -0
  27. package/dist/index-cache.js.map +1 -0
  28. package/dist/index.d.ts +15 -0
  29. package/dist/index.d.ts.map +1 -0
  30. package/dist/index.js +25 -0
  31. package/dist/index.js.map +1 -0
  32. package/dist/storage.d.ts +45 -0
  33. package/dist/storage.d.ts.map +1 -0
  34. package/dist/storage.js +151 -0
  35. package/dist/storage.js.map +1 -0
  36. package/dist/sync.d.ts +60 -0
  37. package/dist/sync.js +184 -0
  38. package/dist/tags.d.ts +85 -0
  39. package/dist/tags.d.ts.map +1 -0
  40. package/dist/tags.js +219 -0
  41. package/dist/tags.js.map +1 -0
  42. package/dist/types.d.ts +102 -0
  43. package/dist/types.d.ts.map +1 -0
  44. package/dist/types.js +6 -0
  45. package/dist/types.js.map +1 -0
  46. package/docs/.nojekyll +0 -0
  47. package/docs/README.md +73 -0
  48. package/docs/_config.yml +2 -0
  49. package/docs/index.html +960 -0
  50. package/docs-website/package.json +20 -0
  51. package/jest.config.js +21 -0
  52. package/package.json +50 -0
  53. package/scripts/init.ts +290 -0
  54. package/src/agent-audit.ts +270 -0
  55. package/src/agent-checkout.ts +227 -0
  56. package/src/agent-coordination.ts +318 -0
  57. package/src/async-queue.ts +203 -0
  58. package/src/auto-branching.ts +279 -0
  59. package/src/auto-commit.ts +166 -0
  60. package/src/cherry-pick.ts +252 -0
  61. package/src/chunked-upload.ts +224 -0
  62. package/src/cli-v2.ts +335 -0
  63. package/src/cli.ts +318 -0
  64. package/src/cliff-detection.ts +232 -0
  65. package/src/commands.ts +267 -0
  66. package/src/commit-hash-system.ts +351 -0
  67. package/src/compression.ts +176 -0
  68. package/src/conflict-resolution-ui.ts +277 -0
  69. package/src/conflict-visualization.ts +238 -0
  70. package/src/diff-formatter.ts +184 -0
  71. package/src/diff.ts +124 -0
  72. package/src/distributed-coordination.ts +273 -0
  73. package/src/git-interop.ts +316 -0
  74. package/src/index-cache.ts +88 -0
  75. package/src/index.ts +38 -0
  76. package/src/merge-engine.ts +143 -0
  77. package/src/message-search.ts +370 -0
  78. package/src/performance-monitoring.ts +236 -0
  79. package/src/rebase.ts +327 -0
  80. package/src/rollback.ts +215 -0
  81. package/src/semantic-grouping.ts +245 -0
  82. package/src/stage-area.ts +324 -0
  83. package/src/stash.ts +278 -0
  84. package/src/storage.ts +131 -0
  85. package/src/sync.ts +205 -0
  86. package/src/tags.ts +244 -0
  87. package/src/types.ts +119 -0
  88. package/src/webhooks.ts +119 -0
  89. package/src/workspace-isolation.ts +298 -0
  90. package/tests/auto-commit.test.ts +308 -0
  91. package/tests/checkout.test.ts +136 -0
  92. package/tests/commit.test.ts +118 -0
  93. package/tests/diff.test.ts +191 -0
  94. package/tests/github.test.ts +94 -0
  95. package/tests/integration.test.ts +267 -0
  96. package/tests/log.test.ts +125 -0
  97. package/tests/phase2-integration.test.ts +370 -0
  98. package/tests/storage.test.ts +167 -0
  99. package/tests/tags.test.ts +477 -0
  100. package/tests/types.test.ts +75 -0
  101. package/tests/v1.1/agent-audit.test.ts +472 -0
  102. package/tests/v1.1/agent-coordination.test.ts +308 -0
  103. package/tests/v1.1/async-queue.test.ts +253 -0
  104. package/tests/v1.1/comprehensive.test.ts +521 -0
  105. package/tests/v1.1/diff-formatter.test.ts +238 -0
  106. package/tests/v1.1/integration.test.ts +389 -0
  107. package/tests/v1.1/onboarding.test.ts +365 -0
  108. package/tests/v1.1/rollback.test.ts +370 -0
  109. package/tests/v1.1/semantic-grouping.test.ts +230 -0
  110. package/tests/v1.2/chunked-upload.test.ts +301 -0
  111. package/tests/v1.2/cliff-detection.test.ts +272 -0
  112. package/tests/v1.2/commit-hash-system.test.ts +288 -0
  113. package/tests/v1.2/compression.test.ts +220 -0
  114. package/tests/v1.2/conflict-visualization.test.ts +263 -0
  115. package/tests/v1.2/distributed.test.ts +261 -0
  116. package/tests/v1.2/performance-monitoring.test.ts +328 -0
  117. package/tests/v1.3/auto-branching.test.ts +270 -0
  118. package/tests/v1.3/message-search.test.ts +264 -0
  119. package/tests/v1.3/stage-area.test.ts +330 -0
  120. package/tests/v1.3/stash-rebase-cherry-pick.test.ts +361 -0
  121. package/tests/v1.4/cli.test.ts +171 -0
  122. package/tests/v1.4/conflict-resolution-advanced.test.ts +429 -0
  123. package/tests/v1.4/conflict-resolution-ui.test.ts +286 -0
  124. package/tests/v1.4/workspace-isolation-advanced.test.ts +382 -0
  125. package/tests/v1.4/workspace-isolation.test.ts +268 -0
  126. package/tests/v1.5/agent-coordination.real.test.ts +401 -0
  127. package/tests/v1.5/cli-v2.test.ts +354 -0
  128. package/tests/v1.5/git-interop.real.test.ts +358 -0
  129. package/tests/v1.5/integration-testing.real.test.ts +440 -0
  130. package/tsconfig.json +26 -0
@@ -0,0 +1,298 @@
1
+ /**
2
+ * Workspace Isolation for Trace
3
+ * Enforce security boundary between project workspace and private data
4
+ */
5
+
6
+ import * as path from 'path';
7
+
8
+ export interface WorkspaceBoundary {
9
+ root: string; // Workspace root (where git is initialized)
10
+ allowed: string[]; // Paths allowed in git
11
+ blocked: string[]; // Paths never committed
12
+ isPublic: boolean; // Whether workspace is public repo
13
+ }
14
+
15
+ export interface SecurityViolation {
16
+ path: string;
17
+ reason: string;
18
+ severity: 'warning' | 'error' | 'critical';
19
+ suggestion: string;
20
+ }
21
+
22
+ export class WorkspaceIsolation {
23
+ private boundary: WorkspaceBoundary;
24
+ private violations: SecurityViolation[] = [];
25
+
26
+ constructor(workspaceRoot: string, isPublic: boolean = true) {
27
+ this.boundary = {
28
+ root: workspaceRoot,
29
+ allowed: [
30
+ 'src/',
31
+ 'tests/',
32
+ 'dist/',
33
+ 'docs/',
34
+ 'package.json',
35
+ 'tsconfig.json',
36
+ 'README.md',
37
+ '.gitignore',
38
+ '.gitattributes',
39
+ 'LICENSE',
40
+ ],
41
+ blocked: [
42
+ '.env',
43
+ '.env.local',
44
+ '.env.*.local',
45
+ '*.key',
46
+ '*.pem',
47
+ 'secrets.json',
48
+ 'config.local.json',
49
+ '.credentials',
50
+ 'credentials.json',
51
+ 'oauth.json',
52
+ 'api-keys.json',
53
+ 'tokens.json',
54
+ 'passwords.txt',
55
+ '.workspace/',
56
+ 'workspace-private/',
57
+ 'private/',
58
+ '.local/',
59
+ 'agent-*.key',
60
+ 'agent-*.pem',
61
+ 'agent-keys/',
62
+ '.trace/config.local.json',
63
+ '.trace/keys.json',
64
+ ],
65
+ isPublic,
66
+ };
67
+ }
68
+
69
+ /**
70
+ * Check if path is safe to commit
71
+ */
72
+ isSafeToCommit(filePath: string): { safe: boolean; violations: SecurityViolation[] } {
73
+ const violations: SecurityViolation[] = [];
74
+
75
+ // Normalize path
76
+ const normalized = path.normalize(filePath).replace(/\\/g, '/');
77
+
78
+ // Check against blocked patterns
79
+ for (const blocked of this.boundary.blocked) {
80
+ if (this.matchesPattern(normalized, blocked)) {
81
+ violations.push({
82
+ path: filePath,
83
+ reason: `Matches blocked pattern: ${blocked}`,
84
+ severity: 'critical',
85
+ suggestion: `Add to .gitignore: ${blocked}`,
86
+ });
87
+ }
88
+ }
89
+
90
+ // Check if file is outside workspace root
91
+ const fullPath = path.resolve(this.boundary.root, filePath);
92
+ const relative = path.relative(this.boundary.root, fullPath);
93
+
94
+ if (relative.startsWith('..')) {
95
+ violations.push({
96
+ path: filePath,
97
+ reason: 'File is outside workspace root',
98
+ severity: 'critical',
99
+ suggestion: `Use paths relative to ${this.boundary.root}`,
100
+ });
101
+ }
102
+
103
+ // Warn on sensitive filenames
104
+ if (this.hasSensitiveName(normalized)) {
105
+ violations.push({
106
+ path: filePath,
107
+ reason: 'Filename suggests sensitive data',
108
+ severity: 'warning',
109
+ suggestion: 'Verify this file should be committed',
110
+ });
111
+ }
112
+
113
+ return {
114
+ safe: violations.length === 0,
115
+ violations,
116
+ };
117
+ }
118
+
119
+ /**
120
+ * Scan directory for violations
121
+ */
122
+ scanDirectory(dirPath: string): {
123
+ safeFiles: string[];
124
+ violatingFiles: string[];
125
+ violations: SecurityViolation[];
126
+ } {
127
+ const safeFiles: string[] = [];
128
+ const violatingFiles: string[] = [];
129
+ const allViolations: SecurityViolation[] = [];
130
+
131
+ // In real implementation, would recursively scan directory
132
+ // For now, check common patterns
133
+
134
+ const testPaths = [
135
+ 'src/index.ts',
136
+ '.env',
137
+ 'secrets.json',
138
+ '.credentials',
139
+ 'package.json',
140
+ ];
141
+
142
+ for (const testPath of testPaths) {
143
+ const result = this.isSafeToCommit(testPath);
144
+ if (result.safe) {
145
+ safeFiles.push(testPath);
146
+ } else {
147
+ violatingFiles.push(testPath);
148
+ allViolations.push(...result.violations);
149
+ }
150
+ }
151
+
152
+ return { safeFiles, violatingFiles, violations: allViolations };
153
+ }
154
+
155
+ /**
156
+ * Check if git operation would leak private data
157
+ */
158
+ validateGitPush(filePaths: string[]): {
159
+ canPush: boolean;
160
+ violations: SecurityViolation[];
161
+ } {
162
+ const violations: SecurityViolation[] = [];
163
+
164
+ for (const file of filePaths) {
165
+ const result = this.isSafeToCommit(file);
166
+ if (!result.safe) {
167
+ violations.push(...result.violations);
168
+ }
169
+ }
170
+
171
+ return {
172
+ canPush: violations.length === 0,
173
+ violations,
174
+ };
175
+ }
176
+
177
+ /**
178
+ * Get workspace boundary info
179
+ */
180
+ getBoundary(): WorkspaceBoundary {
181
+ return { ...this.boundary };
182
+ }
183
+
184
+ /**
185
+ * Set custom allowed paths
186
+ */
187
+ setAllowed(paths: string[]): void {
188
+ this.boundary.allowed = paths;
189
+ }
190
+
191
+ /**
192
+ * Add path to blocked list
193
+ */
194
+ blockPath(pattern: string): void {
195
+ if (!this.boundary.blocked.includes(pattern)) {
196
+ this.boundary.blocked.push(pattern);
197
+ }
198
+ }
199
+
200
+ /**
201
+ * Check if filename has sensitive indicators
202
+ */
203
+ private hasSensitiveName(filePath: string): boolean {
204
+ const sensitive = [
205
+ 'password',
206
+ 'secret',
207
+ 'token',
208
+ 'credential',
209
+ 'apikey',
210
+ 'api_key',
211
+ 'private',
212
+ 'backup',
213
+ 'dump',
214
+ 'key',
215
+ 'pem',
216
+ 'cert',
217
+ ];
218
+
219
+ const fileName = path.basename(filePath).toLowerCase();
220
+
221
+ return sensitive.some(s => fileName.includes(s));
222
+ }
223
+
224
+ /**
225
+ * Match path against pattern (supports * wildcards)
226
+ */
227
+ private matchesPattern(filePath: string, pattern: string): boolean {
228
+ // Simple glob-style matching
229
+ const regex = pattern
230
+ .replace(/\./g, '\\.')
231
+ .replace(/\*/g, '.*')
232
+ .replace(/\?/g, '.');
233
+
234
+ return new RegExp(`^${regex}$`).test(filePath);
235
+ }
236
+
237
+ /**
238
+ * Generate security report
239
+ */
240
+ generateReport(): string {
241
+ let report = `\n🔒 WORKSPACE SECURITY REPORT\n`;
242
+ report += `Workspace Root: ${this.boundary.root}\n`;
243
+ report += `Public Repository: ${this.boundary.isPublic ? 'Yes' : 'No'}\n\n`;
244
+
245
+ report += `✅ ALLOWED PATHS (${this.boundary.allowed.length}):\n`;
246
+ for (const path of this.boundary.allowed) {
247
+ report += ` • ${path}\n`;
248
+ }
249
+
250
+ report += `\n🚫 BLOCKED PATHS (${this.boundary.blocked.length}):\n`;
251
+ for (const path of this.boundary.blocked) {
252
+ report += ` • ${path}\n`;
253
+ }
254
+
255
+ if (this.violations.length > 0) {
256
+ report += `\n⚠️ VIOLATIONS DETECTED (${this.violations.length}):\n`;
257
+ for (const v of this.violations) {
258
+ report += ` [${v.severity.toUpperCase()}] ${v.path}\n`;
259
+ report += ` Reason: ${v.reason}\n`;
260
+ report += ` Fix: ${v.suggestion}\n`;
261
+ }
262
+ }
263
+
264
+ return report;
265
+ }
266
+
267
+ /**
268
+ * Create pre-commit hook content
269
+ */
270
+ getPreCommitHook(): string {
271
+ return `#!/bin/bash
272
+ # Trace Pre-Commit Security Hook
273
+ # Prevents accidental commits of private files
274
+
275
+ STAGED_FILES=$(git diff --cached --name-only)
276
+
277
+ for file in $STAGED_FILES; do
278
+ if [[ $file == .env* ]] || [[ $file == *.key ]] || [[ $file == *.pem ]] || [[ $file == *secret* ]] || [[ $file == *credential* ]]; then
279
+ echo "🚫 SECURITY: Cannot commit private file: $file"
280
+ echo " Add to .gitignore if this is intentional"
281
+ exit 1
282
+ fi
283
+ done
284
+
285
+ echo "✅ Pre-commit security check passed"
286
+ exit 0
287
+ `;
288
+ }
289
+
290
+ /**
291
+ * Clear violations log
292
+ */
293
+ clearViolations(): void {
294
+ this.violations = [];
295
+ }
296
+ }
297
+
298
+ export default WorkspaceIsolation;
@@ -0,0 +1,308 @@
1
+ /**
2
+ * Auto-Commit Feature Tests
3
+ *
4
+ * Core test suite for auto-commit functionality:
5
+ * - File watching with configurable intervals
6
+ * - Change detection (skip if no changes)
7
+ * - Auto-commit prefix handling
8
+ * - Concurrent change handling
9
+ * - Performance requirements (<500ms)
10
+ */
11
+
12
+ import { AutoCommitter } from '../src/auto-commit';
13
+ import { TraceCommands } from '../src/commands';
14
+ import * as fs from 'fs';
15
+ import * as path from 'path';
16
+ import * as os from 'os';
17
+
18
+ describe('Auto-Commit Feature', () => {
19
+ let testDir: string;
20
+ let homeDir: string;
21
+ let memoryDir: string;
22
+ let commands: TraceCommands;
23
+ let autoCommitter: AutoCommitter;
24
+
25
+ beforeEach(() => {
26
+ testDir = fs.mkdtempSync(path.join(os.tmpdir(), 'memory-git-'));
27
+ homeDir = fs.mkdtempSync(path.join(os.tmpdir(), 'home-'));
28
+ process.env.HOME = homeDir;
29
+ memoryDir = path.join(homeDir, '.openclaw/memory');
30
+ fs.mkdirSync(memoryDir, { recursive: true });
31
+
32
+ commands = new TraceCommands(testDir);
33
+ autoCommitter = new AutoCommitter(commands, memoryDir);
34
+ });
35
+
36
+ afterEach(() => {
37
+ if (autoCommitter) {
38
+ autoCommitter.stop();
39
+ }
40
+ if (fs.existsSync(testDir)) {
41
+ fs.rmSync(testDir, { recursive: true, force: true });
42
+ }
43
+ if (fs.existsSync(homeDir)) {
44
+ fs.rmSync(homeDir, { recursive: true, force: true });
45
+ }
46
+ });
47
+
48
+ describe('Interval configuration', () => {
49
+ it('should commit with default 60s interval', () => {
50
+ autoCommitter.start({}); // default interval
51
+ expect(autoCommitter.getInterval()).toBe(60000);
52
+ });
53
+
54
+ it('should respect custom interval', () => {
55
+ autoCommitter.start({ interval: 200 });
56
+ expect(autoCommitter.getInterval()).toBe(200);
57
+ });
58
+
59
+ it('should handle interval in milliseconds', () => {
60
+ autoCommitter.start({ interval: 1500 });
61
+ expect(autoCommitter.getInterval()).toBe(1500);
62
+ });
63
+
64
+ it('should allow interval update while watching', () => {
65
+ autoCommitter.start({ interval: 100 });
66
+ expect(autoCommitter.getInterval()).toBe(100);
67
+
68
+ autoCommitter.setInterval(200);
69
+ expect(autoCommitter.getInterval()).toBe(200);
70
+ });
71
+ });
72
+
73
+ describe('Watch control', () => {
74
+ it('should start watching', () => {
75
+ autoCommitter.start({ interval: 100 });
76
+ expect(autoCommitter.isRunning_()).toBe(true);
77
+ });
78
+
79
+ it('should stop watching', () => {
80
+ autoCommitter.start({ interval: 100 });
81
+ expect(autoCommitter.isRunning_()).toBe(true);
82
+
83
+ autoCommitter.stop();
84
+ expect(autoCommitter.isRunning_()).toBe(false);
85
+ });
86
+
87
+ it('should not throw when stopping if not started', () => {
88
+ expect(() => {
89
+ autoCommitter.stop();
90
+ }).not.toThrow();
91
+ });
92
+
93
+ it('should handle restart after stop', () => {
94
+ autoCommitter.start({ interval: 100 });
95
+ autoCommitter.stop();
96
+ autoCommitter.start({ interval: 100 });
97
+ expect(autoCommitter.isRunning_()).toBe(true);
98
+ });
99
+ });
100
+
101
+ describe('Core logic - commit detection', () => {
102
+ it('should create initial commit', () => {
103
+ fs.writeFileSync(path.join(memoryDir, 'test.md'), 'content');
104
+ const hash = commands.commit('initial');
105
+ expect(hash).toBeDefined();
106
+ expect(hash.length).toBeGreaterThan(0);
107
+ expect(typeof hash).toBe('string');
108
+ });
109
+
110
+ it('should detect file changes', () => {
111
+ fs.writeFileSync(path.join(memoryDir, 'test.md'), 'content');
112
+ commands.commit('initial');
113
+
114
+ // Make change
115
+ fs.writeFileSync(path.join(memoryDir, 'test.md'), 'updated');
116
+
117
+ const status = commands.status();
118
+ expect(status.modified.length).toBeGreaterThan(0);
119
+ expect(status.clean).toBe(false);
120
+ });
121
+
122
+ it('should detect added files', () => {
123
+ fs.writeFileSync(path.join(memoryDir, 'test.md'), 'content');
124
+ commands.commit('initial');
125
+
126
+ // Add new file
127
+ fs.writeFileSync(path.join(memoryDir, 'new.md'), 'new');
128
+
129
+ const status = commands.status();
130
+ expect(status.added.length).toBeGreaterThan(0);
131
+ });
132
+
133
+ it('should detect deleted files', () => {
134
+ fs.writeFileSync(path.join(memoryDir, 'test.md'), 'content');
135
+ commands.commit('initial');
136
+
137
+ // Delete file
138
+ fs.unlinkSync(path.join(memoryDir, 'test.md'));
139
+
140
+ const status = commands.status();
141
+ expect(status.deleted.length).toBeGreaterThan(0);
142
+ });
143
+
144
+ it('should skip commits when no changes', () => {
145
+ fs.writeFileSync(path.join(memoryDir, 'test.md'), 'content');
146
+ commands.commit('initial');
147
+
148
+ const status = commands.status();
149
+ expect(status.clean).toBe(true);
150
+ expect(status.modified.length).toBe(0);
151
+ expect(status.added.length).toBe(0);
152
+ expect(status.deleted.length).toBe(0);
153
+ });
154
+ });
155
+
156
+ describe('Auto-commit markers', () => {
157
+ it('should mark commits with [auto] prefix', () => {
158
+ fs.writeFileSync(path.join(memoryDir, 'test.md'), 'initial');
159
+ commands.commit('initial');
160
+
161
+ fs.writeFileSync(path.join(memoryDir, 'test.md'), 'updated');
162
+
163
+ const message = '[auto] Memory snapshot (files: 1)';
164
+ const hash = commands.commit(message, 'agent', { auto: true });
165
+
166
+ const log = commands.log(1);
167
+ expect(log[0].message).toContain('[auto]');
168
+ expect(log[0].message).toContain('Memory snapshot');
169
+ });
170
+
171
+ it('should include file count in metadata', () => {
172
+ fs.writeFileSync(path.join(memoryDir, 'test.md'), 'content');
173
+ commands.commit('initial');
174
+
175
+ fs.writeFileSync(path.join(memoryDir, 'file1.md'), 'new');
176
+ fs.writeFileSync(path.join(memoryDir, 'file2.md'), 'new');
177
+
178
+ const metadata = { auto: true, filesChanged: 2, timestamp: Date.now() };
179
+ commands.commit('[auto] Memory snapshot (files: 2)', 'agent', metadata);
180
+
181
+ const commit = commands.getCurrentCommit();
182
+ expect(commit?.metadata?.auto).toBe(true);
183
+ expect(commit?.metadata?.filesChanged).toBe(2);
184
+ });
185
+
186
+ it('should mark author as agent', () => {
187
+ fs.writeFileSync(path.join(memoryDir, 'test.md'), 'content');
188
+ const hash = commands.commit('[auto] test', 'agent');
189
+
190
+ const log = commands.log(1);
191
+ expect(log[0].author).toBe('agent');
192
+ });
193
+ });
194
+
195
+ describe('Ignore patterns', () => {
196
+ it('should skip node_modules by default', () => {
197
+ fs.mkdirSync(path.join(memoryDir, 'node_modules'), { recursive: true });
198
+ fs.writeFileSync(path.join(memoryDir, 'node_modules', 'pkg.json'), 'data');
199
+
200
+ // The auto-committer should ignore node_modules changes
201
+ autoCommitter.start({ interval: 100, ignorePatterns: ['node_modules'] });
202
+
203
+ expect(autoCommitter.isRunning_()).toBe(true);
204
+ autoCommitter.stop();
205
+ });
206
+ });
207
+
208
+ describe('Performance', () => {
209
+ it('should initialize quickly', () => {
210
+ const start = Date.now();
211
+ autoCommitter.start({ interval: 50 });
212
+ const duration = Date.now() - start;
213
+
214
+ expect(duration).toBeLessThan(100);
215
+ autoCommitter.stop();
216
+ });
217
+
218
+ it('should commit in under 500ms', () => {
219
+ fs.writeFileSync(path.join(memoryDir, 'test.md'), 'x'.repeat(10000));
220
+ commands.commit('initial');
221
+
222
+ fs.writeFileSync(path.join(memoryDir, 'test.md'), 'x'.repeat(10001));
223
+
224
+ const start = Date.now();
225
+ const message = '[auto] Memory snapshot (files: 1)';
226
+ commands.commit(message, 'agent', { auto: true });
227
+ const duration = Date.now() - start;
228
+
229
+ expect(duration).toBeLessThan(500);
230
+ });
231
+
232
+ it('should handle many commits quickly', () => {
233
+ for (let i = 0; i < 50; i++) {
234
+ fs.writeFileSync(path.join(memoryDir, `file-${i}.txt`), `content ${i}`);
235
+ commands.commit(`commit ${i}`);
236
+ }
237
+
238
+ const log = commands.log(100);
239
+ expect(log.length).toBeGreaterThanOrEqual(50);
240
+ });
241
+ });
242
+
243
+ describe('Edge cases', () => {
244
+ it('should handle empty directory', () => {
245
+ autoCommitter.start({ interval: 100 });
246
+ expect(autoCommitter.isRunning_()).toBe(true);
247
+ autoCommitter.stop();
248
+ });
249
+
250
+ it('should handle special characters in filenames', () => {
251
+ fs.writeFileSync(path.join(memoryDir, 'initial.md'), 'start');
252
+ commands.commit('initial');
253
+
254
+ fs.writeFileSync(path.join(memoryDir, 'file-with-dash.md'), 'content');
255
+ fs.writeFileSync(path.join(memoryDir, 'file_with_underscore.md'), 'content');
256
+
257
+ const status = commands.status();
258
+ expect(status.added.length).toBeGreaterThanOrEqual(2);
259
+ });
260
+
261
+ it('should handle deeply nested files', () => {
262
+ const deep = path.join(memoryDir, 'a', 'b', 'c', 'd');
263
+ fs.mkdirSync(deep, { recursive: true });
264
+ fs.writeFileSync(path.join(deep, 'file.md'), 'content');
265
+
266
+ commands.commit('nested');
267
+ const log = commands.log(1);
268
+ expect(log[0].message).toBe('nested');
269
+ });
270
+
271
+ it('should not throw on permission errors', () => {
272
+ const file = path.join(memoryDir, 'readonly.md');
273
+ fs.writeFileSync(file, 'content');
274
+ commands.commit('initial');
275
+
276
+ autoCommitter.start({ interval: 100 });
277
+ expect(autoCommitter.isRunning_()).toBe(true);
278
+
279
+ autoCommitter.stop();
280
+ });
281
+ });
282
+
283
+ describe('Multi-interval scenarios', () => {
284
+ it('should handle interval changes mid-run', () => {
285
+ autoCommitter.start({ interval: 100 });
286
+ autoCommitter.setInterval(200);
287
+ autoCommitter.setInterval(50);
288
+
289
+ expect(autoCommitter.getInterval()).toBe(50);
290
+ expect(autoCommitter.isRunning_()).toBe(true);
291
+
292
+ autoCommitter.stop();
293
+ });
294
+
295
+ it('should stop and restart cleanly', () => {
296
+ autoCommitter.start({ interval: 100 });
297
+ autoCommitter.stop();
298
+
299
+ expect(autoCommitter.isRunning_()).toBe(false);
300
+
301
+ autoCommitter.start({ interval: 150 });
302
+ expect(autoCommitter.isRunning_()).toBe(true);
303
+ expect(autoCommitter.getInterval()).toBe(150);
304
+
305
+ autoCommitter.stop();
306
+ });
307
+ });
308
+ });