@sylphx/flow 3.18.0 → 3.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/CHANGELOG.md +41 -0
- package/package.json +1 -1
- package/src/config/targets.ts +1 -1
- package/src/core/__tests__/backup-restore.test.ts +1 -1
- package/src/core/__tests__/cleanup-handler.test.ts +292 -0
- package/src/core/__tests__/git-stash-manager.test.ts +246 -0
- package/src/core/__tests__/secrets-manager.test.ts +126 -0
- package/src/core/__tests__/session-cleanup.test.ts +147 -0
- package/src/core/attach-manager.ts +7 -77
- package/src/core/backup-manager.ts +8 -20
- package/src/core/cleanup-handler.ts +179 -7
- package/src/core/error-handling.ts +0 -30
- package/src/core/flow-executor.ts +58 -76
- package/src/core/git-stash-manager.ts +50 -68
- package/src/core/project-manager.ts +12 -14
- package/src/core/session-manager.ts +28 -33
- package/src/core/state-detector.ts +4 -15
- package/src/core/target-resolver.ts +14 -9
- package/src/core/template-loader.ts +7 -33
- package/src/core/upgrade-manager.ts +4 -15
- package/src/index.ts +6 -35
- package/src/targets/claude-code.ts +16 -107
- package/src/targets/functional/claude-code-logic.ts +47 -103
- package/src/targets/opencode.ts +2 -158
- package/src/targets/shared/target-operations.ts +1 -54
- package/src/types/target.types.ts +4 -24
- package/src/utils/config/target-config.ts +8 -14
- package/src/utils/config/target-utils.ts +1 -50
- package/src/utils/files/sync-utils.ts +5 -5
- package/src/utils/object-utils.ts +10 -2
- package/src/utils/security/secret-utils.ts +2 -2
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,46 @@
|
|
|
1
1
|
# @sylphx/flow
|
|
2
2
|
|
|
3
|
+
## 3.19.0 (2026-02-07)
|
|
4
|
+
|
|
5
|
+
Performance, stability, and test coverage overhaul
|
|
6
|
+
|
|
7
|
+
**Performance:**
|
|
8
|
+
- Replace blocking `execSync('which')` with async `exec()` + `Promise.all()`
|
|
9
|
+
- Batch git operations (N shell spawns → 1 per operation)
|
|
10
|
+
- Parallelize I/O across startup path (`Promise.all` for mkdir, unlink, fs.cp)
|
|
11
|
+
- Skip heavy cleanup scans on startup when not needed (24h marker)
|
|
12
|
+
|
|
13
|
+
**Bug fixes:**
|
|
14
|
+
- Fix signal handler conflict causing storage accumulation (root cause: `process.exit(0)` preempted async cleanup)
|
|
15
|
+
- Fix skip-worktree flag leak on crash (read from git index instead of in-memory state)
|
|
16
|
+
- Fix secrets never cleared on session end
|
|
17
|
+
- Fix constructor ordering bug (`gitStashManager` used before initialized)
|
|
18
|
+
- Centralize all cleanup into CleanupHandler (signal, manual, crash recovery paths)
|
|
19
|
+
|
|
20
|
+
**Storage lifecycle:**
|
|
21
|
+
- Add orphaned project detection and cleanup
|
|
22
|
+
- Add session history pruning (keep last 50)
|
|
23
|
+
- Add periodic cleanup gating (at most once per 24h)
|
|
24
|
+
|
|
25
|
+
**Tests:**
|
|
26
|
+
- 30 → 81 tests (170% increase)
|
|
27
|
+
- New test suites: cleanup-handler, git-stash-manager, session-cleanup, secrets-manager
|
|
28
|
+
|
|
29
|
+
**Cleanup:**
|
|
30
|
+
- Remove 432 lines of dead code across 26 files
|
|
31
|
+
|
|
32
|
+
### ✨ Features
|
|
33
|
+
|
|
34
|
+
- **flow:** performance, stability, and test coverage overhaul ([16c461f](https://github.com/SylphxAI/flow/commit/16c461f509d800d7456d933d3153078ded6d5669))
|
|
35
|
+
|
|
36
|
+
### 🐛 Bug Fixes
|
|
37
|
+
|
|
38
|
+
- **flow:** inject env vars into spawned process instead of writing files ([51f037e](https://github.com/SylphxAI/flow/commit/51f037e0a5fed8b24271528efa9cc16f0409bb2a))
|
|
39
|
+
|
|
40
|
+
### 💅 Styles
|
|
41
|
+
|
|
42
|
+
- **flow:** format package.json with biome (tabs → spaces) ([3206303](https://github.com/SylphxAI/flow/commit/320630301d8b85813f96b36e737b5d0b01b23793))
|
|
43
|
+
|
|
3
44
|
## 3.18.0 (2026-02-07)
|
|
4
45
|
|
|
5
46
|
Enable agent teams by default for Claude Code
|
package/package.json
CHANGED
package/src/config/targets.ts
CHANGED
|
@@ -93,7 +93,7 @@ export const getDefaultTargetUnsafe = (): Target => {
|
|
|
93
93
|
* Get targets that support MCP servers
|
|
94
94
|
*/
|
|
95
95
|
export const getTargetsWithMCPSupport = (): readonly Target[] =>
|
|
96
|
-
getImplementedTargets().filter((target) => !!target.
|
|
96
|
+
getImplementedTargets().filter((target) => !!target.config.supportsMCP);
|
|
97
97
|
|
|
98
98
|
/**
|
|
99
99
|
* Get targets that support command execution (agent running)
|
|
@@ -47,7 +47,7 @@ describe('Backup → Attach → Restore Lifecycle', () => {
|
|
|
47
47
|
(projectManager as any).flowDataDir = flowDataDir;
|
|
48
48
|
|
|
49
49
|
backupManager = new BackupManager(projectManager);
|
|
50
|
-
attachManager = new AttachManager(
|
|
50
|
+
attachManager = new AttachManager();
|
|
51
51
|
|
|
52
52
|
// Get project hash
|
|
53
53
|
projectHash = projectManager.getProjectHash(projectPath);
|
|
@@ -0,0 +1,292 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Tests for CleanupHandler
|
|
3
|
+
* Covers: signal cleanup, crash recovery, orphaned project detection,
|
|
4
|
+
* periodic cleanup gating, and session history pruning coordination
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
import fs from 'node:fs';
|
|
8
|
+
import os from 'node:os';
|
|
9
|
+
import path from 'node:path';
|
|
10
|
+
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
|
11
|
+
import { CleanupHandler } from '../cleanup-handler.js';
|
|
12
|
+
|
|
13
|
+
// --- Mock factories ---
|
|
14
|
+
|
|
15
|
+
function createMockProjectManager(flowHome: string) {
|
|
16
|
+
return {
|
|
17
|
+
getFlowHomeDir: () => flowHome,
|
|
18
|
+
getProjectPaths: (hash: string) => ({
|
|
19
|
+
sessionFile: path.join(flowHome, 'sessions', `${hash}.json`),
|
|
20
|
+
backupsDir: path.join(flowHome, 'backups', hash),
|
|
21
|
+
secretsDir: path.join(flowHome, 'secrets', hash),
|
|
22
|
+
latestBackup: path.join(flowHome, 'backups', hash, 'latest'),
|
|
23
|
+
}),
|
|
24
|
+
};
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
function createMockSessionManager() {
|
|
28
|
+
return {
|
|
29
|
+
detectOrphanedSessions: vi.fn().mockResolvedValue(new Map()),
|
|
30
|
+
endSession: vi.fn().mockResolvedValue({ shouldRestore: false, session: null }),
|
|
31
|
+
recoverSession: vi.fn().mockResolvedValue(undefined),
|
|
32
|
+
getActiveSession: vi.fn().mockResolvedValue(null),
|
|
33
|
+
cleanupSessionHistory: vi.fn().mockResolvedValue(undefined),
|
|
34
|
+
};
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
function createMockBackupManager() {
|
|
38
|
+
return {
|
|
39
|
+
restoreBackup: vi.fn().mockResolvedValue(undefined),
|
|
40
|
+
cleanupOldBackups: vi.fn().mockResolvedValue(undefined),
|
|
41
|
+
};
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
function createMockGitStashManager() {
|
|
45
|
+
return {
|
|
46
|
+
popSettingsChanges: vi.fn().mockResolvedValue(undefined),
|
|
47
|
+
};
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
function createMockSecretsManager() {
|
|
51
|
+
return {
|
|
52
|
+
clearSecrets: vi.fn().mockResolvedValue(undefined),
|
|
53
|
+
};
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
describe('CleanupHandler', () => {
|
|
57
|
+
let tempDir: string;
|
|
58
|
+
let flowHome: string;
|
|
59
|
+
let handler: CleanupHandler;
|
|
60
|
+
let mockProjectManager: ReturnType<typeof createMockProjectManager>;
|
|
61
|
+
let mockSessionManager: ReturnType<typeof createMockSessionManager>;
|
|
62
|
+
let mockBackupManager: ReturnType<typeof createMockBackupManager>;
|
|
63
|
+
let mockGitStash: ReturnType<typeof createMockGitStashManager>;
|
|
64
|
+
let mockSecrets: ReturnType<typeof createMockSecretsManager>;
|
|
65
|
+
|
|
66
|
+
beforeEach(() => {
|
|
67
|
+
tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'flow-cleanup-test-'));
|
|
68
|
+
flowHome = path.join(tempDir, '.sylphx-flow');
|
|
69
|
+
fs.mkdirSync(path.join(flowHome, 'sessions'), { recursive: true });
|
|
70
|
+
fs.mkdirSync(path.join(flowHome, 'backups'), { recursive: true });
|
|
71
|
+
fs.mkdirSync(path.join(flowHome, 'secrets'), { recursive: true });
|
|
72
|
+
|
|
73
|
+
mockProjectManager = createMockProjectManager(flowHome);
|
|
74
|
+
mockSessionManager = createMockSessionManager();
|
|
75
|
+
mockBackupManager = createMockBackupManager();
|
|
76
|
+
mockGitStash = createMockGitStashManager();
|
|
77
|
+
mockSecrets = createMockSecretsManager();
|
|
78
|
+
|
|
79
|
+
handler = new CleanupHandler(
|
|
80
|
+
mockProjectManager as any,
|
|
81
|
+
mockSessionManager as any,
|
|
82
|
+
mockBackupManager as any,
|
|
83
|
+
mockGitStash as any,
|
|
84
|
+
mockSecrets as any
|
|
85
|
+
);
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
afterEach(() => {
|
|
89
|
+
if (tempDir && fs.existsSync(tempDir)) {
|
|
90
|
+
fs.rmSync(tempDir, { recursive: true, force: true });
|
|
91
|
+
}
|
|
92
|
+
});
|
|
93
|
+
|
|
94
|
+
// --- cleanup() ---
|
|
95
|
+
|
|
96
|
+
describe('cleanup()', () => {
|
|
97
|
+
it('should restore backup and clean up when last session ends', async () => {
|
|
98
|
+
const session = { sessionId: 'session-1', projectPath: '/tmp/project' };
|
|
99
|
+
mockSessionManager.endSession.mockResolvedValue({ shouldRestore: true, session });
|
|
100
|
+
|
|
101
|
+
await handler.cleanup('abc123');
|
|
102
|
+
|
|
103
|
+
expect(mockBackupManager.restoreBackup).toHaveBeenCalledWith('abc123', 'session-1');
|
|
104
|
+
expect(mockBackupManager.cleanupOldBackups).toHaveBeenCalledWith('abc123', 3);
|
|
105
|
+
expect(mockGitStash.popSettingsChanges).toHaveBeenCalledWith('/tmp/project');
|
|
106
|
+
expect(mockSecrets.clearSecrets).toHaveBeenCalledWith('abc123');
|
|
107
|
+
});
|
|
108
|
+
|
|
109
|
+
it('should not restore when other sessions still active', async () => {
|
|
110
|
+
mockSessionManager.endSession.mockResolvedValue({ shouldRestore: false, session: null });
|
|
111
|
+
|
|
112
|
+
await handler.cleanup('abc123');
|
|
113
|
+
|
|
114
|
+
expect(mockBackupManager.restoreBackup).not.toHaveBeenCalled();
|
|
115
|
+
expect(mockGitStash.popSettingsChanges).not.toHaveBeenCalled();
|
|
116
|
+
expect(mockSecrets.clearSecrets).not.toHaveBeenCalled();
|
|
117
|
+
});
|
|
118
|
+
});
|
|
119
|
+
|
|
120
|
+
// --- recoverOnStartup() ---
|
|
121
|
+
|
|
122
|
+
describe('recoverOnStartup()', () => {
|
|
123
|
+
it('should recover orphaned sessions', async () => {
|
|
124
|
+
const session = {
|
|
125
|
+
sessionId: 'session-crashed',
|
|
126
|
+
projectPath: '/tmp/crashed-project',
|
|
127
|
+
projectHash: 'hash1',
|
|
128
|
+
};
|
|
129
|
+
const orphaned = new Map([['hash1', session]]);
|
|
130
|
+
mockSessionManager.detectOrphanedSessions.mockResolvedValue(orphaned);
|
|
131
|
+
|
|
132
|
+
await handler.recoverOnStartup();
|
|
133
|
+
|
|
134
|
+
expect(mockBackupManager.restoreBackup).toHaveBeenCalledWith('hash1', 'session-crashed');
|
|
135
|
+
expect(mockSessionManager.recoverSession).toHaveBeenCalledWith('hash1', session);
|
|
136
|
+
expect(mockGitStash.popSettingsChanges).toHaveBeenCalledWith('/tmp/crashed-project');
|
|
137
|
+
expect(mockSecrets.clearSecrets).toHaveBeenCalledWith('hash1');
|
|
138
|
+
expect(mockBackupManager.cleanupOldBackups).toHaveBeenCalledWith('hash1', 3);
|
|
139
|
+
});
|
|
140
|
+
|
|
141
|
+
it('should handle multiple orphaned sessions independently', async () => {
|
|
142
|
+
const session1 = { sessionId: 's1', projectPath: '/p1' };
|
|
143
|
+
const session2 = { sessionId: 's2', projectPath: '/p2' };
|
|
144
|
+
const orphaned = new Map([
|
|
145
|
+
['h1', session1],
|
|
146
|
+
['h2', session2],
|
|
147
|
+
]);
|
|
148
|
+
mockSessionManager.detectOrphanedSessions.mockResolvedValue(orphaned);
|
|
149
|
+
|
|
150
|
+
// Make first recovery fail
|
|
151
|
+
mockBackupManager.restoreBackup
|
|
152
|
+
.mockRejectedValueOnce(new Error('restore failed'))
|
|
153
|
+
.mockResolvedValueOnce(undefined);
|
|
154
|
+
|
|
155
|
+
await handler.recoverOnStartup();
|
|
156
|
+
|
|
157
|
+
// Second session should still be recovered despite first failure
|
|
158
|
+
expect(mockBackupManager.restoreBackup).toHaveBeenCalledTimes(2);
|
|
159
|
+
expect(mockSessionManager.recoverSession).toHaveBeenCalledWith('h2', session2);
|
|
160
|
+
});
|
|
161
|
+
|
|
162
|
+
it('should skip heavy cleanup when marker is fresh', async () => {
|
|
163
|
+
// Create fresh marker (< 24h old)
|
|
164
|
+
const markerPath = path.join(flowHome, '.last-cleanup');
|
|
165
|
+
fs.writeFileSync(markerPath, new Date().toISOString());
|
|
166
|
+
|
|
167
|
+
await handler.recoverOnStartup();
|
|
168
|
+
|
|
169
|
+
// Session history and orphaned project cleanup should NOT run
|
|
170
|
+
expect(mockSessionManager.cleanupSessionHistory).not.toHaveBeenCalled();
|
|
171
|
+
});
|
|
172
|
+
|
|
173
|
+
it('should run heavy cleanup when marker is stale', async () => {
|
|
174
|
+
// Create stale marker (> 24h old)
|
|
175
|
+
const markerPath = path.join(flowHome, '.last-cleanup');
|
|
176
|
+
fs.writeFileSync(markerPath, '2020-01-01T00:00:00.000Z');
|
|
177
|
+
// Set mtime to past
|
|
178
|
+
const pastTime = new Date('2020-01-01T00:00:00.000Z');
|
|
179
|
+
fs.utimesSync(markerPath, pastTime, pastTime);
|
|
180
|
+
|
|
181
|
+
await handler.recoverOnStartup();
|
|
182
|
+
|
|
183
|
+
expect(mockSessionManager.cleanupSessionHistory).toHaveBeenCalledWith(50);
|
|
184
|
+
});
|
|
185
|
+
|
|
186
|
+
it('should run heavy cleanup when marker does not exist', async () => {
|
|
187
|
+
// No marker file — first run
|
|
188
|
+
await handler.recoverOnStartup();
|
|
189
|
+
|
|
190
|
+
expect(mockSessionManager.cleanupSessionHistory).toHaveBeenCalledWith(50);
|
|
191
|
+
});
|
|
192
|
+
|
|
193
|
+
it('should update marker after heavy cleanup', async () => {
|
|
194
|
+
await handler.recoverOnStartup();
|
|
195
|
+
|
|
196
|
+
const markerPath = path.join(flowHome, '.last-cleanup');
|
|
197
|
+
expect(fs.existsSync(markerPath)).toBe(true);
|
|
198
|
+
});
|
|
199
|
+
|
|
200
|
+
it('should do nothing when no orphaned sessions and cleanup not needed', async () => {
|
|
201
|
+
// Fresh marker
|
|
202
|
+
const markerPath = path.join(flowHome, '.last-cleanup');
|
|
203
|
+
fs.writeFileSync(markerPath, new Date().toISOString());
|
|
204
|
+
|
|
205
|
+
await handler.recoverOnStartup();
|
|
206
|
+
|
|
207
|
+
expect(mockBackupManager.restoreBackup).not.toHaveBeenCalled();
|
|
208
|
+
expect(mockSessionManager.cleanupSessionHistory).not.toHaveBeenCalled();
|
|
209
|
+
});
|
|
210
|
+
});
|
|
211
|
+
|
|
212
|
+
// --- Orphaned project cleanup ---
|
|
213
|
+
|
|
214
|
+
describe('cleanupOrphanedProjects (via recoverOnStartup)', () => {
|
|
215
|
+
it('should clean up projects whose paths no longer exist', async () => {
|
|
216
|
+
// Create orphaned project data (backups dir exists, but project path is gone)
|
|
217
|
+
const orphanHash = 'deadbeef12345678';
|
|
218
|
+
const backupDir = path.join(flowHome, 'backups', orphanHash, 'session-1000');
|
|
219
|
+
fs.mkdirSync(backupDir, { recursive: true });
|
|
220
|
+
|
|
221
|
+
// Write manifest pointing to non-existent path
|
|
222
|
+
const manifest = {
|
|
223
|
+
sessionId: 'session-1000',
|
|
224
|
+
projectPath: '/nonexistent/project/path',
|
|
225
|
+
target: 'claude-code',
|
|
226
|
+
};
|
|
227
|
+
fs.writeFileSync(path.join(backupDir, 'manifest.json'), JSON.stringify(manifest));
|
|
228
|
+
|
|
229
|
+
// Create secrets
|
|
230
|
+
const secretsDir = path.join(flowHome, 'secrets', orphanHash);
|
|
231
|
+
fs.mkdirSync(secretsDir, { recursive: true });
|
|
232
|
+
fs.writeFileSync(path.join(secretsDir, 'mcp-env.json'), '{}');
|
|
233
|
+
|
|
234
|
+
await handler.recoverOnStartup();
|
|
235
|
+
|
|
236
|
+
// Orphaned data should be cleaned up
|
|
237
|
+
expect(fs.existsSync(path.join(flowHome, 'backups', orphanHash))).toBe(false);
|
|
238
|
+
expect(fs.existsSync(path.join(flowHome, 'secrets', orphanHash))).toBe(false);
|
|
239
|
+
});
|
|
240
|
+
|
|
241
|
+
it('should NOT clean up projects with active sessions', async () => {
|
|
242
|
+
const activeHash = 'active12345678';
|
|
243
|
+
const backupDir = path.join(flowHome, 'backups', activeHash, 'session-2000');
|
|
244
|
+
fs.mkdirSync(backupDir, { recursive: true });
|
|
245
|
+
fs.writeFileSync(
|
|
246
|
+
path.join(backupDir, 'manifest.json'),
|
|
247
|
+
JSON.stringify({ projectPath: '/nonexistent', sessionId: 'session-2000' })
|
|
248
|
+
);
|
|
249
|
+
|
|
250
|
+
// Mock: this project has an active session
|
|
251
|
+
mockSessionManager.getActiveSession.mockImplementation(async (hash: string) => {
|
|
252
|
+
if (hash === activeHash) {
|
|
253
|
+
return { projectHash: activeHash };
|
|
254
|
+
}
|
|
255
|
+
return null;
|
|
256
|
+
});
|
|
257
|
+
|
|
258
|
+
await handler.recoverOnStartup();
|
|
259
|
+
|
|
260
|
+
// Should NOT be cleaned up (active session exists)
|
|
261
|
+
expect(fs.existsSync(path.join(flowHome, 'backups', activeHash))).toBe(true);
|
|
262
|
+
});
|
|
263
|
+
|
|
264
|
+
it('should clean up backup dirs with no manifest', async () => {
|
|
265
|
+
const orphanHash = 'nomanifest1234';
|
|
266
|
+
const backupDir = path.join(flowHome, 'backups', orphanHash);
|
|
267
|
+
fs.mkdirSync(backupDir, { recursive: true });
|
|
268
|
+
// No session subdirectories, no manifest
|
|
269
|
+
|
|
270
|
+
await handler.recoverOnStartup();
|
|
271
|
+
|
|
272
|
+
expect(fs.existsSync(backupDir)).toBe(false);
|
|
273
|
+
});
|
|
274
|
+
|
|
275
|
+
it('should NOT clean up projects whose paths still exist', async () => {
|
|
276
|
+
const validHash = 'validproject123';
|
|
277
|
+
const backupDir = path.join(flowHome, 'backups', validHash, 'session-3000');
|
|
278
|
+
fs.mkdirSync(backupDir, { recursive: true });
|
|
279
|
+
|
|
280
|
+
// Point manifest to a path that exists (the temp dir itself)
|
|
281
|
+
fs.writeFileSync(
|
|
282
|
+
path.join(backupDir, 'manifest.json'),
|
|
283
|
+
JSON.stringify({ projectPath: tempDir, sessionId: 'session-3000' })
|
|
284
|
+
);
|
|
285
|
+
|
|
286
|
+
await handler.recoverOnStartup();
|
|
287
|
+
|
|
288
|
+
// Should NOT be cleaned up (project path exists)
|
|
289
|
+
expect(fs.existsSync(path.join(flowHome, 'backups', validHash))).toBe(true);
|
|
290
|
+
});
|
|
291
|
+
});
|
|
292
|
+
});
|
|
@@ -0,0 +1,246 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Tests for GitStashManager
|
|
3
|
+
* Covers: batched git operations, crash-safe skip-worktree detection
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
import { exec } from 'node:child_process';
|
|
7
|
+
import fs from 'node:fs';
|
|
8
|
+
import os from 'node:os';
|
|
9
|
+
import path from 'node:path';
|
|
10
|
+
import { promisify } from 'node:util';
|
|
11
|
+
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
|
|
12
|
+
import { GitStashManager } from '../git-stash-manager.js';
|
|
13
|
+
|
|
14
|
+
const execAsync = promisify(exec);
|
|
15
|
+
|
|
16
|
+
describe('GitStashManager', () => {
|
|
17
|
+
let tempDir: string;
|
|
18
|
+
let manager: GitStashManager;
|
|
19
|
+
|
|
20
|
+
beforeEach(async () => {
|
|
21
|
+
tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'flow-git-test-'));
|
|
22
|
+
manager = new GitStashManager();
|
|
23
|
+
|
|
24
|
+
// Initialize git repo
|
|
25
|
+
await execAsync('git init', { cwd: tempDir });
|
|
26
|
+
await execAsync('git config user.email "test@test.com"', { cwd: tempDir });
|
|
27
|
+
await execAsync('git config user.name "Test"', { cwd: tempDir });
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
afterEach(() => {
|
|
31
|
+
if (tempDir && fs.existsSync(tempDir)) {
|
|
32
|
+
fs.rmSync(tempDir, { recursive: true, force: true });
|
|
33
|
+
}
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
describe('isGitRepo()', () => {
|
|
37
|
+
it('should detect git repository', async () => {
|
|
38
|
+
expect(await manager.isGitRepo(tempDir)).toBe(true);
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
it('should return false for non-git directory', async () => {
|
|
42
|
+
const nonGitDir = fs.mkdtempSync(path.join(os.tmpdir(), 'flow-nongit-'));
|
|
43
|
+
try {
|
|
44
|
+
expect(await manager.isGitRepo(nonGitDir)).toBe(false);
|
|
45
|
+
} finally {
|
|
46
|
+
fs.rmSync(nonGitDir, { recursive: true, force: true });
|
|
47
|
+
}
|
|
48
|
+
});
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
describe('getTrackedSettingsFiles()', () => {
|
|
52
|
+
it('should return tracked .claude files', async () => {
|
|
53
|
+
// Create and track .claude files
|
|
54
|
+
const claudeDir = path.join(tempDir, '.claude');
|
|
55
|
+
fs.mkdirSync(claudeDir, { recursive: true });
|
|
56
|
+
fs.writeFileSync(path.join(claudeDir, 'settings.json'), '{}');
|
|
57
|
+
|
|
58
|
+
await execAsync('git add .claude/settings.json', { cwd: tempDir });
|
|
59
|
+
await execAsync('git commit -m "init"', { cwd: tempDir });
|
|
60
|
+
|
|
61
|
+
const files = await manager.getTrackedSettingsFiles(tempDir);
|
|
62
|
+
expect(files).toContain('.claude/settings.json');
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
it('should return empty for repos with no settings files', async () => {
|
|
66
|
+
// Create an initial commit with unrelated file
|
|
67
|
+
fs.writeFileSync(path.join(tempDir, 'README.md'), '# Test');
|
|
68
|
+
await execAsync('git add README.md', { cwd: tempDir });
|
|
69
|
+
await execAsync('git commit -m "init"', { cwd: tempDir });
|
|
70
|
+
|
|
71
|
+
const files = await manager.getTrackedSettingsFiles(tempDir);
|
|
72
|
+
expect(files).toEqual([]);
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
it('should handle both .claude and .opencode files', async () => {
|
|
76
|
+
// Create and track files in both directories
|
|
77
|
+
const claudeDir = path.join(tempDir, '.claude');
|
|
78
|
+
const opencodeDir = path.join(tempDir, '.opencode');
|
|
79
|
+
fs.mkdirSync(claudeDir, { recursive: true });
|
|
80
|
+
fs.mkdirSync(opencodeDir, { recursive: true });
|
|
81
|
+
fs.writeFileSync(path.join(claudeDir, 'settings.json'), '{}');
|
|
82
|
+
fs.writeFileSync(path.join(opencodeDir, 'config.json'), '{}');
|
|
83
|
+
|
|
84
|
+
await execAsync('git add .claude .opencode', { cwd: tempDir });
|
|
85
|
+
await execAsync('git commit -m "init"', { cwd: tempDir });
|
|
86
|
+
|
|
87
|
+
const files = await manager.getTrackedSettingsFiles(tempDir);
|
|
88
|
+
expect(files).toContain('.claude/settings.json');
|
|
89
|
+
expect(files).toContain('.opencode/config.json');
|
|
90
|
+
});
|
|
91
|
+
});
|
|
92
|
+
|
|
93
|
+
describe('stashSettingsChanges() — batched', () => {
|
|
94
|
+
it('should mark tracked files as skip-worktree in a single command', async () => {
|
|
95
|
+
// Create and track multiple .claude files
|
|
96
|
+
const claudeDir = path.join(tempDir, '.claude');
|
|
97
|
+
fs.mkdirSync(claudeDir, { recursive: true });
|
|
98
|
+
fs.writeFileSync(path.join(claudeDir, 'settings.json'), '{}');
|
|
99
|
+
fs.writeFileSync(path.join(claudeDir, 'config.json'), '{}');
|
|
100
|
+
|
|
101
|
+
await execAsync('git add .claude', { cwd: tempDir });
|
|
102
|
+
await execAsync('git commit -m "init"', { cwd: tempDir });
|
|
103
|
+
|
|
104
|
+
await manager.stashSettingsChanges(tempDir);
|
|
105
|
+
|
|
106
|
+
// Verify skip-worktree flags are set
|
|
107
|
+
const { stdout } = await execAsync('git ls-files -v .claude', { cwd: tempDir });
|
|
108
|
+
const lines = stdout.trim().split('\n');
|
|
109
|
+
const skippedFiles = lines.filter((l) => l.startsWith('S '));
|
|
110
|
+
expect(skippedFiles.length).toBe(2);
|
|
111
|
+
});
|
|
112
|
+
|
|
113
|
+
it('should do nothing in non-git directory', async () => {
|
|
114
|
+
const nonGitDir = fs.mkdtempSync(path.join(os.tmpdir(), 'flow-nongit-'));
|
|
115
|
+
try {
|
|
116
|
+
// Should not throw
|
|
117
|
+
await manager.stashSettingsChanges(nonGitDir); // should not throw
|
|
118
|
+
} finally {
|
|
119
|
+
fs.rmSync(nonGitDir, { recursive: true, force: true });
|
|
120
|
+
}
|
|
121
|
+
});
|
|
122
|
+
|
|
123
|
+
it('should do nothing when no tracked settings files exist', async () => {
|
|
124
|
+
fs.writeFileSync(path.join(tempDir, 'README.md'), '# Test');
|
|
125
|
+
await execAsync('git add README.md', { cwd: tempDir });
|
|
126
|
+
await execAsync('git commit -m "init"', { cwd: tempDir });
|
|
127
|
+
|
|
128
|
+
await manager.stashSettingsChanges(tempDir); // should not throw
|
|
129
|
+
});
|
|
130
|
+
});
|
|
131
|
+
|
|
132
|
+
describe('popSettingsChanges() — crash-safe', () => {
|
|
133
|
+
it('should detect and remove skip-worktree flags from git index', async () => {
|
|
134
|
+
// Setup: create tracked file and set skip-worktree
|
|
135
|
+
const claudeDir = path.join(tempDir, '.claude');
|
|
136
|
+
fs.mkdirSync(claudeDir, { recursive: true });
|
|
137
|
+
fs.writeFileSync(path.join(claudeDir, 'settings.json'), '{}');
|
|
138
|
+
|
|
139
|
+
await execAsync('git add .claude', { cwd: tempDir });
|
|
140
|
+
await execAsync('git commit -m "init"', { cwd: tempDir });
|
|
141
|
+
await execAsync('git update-index --skip-worktree .claude/settings.json', { cwd: tempDir });
|
|
142
|
+
|
|
143
|
+
// Verify flag is set
|
|
144
|
+
const { stdout: before } = await execAsync('git ls-files -v .claude', { cwd: tempDir });
|
|
145
|
+
expect(before.trim()).toMatch(/^S /);
|
|
146
|
+
|
|
147
|
+
// Pop should detect and remove the flag
|
|
148
|
+
await manager.popSettingsChanges(tempDir);
|
|
149
|
+
|
|
150
|
+
// Verify flag is removed
|
|
151
|
+
const { stdout: after } = await execAsync('git ls-files -v .claude', { cwd: tempDir });
|
|
152
|
+
expect(after.trim()).not.toMatch(/^S /);
|
|
153
|
+
});
|
|
154
|
+
|
|
155
|
+
it('should work without in-memory state (crash recovery scenario)', async () => {
|
|
156
|
+
// Setup: create tracked file and set skip-worktree via raw git command
|
|
157
|
+
// (simulating flags left by a crashed process — manager has no in-memory state)
|
|
158
|
+
const claudeDir = path.join(tempDir, '.claude');
|
|
159
|
+
fs.mkdirSync(claudeDir, { recursive: true });
|
|
160
|
+
fs.writeFileSync(path.join(claudeDir, 'settings.json'), '{}');
|
|
161
|
+
|
|
162
|
+
await execAsync('git add .claude', { cwd: tempDir });
|
|
163
|
+
await execAsync('git commit -m "init"', { cwd: tempDir });
|
|
164
|
+
|
|
165
|
+
// Set skip-worktree directly (not through our manager)
|
|
166
|
+
await execAsync('git update-index --skip-worktree .claude/settings.json', { cwd: tempDir });
|
|
167
|
+
|
|
168
|
+
// Create a FRESH manager instance (simulating restart after crash)
|
|
169
|
+
const freshManager = new GitStashManager();
|
|
170
|
+
|
|
171
|
+
// Should detect and remove the flag from git index
|
|
172
|
+
await freshManager.popSettingsChanges(tempDir);
|
|
173
|
+
|
|
174
|
+
const { stdout } = await execAsync('git ls-files -v .claude', { cwd: tempDir });
|
|
175
|
+
expect(stdout.trim()).not.toMatch(/^S /);
|
|
176
|
+
});
|
|
177
|
+
|
|
178
|
+
it('should handle multiple flagged files in a single batch', async () => {
|
|
179
|
+
const claudeDir = path.join(tempDir, '.claude');
|
|
180
|
+
fs.mkdirSync(claudeDir, { recursive: true });
|
|
181
|
+
fs.writeFileSync(path.join(claudeDir, 'settings.json'), '{}');
|
|
182
|
+
fs.writeFileSync(path.join(claudeDir, 'config.json'), '{}');
|
|
183
|
+
|
|
184
|
+
await execAsync('git add .claude', { cwd: tempDir });
|
|
185
|
+
await execAsync('git commit -m "init"', { cwd: tempDir });
|
|
186
|
+
|
|
187
|
+
// Set skip-worktree on both files
|
|
188
|
+
await execAsync('git update-index --skip-worktree .claude/settings.json .claude/config.json', { cwd: tempDir });
|
|
189
|
+
|
|
190
|
+
await manager.popSettingsChanges(tempDir);
|
|
191
|
+
|
|
192
|
+
// Both flags should be removed
|
|
193
|
+
const { stdout } = await execAsync('git ls-files -v .claude', { cwd: tempDir });
|
|
194
|
+
const skippedCount = stdout
|
|
195
|
+
.trim()
|
|
196
|
+
.split('\n')
|
|
197
|
+
.filter((l) => l.startsWith('S ')).length;
|
|
198
|
+
expect(skippedCount).toBe(0);
|
|
199
|
+
});
|
|
200
|
+
|
|
201
|
+
it('should do nothing when no skip-worktree flags exist', async () => {
|
|
202
|
+
fs.writeFileSync(path.join(tempDir, 'README.md'), '# Test');
|
|
203
|
+
await execAsync('git add README.md', { cwd: tempDir });
|
|
204
|
+
await execAsync('git commit -m "init"', { cwd: tempDir });
|
|
205
|
+
|
|
206
|
+
await manager.popSettingsChanges(tempDir); // should not throw
|
|
207
|
+
});
|
|
208
|
+
|
|
209
|
+
it('should do nothing in non-git directory', async () => {
|
|
210
|
+
const nonGitDir = fs.mkdtempSync(path.join(os.tmpdir(), 'flow-nongit-'));
|
|
211
|
+
try {
|
|
212
|
+
await manager.popSettingsChanges(nonGitDir); // should not throw
|
|
213
|
+
} finally {
|
|
214
|
+
fs.rmSync(nonGitDir, { recursive: true, force: true });
|
|
215
|
+
}
|
|
216
|
+
});
|
|
217
|
+
});
|
|
218
|
+
|
|
219
|
+
describe('Full stash/pop lifecycle', () => {
|
|
220
|
+
it('should stash and pop without leaving artifacts', async () => {
|
|
221
|
+
const claudeDir = path.join(tempDir, '.claude');
|
|
222
|
+
fs.mkdirSync(claudeDir, { recursive: true });
|
|
223
|
+
fs.writeFileSync(path.join(claudeDir, 'settings.json'), '{}');
|
|
224
|
+
|
|
225
|
+
await execAsync('git add .claude', { cwd: tempDir });
|
|
226
|
+
await execAsync('git commit -m "init"', { cwd: tempDir });
|
|
227
|
+
|
|
228
|
+
// Stash
|
|
229
|
+
await manager.stashSettingsChanges(tempDir);
|
|
230
|
+
|
|
231
|
+
// Modify the file (simulating Flow's changes)
|
|
232
|
+
fs.writeFileSync(path.join(claudeDir, 'settings.json'), '{"modified": true}');
|
|
233
|
+
|
|
234
|
+
// File should NOT show in git status (skip-worktree hides it)
|
|
235
|
+
const { stdout: status } = await execAsync('git status --porcelain', { cwd: tempDir });
|
|
236
|
+
expect(status.trim()).toBe('');
|
|
237
|
+
|
|
238
|
+
// Pop
|
|
239
|
+
await manager.popSettingsChanges(tempDir);
|
|
240
|
+
|
|
241
|
+
// File SHOULD now show in git status (flag removed)
|
|
242
|
+
const { stdout: statusAfter } = await execAsync('git status --porcelain', { cwd: tempDir });
|
|
243
|
+
expect(statusAfter.trim()).not.toBe('');
|
|
244
|
+
});
|
|
245
|
+
});
|
|
246
|
+
});
|