tlc-claude-code 1.8.5 → 2.1.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/.claude/commands/tlc/bootstrap.md +77 -0
- package/.claude/commands/tlc/build.md +20 -6
- package/.claude/commands/tlc/deploy.md +194 -2
- package/.claude/commands/tlc/e2e-verify.md +214 -0
- package/.claude/commands/tlc/guard.md +191 -0
- package/.claude/commands/tlc/help.md +32 -0
- package/.claude/commands/tlc/init.md +73 -37
- package/.claude/commands/tlc/llm.md +19 -4
- package/.claude/commands/tlc/preflight.md +134 -0
- package/.claude/commands/tlc/recall.md +87 -0
- package/.claude/commands/tlc/remember.md +71 -0
- package/.claude/commands/tlc/review.md +17 -4
- package/.claude/commands/tlc/watchci.md +159 -0
- package/.claude/hooks/tlc-block-tools.sh +41 -0
- package/.claude/hooks/tlc-capture-exchange.sh +50 -0
- package/.claude/hooks/tlc-post-build.sh +38 -0
- package/.claude/hooks/tlc-post-push.sh +22 -0
- package/.claude/hooks/tlc-prompt-guard.sh +69 -0
- package/.claude/hooks/tlc-session-init.sh +123 -0
- package/CLAUDE.md +96 -201
- package/bin/install.js +171 -2
- package/bin/postinstall.js +45 -26
- package/dashboard-web/dist/assets/index-CdS5CHqu.css +1 -0
- package/dashboard-web/dist/assets/index-CwNPPVpg.js +483 -0
- package/dashboard-web/dist/assets/index-CwNPPVpg.js.map +1 -0
- package/dashboard-web/dist/index.html +2 -2
- package/docker-compose.dev.yml +18 -12
- package/package.json +3 -1
- package/server/index.js +240 -1
- package/server/lib/bug-writer.js +204 -0
- package/server/lib/bug-writer.test.js +279 -0
- package/server/lib/capture-bridge.js +242 -0
- package/server/lib/capture-bridge.test.js +363 -0
- package/server/lib/capture-guard.js +140 -0
- package/server/lib/capture-guard.test.js +182 -0
- package/server/lib/claude-cascade.js +247 -0
- package/server/lib/claude-cascade.test.js +245 -0
- package/server/lib/command-runner.js +159 -0
- package/server/lib/command-runner.test.js +92 -0
- package/server/lib/context-injection.js +121 -0
- package/server/lib/context-injection.test.js +340 -0
- package/server/lib/conversation-chunker.js +320 -0
- package/server/lib/conversation-chunker.test.js +573 -0
- package/server/lib/deploy/runners/dependency-runner.js +106 -0
- package/server/lib/deploy/runners/dependency-runner.test.js +148 -0
- package/server/lib/deploy/runners/secrets-runner.js +174 -0
- package/server/lib/deploy/runners/secrets-runner.test.js +127 -0
- package/server/lib/deploy/security-gates.js +11 -24
- package/server/lib/deploy/security-gates.test.js +9 -2
- package/server/lib/deploy-engine.js +182 -0
- package/server/lib/deploy-engine.test.js +147 -0
- package/server/lib/docker-api.js +137 -0
- package/server/lib/docker-api.test.js +202 -0
- package/server/lib/docker-client.js +297 -0
- package/server/lib/docker-client.test.js +308 -0
- package/server/lib/embedding-client.js +160 -0
- package/server/lib/embedding-client.test.js +243 -0
- package/server/lib/global-config.js +198 -0
- package/server/lib/global-config.test.js +288 -0
- package/server/lib/inherited-search.js +184 -0
- package/server/lib/inherited-search.test.js +343 -0
- package/server/lib/input-sanitizer.js +86 -0
- package/server/lib/input-sanitizer.test.js +117 -0
- package/server/lib/launchd-agent.js +225 -0
- package/server/lib/launchd-agent.test.js +185 -0
- package/server/lib/memory-api.js +182 -0
- package/server/lib/memory-api.test.js +320 -0
- package/server/lib/memory-bridge-e2e.test.js +160 -0
- package/server/lib/memory-committer.js +18 -4
- package/server/lib/memory-committer.test.js +21 -0
- package/server/lib/memory-hooks-capture.test.js +415 -0
- package/server/lib/memory-hooks-integration.test.js +98 -0
- package/server/lib/memory-hooks.js +139 -0
- package/server/lib/memory-inheritance.js +179 -0
- package/server/lib/memory-inheritance.test.js +360 -0
- package/server/lib/memory-store-adapter.js +105 -0
- package/server/lib/memory-store-adapter.test.js +141 -0
- package/server/lib/memory-wiring-e2e.test.js +93 -0
- package/server/lib/nginx-config.js +114 -0
- package/server/lib/nginx-config.test.js +82 -0
- package/server/lib/ollama-health.js +91 -0
- package/server/lib/ollama-health.test.js +74 -0
- package/server/lib/plan-writer.js +196 -0
- package/server/lib/plan-writer.test.js +298 -0
- package/server/lib/port-guard.js +44 -0
- package/server/lib/port-guard.test.js +65 -0
- package/server/lib/project-scanner.js +302 -0
- package/server/lib/project-scanner.test.js +541 -0
- package/server/lib/project-status.js +302 -0
- package/server/lib/project-status.test.js +470 -0
- package/server/lib/projects-registry.js +237 -0
- package/server/lib/projects-registry.test.js +275 -0
- package/server/lib/recall-command.js +207 -0
- package/server/lib/recall-command.test.js +306 -0
- package/server/lib/remember-command.js +98 -0
- package/server/lib/remember-command.test.js +288 -0
- package/server/lib/rich-capture.js +221 -0
- package/server/lib/rich-capture.test.js +312 -0
- package/server/lib/roadmap-api.js +200 -0
- package/server/lib/roadmap-api.test.js +318 -0
- package/server/lib/security/crypto-utils.test.js +2 -2
- package/server/lib/semantic-recall.js +242 -0
- package/server/lib/semantic-recall.test.js +463 -0
- package/server/lib/setup-generator.js +315 -0
- package/server/lib/setup-generator.test.js +303 -0
- package/server/lib/ssh-client.js +184 -0
- package/server/lib/ssh-client.test.js +127 -0
- package/server/lib/test-inventory.js +112 -0
- package/server/lib/test-inventory.test.js +360 -0
- package/server/lib/vector-indexer.js +246 -0
- package/server/lib/vector-indexer.test.js +459 -0
- package/server/lib/vector-store.js +260 -0
- package/server/lib/vector-store.test.js +706 -0
- package/server/lib/vps-api.js +184 -0
- package/server/lib/vps-api.test.js +208 -0
- package/server/lib/vps-bootstrap.js +124 -0
- package/server/lib/vps-bootstrap.test.js +79 -0
- package/server/lib/vps-monitor.js +126 -0
- package/server/lib/vps-monitor.test.js +98 -0
- package/server/lib/workspace-api.js +992 -0
- package/server/lib/workspace-api.test.js +1217 -0
- package/server/lib/workspace-bootstrap.js +164 -0
- package/server/lib/workspace-bootstrap.test.js +503 -0
- package/server/lib/workspace-context.js +129 -0
- package/server/lib/workspace-context.test.js +214 -0
- package/server/lib/workspace-detector.js +162 -0
- package/server/lib/workspace-detector.test.js +193 -0
- package/server/lib/workspace-init.js +307 -0
- package/server/lib/workspace-init.test.js +244 -0
- package/server/lib/workspace-snapshot.js +236 -0
- package/server/lib/workspace-snapshot.test.js +444 -0
- package/server/lib/workspace-watcher.js +162 -0
- package/server/lib/workspace-watcher.test.js +257 -0
- package/server/package-lock.json +1306 -17
- package/server/package.json +7 -0
- package/dashboard-web/dist/assets/index-B1I_joSL.js +0 -393
- package/dashboard-web/dist/assets/index-B1I_joSL.js.map +0 -1
- package/dashboard-web/dist/assets/index-Trhg1C1Y.css +0 -1
|
@@ -0,0 +1,162 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Workspace Watcher - Auto-detect and register new repos in a workspace
|
|
3
|
+
*
|
|
4
|
+
* Scans a workspace root directory for new git repositories, registers them
|
|
5
|
+
* in the projects registry, and broadcasts WebSocket events on detection.
|
|
6
|
+
* Also detects removed repos (directories deleted from disk but still in registry).
|
|
7
|
+
*
|
|
8
|
+
* Task 5 (Phase 72): Auto-Detect & Register New Repos
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
import fs from 'fs';
|
|
12
|
+
import path from 'path';
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* Extracts the git remote origin URL from a repo's .git/config file.
|
|
16
|
+
* @param {string} repoPath - Absolute path to the repo directory
|
|
17
|
+
* @returns {string|null} The remote origin URL or null if not found
|
|
18
|
+
*/
|
|
19
|
+
function extractGitRemoteUrl(repoPath) {
|
|
20
|
+
const configPath = path.join(repoPath, '.git', 'config');
|
|
21
|
+
try {
|
|
22
|
+
const content = fs.readFileSync(configPath, 'utf-8');
|
|
23
|
+
const match = content.match(/\[remote\s+"origin"\][^[]*url\s*=\s*(.+)/);
|
|
24
|
+
if (match) {
|
|
25
|
+
return match[1].trim();
|
|
26
|
+
}
|
|
27
|
+
} catch {
|
|
28
|
+
// No config file or unreadable — return null
|
|
29
|
+
}
|
|
30
|
+
return null;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* Checks whether a directory contains a .git subdirectory.
|
|
35
|
+
* @param {string} dirPath - Absolute path to the directory
|
|
36
|
+
* @returns {boolean} True if a .git directory exists inside dirPath
|
|
37
|
+
*/
|
|
38
|
+
function isGitRepo(dirPath) {
|
|
39
|
+
try {
|
|
40
|
+
const gitDir = path.join(dirPath, '.git');
|
|
41
|
+
const stat = fs.statSync(gitDir);
|
|
42
|
+
return stat.isDirectory();
|
|
43
|
+
} catch {
|
|
44
|
+
return false;
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* Creates a workspace watcher that scans for new and removed git repositories.
|
|
50
|
+
*
|
|
51
|
+
* @param {object} options - Configuration options
|
|
52
|
+
* @param {object} options.registry - Projects registry with addProject/removeProject/listProjects
|
|
53
|
+
* @param {Function} [options.broadcast] - Optional WebSocket broadcast function
|
|
54
|
+
* @param {boolean} [options.enabled=true] - Whether the watcher is enabled
|
|
55
|
+
* @param {number} [options.debounceMs=500] - Debounce interval in milliseconds (for future fs.watch)
|
|
56
|
+
* @returns {object} Watcher with start(), stop(), and scan() methods
|
|
57
|
+
*/
|
|
58
|
+
export function createWorkspaceWatcher({ registry, broadcast, enabled = true, debounceMs = 500 } = {}) {
|
|
59
|
+
let watching = false;
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* Start watching the workspace root for filesystem changes.
|
|
63
|
+
* Placeholder for future fs.watch integration.
|
|
64
|
+
* @param {string} workspaceRoot - Absolute path to the workspace directory
|
|
65
|
+
*/
|
|
66
|
+
function start(workspaceRoot) {
|
|
67
|
+
watching = true;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/**
|
|
71
|
+
* Stop watching the workspace root.
|
|
72
|
+
*/
|
|
73
|
+
function stop() {
|
|
74
|
+
watching = false;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/**
|
|
78
|
+
* Scan the workspace root for new and removed git repositories.
|
|
79
|
+
*
|
|
80
|
+
* - Lists all subdirectories in workspaceRoot
|
|
81
|
+
* - For each directory with .git/: if not already registered, extract remote URL,
|
|
82
|
+
* call registry.addProject, and optionally broadcast a 'new-project' event
|
|
83
|
+
* - For each project in the registry that no longer exists on disk,
|
|
84
|
+
* call registry.removeProject
|
|
85
|
+
*
|
|
86
|
+
* @param {string} workspaceRoot - Absolute path to the workspace directory
|
|
87
|
+
* @returns {Promise<Array<{name: string, gitUrl: string|null}>>} Newly detected repos
|
|
88
|
+
*/
|
|
89
|
+
async function scan(workspaceRoot) {
|
|
90
|
+
if (!enabled) {
|
|
91
|
+
return [];
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
// Get currently registered projects
|
|
95
|
+
const existingProjects = registry.listProjects();
|
|
96
|
+
const existingNames = new Set(
|
|
97
|
+
(Array.isArray(existingProjects) ? existingProjects : []).map((p) => p.name)
|
|
98
|
+
);
|
|
99
|
+
|
|
100
|
+
const newRepos = [];
|
|
101
|
+
|
|
102
|
+
// Read directories in workspaceRoot
|
|
103
|
+
let entries;
|
|
104
|
+
try {
|
|
105
|
+
entries = fs.readdirSync(workspaceRoot, { withFileTypes: true });
|
|
106
|
+
} catch {
|
|
107
|
+
return [];
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
// Collect names of directories that exist on disk (with .git/)
|
|
111
|
+
const onDiskRepoNames = new Set();
|
|
112
|
+
|
|
113
|
+
for (const entry of entries) {
|
|
114
|
+
if (!entry.isDirectory()) continue;
|
|
115
|
+
|
|
116
|
+
const dirPath = path.join(workspaceRoot, entry.name);
|
|
117
|
+
|
|
118
|
+
if (!isGitRepo(dirPath)) continue;
|
|
119
|
+
|
|
120
|
+
onDiskRepoNames.add(entry.name);
|
|
121
|
+
|
|
122
|
+
// Skip repos already in the registry
|
|
123
|
+
if (existingNames.has(entry.name)) continue;
|
|
124
|
+
|
|
125
|
+
const gitUrl = extractGitRemoteUrl(dirPath) || null;
|
|
126
|
+
|
|
127
|
+
const projectInfo = {
|
|
128
|
+
name: entry.name,
|
|
129
|
+
gitUrl,
|
|
130
|
+
};
|
|
131
|
+
|
|
132
|
+
// Register with the projects registry
|
|
133
|
+
registry.addProject(workspaceRoot, projectInfo);
|
|
134
|
+
|
|
135
|
+
// Broadcast WebSocket event if broadcast function provided
|
|
136
|
+
if (typeof broadcast === 'function') {
|
|
137
|
+
broadcast({
|
|
138
|
+
type: 'new-project',
|
|
139
|
+
project: projectInfo,
|
|
140
|
+
});
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
newRepos.push(projectInfo);
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
// Detect removed repos: in registry but no longer on disk
|
|
147
|
+
const registeredProjects = Array.isArray(existingProjects) ? existingProjects : [];
|
|
148
|
+
for (const project of registeredProjects) {
|
|
149
|
+
if (!onDiskRepoNames.has(project.name)) {
|
|
150
|
+
registry.removeProject(workspaceRoot, project.name);
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
return newRepos;
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
return {
|
|
158
|
+
start,
|
|
159
|
+
stop,
|
|
160
|
+
scan,
|
|
161
|
+
};
|
|
162
|
+
}
|
|
@@ -0,0 +1,257 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Workspace Watcher Tests
|
|
3
|
+
* Tests for auto-detecting and registering new repos in a workspace.
|
|
4
|
+
*
|
|
5
|
+
* Task 5 (Phase 72): Auto-Detect & Register New Repos
|
|
6
|
+
* - Detects new directories with .git/ via scan()
|
|
7
|
+
* - Adds detected repos to projects.json via registry.addProject
|
|
8
|
+
* - Extracts git remote URL from new repos
|
|
9
|
+
* - Broadcasts WebSocket event when new project detected
|
|
10
|
+
* - Ignores directories without .git/
|
|
11
|
+
* - Configurable enable/disable
|
|
12
|
+
* - Handles rapid successive additions (multiple new repos)
|
|
13
|
+
* - Handles directory deletion (removed repos)
|
|
14
|
+
* - Returns list of newly detected repos from scan
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
import { describe, it, beforeEach, afterEach, expect, vi } from 'vitest';
|
|
18
|
+
import fs from 'fs';
|
|
19
|
+
import path from 'path';
|
|
20
|
+
import os from 'os';
|
|
21
|
+
import { createWorkspaceWatcher } from './workspace-watcher.js';
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* Creates a mock projects registry with vi.fn() methods.
|
|
25
|
+
* @returns {object} Mock registry
|
|
26
|
+
*/
|
|
27
|
+
function createMockRegistry() {
|
|
28
|
+
return {
|
|
29
|
+
load: vi.fn().mockReturnValue({ version: 1, projects: [] }),
|
|
30
|
+
save: vi.fn(),
|
|
31
|
+
addProject: vi.fn(),
|
|
32
|
+
removeProject: vi.fn(),
|
|
33
|
+
listProjects: vi.fn().mockReturnValue([]),
|
|
34
|
+
detectFromFilesystem: vi.fn().mockReturnValue([]),
|
|
35
|
+
};
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* Creates a fake git repo directory with a .git/ subdirectory.
|
|
40
|
+
* Optionally writes a git config file with a remote origin URL.
|
|
41
|
+
* @param {string} parentDir - Parent directory to create the repo in
|
|
42
|
+
* @param {string} repoName - Name of the repo directory
|
|
43
|
+
* @param {string} [remoteUrl] - Optional git remote origin URL
|
|
44
|
+
* @returns {string} Full path to the created repo directory
|
|
45
|
+
*/
|
|
46
|
+
function createFakeRepo(parentDir, repoName, remoteUrl) {
|
|
47
|
+
const repoPath = path.join(parentDir, repoName);
|
|
48
|
+
fs.mkdirSync(repoPath, { recursive: true });
|
|
49
|
+
fs.mkdirSync(path.join(repoPath, '.git'), { recursive: true });
|
|
50
|
+
if (remoteUrl) {
|
|
51
|
+
const configContent = [
|
|
52
|
+
'[remote "origin"]',
|
|
53
|
+
`\turl = ${remoteUrl}`,
|
|
54
|
+
'\tfetch = +refs/heads/*:refs/remotes/origin/*',
|
|
55
|
+
].join('\n');
|
|
56
|
+
fs.writeFileSync(path.join(repoPath, '.git', 'config'), configContent);
|
|
57
|
+
}
|
|
58
|
+
return repoPath;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* Creates a plain directory (not a git repo) inside a parent directory.
|
|
63
|
+
* @param {string} parentDir - Parent directory
|
|
64
|
+
* @param {string} dirName - Directory name
|
|
65
|
+
* @returns {string} Full path to the created directory
|
|
66
|
+
*/
|
|
67
|
+
function createPlainDir(parentDir, dirName) {
|
|
68
|
+
const dirPath = path.join(parentDir, dirName);
|
|
69
|
+
fs.mkdirSync(dirPath, { recursive: true });
|
|
70
|
+
return dirPath;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
describe('workspace-watcher', () => {
|
|
74
|
+
let tempDir;
|
|
75
|
+
let registry;
|
|
76
|
+
let broadcast;
|
|
77
|
+
|
|
78
|
+
beforeEach(() => {
|
|
79
|
+
tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'workspace-watcher-test-'));
|
|
80
|
+
registry = createMockRegistry();
|
|
81
|
+
broadcast = vi.fn();
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
afterEach(() => {
|
|
85
|
+
fs.rmSync(tempDir, { recursive: true, force: true });
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
describe('scan detection', () => {
|
|
89
|
+
it('should detect new directory with .git/', async () => {
|
|
90
|
+
createFakeRepo(tempDir, 'my-new-repo');
|
|
91
|
+
|
|
92
|
+
const watcher = createWorkspaceWatcher({ registry, broadcast });
|
|
93
|
+
const newRepos = await watcher.scan(tempDir);
|
|
94
|
+
|
|
95
|
+
expect(newRepos).toHaveLength(1);
|
|
96
|
+
expect(newRepos[0]).toHaveProperty('name', 'my-new-repo');
|
|
97
|
+
});
|
|
98
|
+
|
|
99
|
+
it('should ignore directories without .git/', async () => {
|
|
100
|
+
createPlainDir(tempDir, 'not-a-repo');
|
|
101
|
+
createPlainDir(tempDir, 'also-not-a-repo');
|
|
102
|
+
createFakeRepo(tempDir, 'actual-repo');
|
|
103
|
+
|
|
104
|
+
const watcher = createWorkspaceWatcher({ registry, broadcast });
|
|
105
|
+
const newRepos = await watcher.scan(tempDir);
|
|
106
|
+
|
|
107
|
+
expect(newRepos).toHaveLength(1);
|
|
108
|
+
expect(newRepos[0]).toHaveProperty('name', 'actual-repo');
|
|
109
|
+
});
|
|
110
|
+
|
|
111
|
+
it('should return list of newly detected repos from scan', async () => {
|
|
112
|
+
createFakeRepo(tempDir, 'repo-alpha', 'git@github.com:user/alpha.git');
|
|
113
|
+
createFakeRepo(tempDir, 'repo-beta', 'https://github.com/user/beta.git');
|
|
114
|
+
|
|
115
|
+
const watcher = createWorkspaceWatcher({ registry, broadcast });
|
|
116
|
+
const newRepos = await watcher.scan(tempDir);
|
|
117
|
+
|
|
118
|
+
expect(newRepos).toHaveLength(2);
|
|
119
|
+
const names = newRepos.map((r) => r.name);
|
|
120
|
+
expect(names).toContain('repo-alpha');
|
|
121
|
+
expect(names).toContain('repo-beta');
|
|
122
|
+
});
|
|
123
|
+
});
|
|
124
|
+
|
|
125
|
+
describe('registry integration', () => {
|
|
126
|
+
it('should add detected repo to projects.json via registry.addProject', async () => {
|
|
127
|
+
createFakeRepo(tempDir, 'new-project', 'git@github.com:org/new-project.git');
|
|
128
|
+
|
|
129
|
+
const watcher = createWorkspaceWatcher({ registry, broadcast });
|
|
130
|
+
await watcher.scan(tempDir);
|
|
131
|
+
|
|
132
|
+
expect(registry.addProject).toHaveBeenCalledTimes(1);
|
|
133
|
+
expect(registry.addProject).toHaveBeenCalledWith(
|
|
134
|
+
tempDir,
|
|
135
|
+
expect.objectContaining({
|
|
136
|
+
name: 'new-project',
|
|
137
|
+
})
|
|
138
|
+
);
|
|
139
|
+
});
|
|
140
|
+
|
|
141
|
+
it('should extract git remote URL from new repo', async () => {
|
|
142
|
+
const remoteUrl = 'git@github.com:myorg/my-service.git';
|
|
143
|
+
createFakeRepo(tempDir, 'my-service', remoteUrl);
|
|
144
|
+
|
|
145
|
+
const watcher = createWorkspaceWatcher({ registry, broadcast });
|
|
146
|
+
const newRepos = await watcher.scan(tempDir);
|
|
147
|
+
|
|
148
|
+
expect(newRepos[0]).toHaveProperty('gitUrl', remoteUrl);
|
|
149
|
+
expect(registry.addProject).toHaveBeenCalledWith(
|
|
150
|
+
tempDir,
|
|
151
|
+
expect.objectContaining({
|
|
152
|
+
gitUrl: remoteUrl,
|
|
153
|
+
})
|
|
154
|
+
);
|
|
155
|
+
});
|
|
156
|
+
});
|
|
157
|
+
|
|
158
|
+
describe('WebSocket broadcast', () => {
|
|
159
|
+
it('should broadcast event when new project detected', async () => {
|
|
160
|
+
createFakeRepo(tempDir, 'detected-repo', 'git@github.com:org/detected.git');
|
|
161
|
+
|
|
162
|
+
const watcher = createWorkspaceWatcher({ registry, broadcast });
|
|
163
|
+
await watcher.scan(tempDir);
|
|
164
|
+
|
|
165
|
+
expect(broadcast).toHaveBeenCalledTimes(1);
|
|
166
|
+
expect(broadcast).toHaveBeenCalledWith(
|
|
167
|
+
expect.objectContaining({
|
|
168
|
+
type: 'new-project',
|
|
169
|
+
project: expect.objectContaining({
|
|
170
|
+
name: 'detected-repo',
|
|
171
|
+
}),
|
|
172
|
+
})
|
|
173
|
+
);
|
|
174
|
+
});
|
|
175
|
+
|
|
176
|
+
it('should work without broadcast function (optional)', async () => {
|
|
177
|
+
createFakeRepo(tempDir, 'no-broadcast-repo');
|
|
178
|
+
|
|
179
|
+
const watcher = createWorkspaceWatcher({ registry });
|
|
180
|
+
const newRepos = await watcher.scan(tempDir);
|
|
181
|
+
|
|
182
|
+
// Should not throw, should still detect
|
|
183
|
+
expect(newRepos).toHaveLength(1);
|
|
184
|
+
expect(registry.addProject).toHaveBeenCalledTimes(1);
|
|
185
|
+
});
|
|
186
|
+
});
|
|
187
|
+
|
|
188
|
+
describe('enable/disable configuration', () => {
|
|
189
|
+
it('should return empty when disabled', async () => {
|
|
190
|
+
createFakeRepo(tempDir, 'invisible-repo');
|
|
191
|
+
|
|
192
|
+
const watcher = createWorkspaceWatcher({
|
|
193
|
+
registry,
|
|
194
|
+
broadcast,
|
|
195
|
+
enabled: false,
|
|
196
|
+
});
|
|
197
|
+
const newRepos = await watcher.scan(tempDir);
|
|
198
|
+
|
|
199
|
+
expect(newRepos).toHaveLength(0);
|
|
200
|
+
expect(registry.addProject).not.toHaveBeenCalled();
|
|
201
|
+
expect(broadcast).not.toHaveBeenCalled();
|
|
202
|
+
});
|
|
203
|
+
|
|
204
|
+
it('should detect repos when enabled (default)', async () => {
|
|
205
|
+
createFakeRepo(tempDir, 'visible-repo');
|
|
206
|
+
|
|
207
|
+
const watcher = createWorkspaceWatcher({ registry, broadcast });
|
|
208
|
+
const newRepos = await watcher.scan(tempDir);
|
|
209
|
+
|
|
210
|
+
expect(newRepos).toHaveLength(1);
|
|
211
|
+
});
|
|
212
|
+
});
|
|
213
|
+
|
|
214
|
+
describe('multiple repos', () => {
|
|
215
|
+
it('should handle rapid successive additions (multiple new repos at once)', async () => {
|
|
216
|
+
createFakeRepo(tempDir, 'repo-one', 'git@github.com:org/one.git');
|
|
217
|
+
createFakeRepo(tempDir, 'repo-two', 'git@github.com:org/two.git');
|
|
218
|
+
createFakeRepo(tempDir, 'repo-three', 'git@github.com:org/three.git');
|
|
219
|
+
// Also add a non-repo directory to make sure it is skipped
|
|
220
|
+
createPlainDir(tempDir, 'some-folder');
|
|
221
|
+
|
|
222
|
+
const watcher = createWorkspaceWatcher({ registry, broadcast });
|
|
223
|
+
const newRepos = await watcher.scan(tempDir);
|
|
224
|
+
|
|
225
|
+
expect(newRepos).toHaveLength(3);
|
|
226
|
+
expect(registry.addProject).toHaveBeenCalledTimes(3);
|
|
227
|
+
expect(broadcast).toHaveBeenCalledTimes(3);
|
|
228
|
+
|
|
229
|
+
const names = newRepos.map((r) => r.name).sort();
|
|
230
|
+
expect(names).toEqual(['repo-one', 'repo-three', 'repo-two']);
|
|
231
|
+
});
|
|
232
|
+
});
|
|
233
|
+
|
|
234
|
+
describe('directory deletion', () => {
|
|
235
|
+
it('should detect removed repos when directory is deleted', async () => {
|
|
236
|
+
// Set up registry to report a project that no longer exists on disk
|
|
237
|
+
const missingProject = {
|
|
238
|
+
name: 'deleted-repo',
|
|
239
|
+
localPath: 'deleted-repo',
|
|
240
|
+
gitUrl: 'git@github.com:org/deleted.git',
|
|
241
|
+
};
|
|
242
|
+
registry.listProjects.mockReturnValue([missingProject]);
|
|
243
|
+
|
|
244
|
+
// The directory does NOT exist on disk — it was deleted
|
|
245
|
+
// (we do not create it in tempDir)
|
|
246
|
+
|
|
247
|
+
const watcher = createWorkspaceWatcher({ registry, broadcast });
|
|
248
|
+
const newRepos = await watcher.scan(tempDir);
|
|
249
|
+
|
|
250
|
+
// No new repos detected (nothing on disk with .git/)
|
|
251
|
+
expect(newRepos).toHaveLength(0);
|
|
252
|
+
|
|
253
|
+
// The watcher should call removeProject for the missing repo
|
|
254
|
+
expect(registry.removeProject).toHaveBeenCalledWith(tempDir, 'deleted-repo');
|
|
255
|
+
});
|
|
256
|
+
});
|
|
257
|
+
});
|