claudedesk 4.3.1 → 4.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (124) hide show
  1. package/.github/workflows/ci.yml +44 -2
  2. package/CLAUDE.md +36 -3
  3. package/PHASE_1_IMPLEMENTATION.md +313 -0
  4. package/PHASE_2_PARTIAL_IMPLEMENTATION.md +286 -0
  5. package/dist/main/cli-manager.js +67 -2
  6. package/dist/main/command-registry.js +196 -0
  7. package/dist/main/git-manager.js +841 -0
  8. package/dist/main/index.js +25 -1
  9. package/dist/main/ipc-handlers.js +347 -3
  10. package/dist/main/layout-presets-manager.js +233 -0
  11. package/dist/main/model-history-manager.js +187 -0
  12. package/dist/main/session-manager.js +83 -26
  13. package/dist/main/session-persistence.js +1 -0
  14. package/dist/main/session-pool.js +40 -9
  15. package/dist/main/settings-persistence.js +67 -12
  16. package/dist/renderer/assets/index-BNeYLqV4.css +32 -0
  17. package/dist/renderer/assets/index-D5O5Ljoo.js +17189 -0
  18. package/dist/renderer/index.html +2 -2
  19. package/dist/shared/ipc-contract.js +79 -0
  20. package/dist/shared/model-detector.js +83 -0
  21. package/dist/shared/types/command-types.js +5 -0
  22. package/dist/shared/types/git-types.js +2 -0
  23. package/dist/types/layout-presets.js +11 -0
  24. package/docs/git-integration-implementation-tasks.md +974 -0
  25. package/docs/git-integration-product-spec.md +916 -0
  26. package/docs/git-integration-ui-spec.md +1464 -0
  27. package/docs/repo-index.md +83 -8
  28. package/e2e/app-launch.spec.ts +31 -0
  29. package/e2e/fixtures/electron.ts +34 -0
  30. package/e2e/keyboard-shortcuts.spec.ts +50 -0
  31. package/e2e/session.spec.ts +34 -0
  32. package/e2e/split-view.spec.ts +21 -0
  33. package/package.json +16 -3
  34. package/playwright.config.ts +15 -0
  35. package/src/main/cli-manager.ts +74 -3
  36. package/src/main/command-registry.ts +221 -0
  37. package/src/main/git-manager.test.ts +374 -0
  38. package/src/main/git-manager.ts +909 -0
  39. package/src/main/index.ts +31 -1
  40. package/src/main/ipc-emitter.test.ts +60 -0
  41. package/src/main/ipc-handlers.ts +295 -3
  42. package/src/main/ipc-registry.test.ts +75 -0
  43. package/src/main/layout-presets-manager.ts +268 -0
  44. package/src/main/model-history-manager.ts +196 -0
  45. package/src/main/session-manager.ts +102 -30
  46. package/src/main/session-persistence.test.ts +215 -0
  47. package/src/main/session-persistence.ts +1 -0
  48. package/src/main/session-pool.ts +31 -9
  49. package/src/main/settings-persistence.ts +74 -12
  50. package/src/renderer/App.tsx +215 -43
  51. package/src/renderer/components/CustomLayoutBuilder.tsx +143 -0
  52. package/src/renderer/components/GitPanel.test.tsx +181 -0
  53. package/src/renderer/components/GitPanel.tsx +1407 -0
  54. package/src/renderer/components/LayoutPicker.tsx +182 -0
  55. package/src/renderer/components/LayoutPreviewCard.tsx +175 -0
  56. package/src/renderer/components/ModelHistoryPanel.tsx +435 -0
  57. package/src/renderer/components/PaneHeader.test.tsx +96 -0
  58. package/src/renderer/components/PaneHeader.tsx +28 -0
  59. package/src/renderer/components/SplitLayout.test.tsx +153 -0
  60. package/src/renderer/components/SplitLayout.tsx +36 -1
  61. package/src/renderer/components/Terminal.tsx +10 -10
  62. package/src/renderer/components/WelcomeWizard.tsx +143 -0
  63. package/src/renderer/components/WizardStepper.tsx +135 -0
  64. package/src/renderer/components/ui/ClaudeReadinessProgress.tsx +168 -0
  65. package/src/renderer/components/ui/CommitDialog.test.tsx +134 -0
  66. package/src/renderer/components/ui/CommitDialog.tsx +464 -0
  67. package/src/renderer/components/ui/EmptyState.test.tsx +87 -0
  68. package/src/renderer/components/ui/EmptyState.tsx +115 -86
  69. package/src/renderer/components/ui/FeatureShowcase.tsx +187 -0
  70. package/src/renderer/components/ui/FuelGaugeBar.tsx +59 -0
  71. package/src/renderer/components/ui/FuelStatusIndicator.tsx +358 -0
  72. package/src/renderer/components/ui/FuelTooltip.tsx +267 -0
  73. package/src/renderer/components/ui/HelpButton.tsx +43 -0
  74. package/src/renderer/components/ui/ModelBadge.tsx +72 -0
  75. package/src/renderer/components/ui/ModelSwitcher.tsx +180 -0
  76. package/src/renderer/components/ui/PanelFooter.tsx +90 -0
  77. package/src/renderer/components/ui/PanelHeader.tsx +87 -0
  78. package/src/renderer/components/ui/PanelHelpOverlay.tsx +274 -0
  79. package/src/renderer/components/ui/QuickActionCard.tsx +103 -0
  80. package/src/renderer/components/ui/RecentSessionsList.tsx +154 -0
  81. package/src/renderer/components/ui/SessionStatusIndicator.tsx +104 -0
  82. package/src/renderer/components/ui/SettingsDialog.tsx +94 -0
  83. package/src/renderer/components/ui/ShortcutsPanel.tsx +433 -0
  84. package/src/renderer/components/ui/StatusPopover.tsx +344 -0
  85. package/src/renderer/components/ui/TabBar.test.tsx +124 -0
  86. package/src/renderer/components/ui/TabBar.tsx +152 -168
  87. package/src/renderer/components/ui/ToolbarDropdown.tsx +227 -0
  88. package/src/renderer/components/ui/ToolsDropdown.tsx +119 -0
  89. package/src/renderer/components/ui/TooltipCoach.tsx +217 -0
  90. package/src/renderer/components/ui/WelcomeHero.tsx +85 -0
  91. package/src/renderer/components/ui/index.ts +5 -0
  92. package/src/renderer/components/wizard/Step1_Welcome.tsx +166 -0
  93. package/src/renderer/components/wizard/Step2_LayoutPicker.tsx +246 -0
  94. package/src/renderer/components/wizard/Step3_Features.tsx +278 -0
  95. package/src/renderer/components/wizard/Step4_Ready.tsx +279 -0
  96. package/src/renderer/hooks/useGit.test.ts +140 -0
  97. package/src/renderer/hooks/useGit.ts +395 -0
  98. package/src/renderer/hooks/useLayoutPicker.ts +77 -0
  99. package/src/renderer/hooks/useModelHistory.ts +69 -0
  100. package/src/renderer/hooks/useSessionManager.test.ts +146 -0
  101. package/src/renderer/hooks/useSessionManager.ts +5 -0
  102. package/src/renderer/hooks/useSplitView.test.ts +168 -0
  103. package/src/renderer/hooks/useSplitView.ts +126 -128
  104. package/src/renderer/styles/globals.css +505 -0
  105. package/src/renderer/utils/fuzzy-search.test.ts +121 -0
  106. package/src/renderer/utils/layout-tree.test.ts +310 -0
  107. package/src/renderer/utils/layout-tree.ts +170 -0
  108. package/src/renderer/utils/variable-resolver.test.ts +102 -0
  109. package/src/shared/ipc-contract.ts +157 -0
  110. package/src/shared/ipc-types.ts +52 -1
  111. package/src/shared/message-parser.test.ts +79 -0
  112. package/src/shared/model-detector.test.ts +90 -0
  113. package/src/shared/model-detector.ts +97 -0
  114. package/src/shared/types/command-types.ts +26 -0
  115. package/src/shared/types/git-types.ts +126 -0
  116. package/src/types/layout-presets.ts +22 -0
  117. package/test/helpers/electron-api-mock.ts +52 -0
  118. package/test/setup-main.ts +61 -0
  119. package/test/setup-renderer.ts +8 -0
  120. package/tsconfig.json +1 -0
  121. package/tsconfig.main.json +2 -1
  122. package/vitest.workspace.ts +37 -0
  123. package/dist/renderer/assets/index-CR22a7j2.css +0 -32
  124. package/dist/renderer/assets/index-Dp-eceNq.js +0 -13915
@@ -0,0 +1,841 @@
1
+ "use strict";
2
+ var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
3
+ if (k2 === undefined) k2 = k;
4
+ var desc = Object.getOwnPropertyDescriptor(m, k);
5
+ if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
6
+ desc = { enumerable: true, get: function() { return m[k]; } };
7
+ }
8
+ Object.defineProperty(o, k2, desc);
9
+ }) : (function(o, m, k, k2) {
10
+ if (k2 === undefined) k2 = k;
11
+ o[k2] = m[k];
12
+ }));
13
+ var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
14
+ Object.defineProperty(o, "default", { enumerable: true, value: v });
15
+ }) : function(o, v) {
16
+ o["default"] = v;
17
+ });
18
+ var __importStar = (this && this.__importStar) || (function () {
19
+ var ownKeys = function(o) {
20
+ ownKeys = Object.getOwnPropertyNames || function (o) {
21
+ var ar = [];
22
+ for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k;
23
+ return ar;
24
+ };
25
+ return ownKeys(o);
26
+ };
27
+ return function (mod) {
28
+ if (mod && mod.__esModule) return mod;
29
+ var result = {};
30
+ if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]);
31
+ __setModuleDefault(result, mod);
32
+ return result;
33
+ };
34
+ })();
35
+ Object.defineProperty(exports, "__esModule", { value: true });
36
+ exports.GitManager = void 0;
37
+ const child_process_1 = require("child_process");
38
+ const fs = __importStar(require("fs"));
39
+ const path = __importStar(require("path"));
40
+ const ipc_emitter_1 = require("./ipc-emitter");
41
+ class GitManager {
42
+ constructor(checkpointManager) {
43
+ this.emitter = null;
44
+ this.watchers = new Map();
45
+ this.debounceTimers = new Map();
46
+ this.mutexes = new Map();
47
+ this.gitBinary = null;
48
+ this.defaultTimeoutMs = 30000;
49
+ this.maxDiffSizeBytes = 102400; // 100KB
50
+ this.checkpointManager = checkpointManager || null;
51
+ this.detectGitBinary();
52
+ }
53
+ setMainWindow(window) {
54
+ this.emitter = new ipc_emitter_1.IPCEmitter(window);
55
+ }
56
+ destroy() {
57
+ for (const [workDir, watcher] of this.watchers) {
58
+ watcher.close();
59
+ const timer = this.debounceTimers.get(workDir);
60
+ if (timer)
61
+ clearTimeout(timer);
62
+ }
63
+ this.watchers.clear();
64
+ this.debounceTimers.clear();
65
+ this.mutexes.clear();
66
+ this.emitter = null;
67
+ }
68
+ // ── Git binary detection ──
69
+ detectGitBinary() {
70
+ const cmd = process.platform === 'win32' ? 'where' : 'which';
71
+ const arg = 'git';
72
+ try {
73
+ (0, child_process_1.execFile)(cmd, [arg], { timeout: 5000 }, (err, stdout) => {
74
+ if (!err && stdout.trim()) {
75
+ this.gitBinary = stdout.trim().split('\n')[0].trim();
76
+ console.log('[GitManager] Found git binary:', this.gitBinary);
77
+ }
78
+ else {
79
+ console.warn('[GitManager] Git binary not found');
80
+ this.gitBinary = null;
81
+ }
82
+ });
83
+ }
84
+ catch {
85
+ this.gitBinary = null;
86
+ }
87
+ }
88
+ // ── Low-level command execution ──
89
+ execGit(workDir, args, timeoutMs) {
90
+ return new Promise((resolve) => {
91
+ const binary = this.gitBinary || 'git';
92
+ const timeout = timeoutMs || this.defaultTimeoutMs;
93
+ (0, child_process_1.execFile)(binary, args, {
94
+ cwd: workDir,
95
+ timeout,
96
+ maxBuffer: 10 * 1024 * 1024, // 10MB
97
+ env: { ...process.env, GIT_TERMINAL_PROMPT: '0' },
98
+ }, (err, stdout, stderr) => {
99
+ const exitCode = err && 'code' in err ? err.code : (err ? 1 : 0);
100
+ resolve({
101
+ stdout: stdout || '',
102
+ stderr: stderr || '',
103
+ exitCode: typeof exitCode === 'number' ? exitCode : 1,
104
+ });
105
+ });
106
+ });
107
+ }
108
+ async withMutex(workDir, fn) {
109
+ const prev = this.mutexes.get(workDir) || Promise.resolve();
110
+ let resolve;
111
+ const next = new Promise((r) => { resolve = r; });
112
+ this.mutexes.set(workDir, next);
113
+ await prev;
114
+ try {
115
+ return await fn();
116
+ }
117
+ finally {
118
+ resolve();
119
+ }
120
+ }
121
+ detectErrorCode(stderr, exitCode) {
122
+ const msg = stderr.toLowerCase();
123
+ if (msg.includes('not a git repository'))
124
+ return 'NOT_A_REPO';
125
+ if (msg.includes('authentication failed') || msg.includes('could not read username'))
126
+ return 'AUTH_FAILED';
127
+ if (msg.includes('merge conflict') || msg.includes('unmerged'))
128
+ return 'MERGE_CONFLICTS';
129
+ if (msg.includes('no upstream') || msg.includes('has no upstream branch'))
130
+ return 'NO_UPSTREAM';
131
+ if (msg.includes('rejected') || msg.includes('non-fast-forward'))
132
+ return 'PUSH_REJECTED';
133
+ if (msg.includes('already exists'))
134
+ return 'BRANCH_EXISTS';
135
+ if (msg.includes('not found') || msg.includes('did not match'))
136
+ return 'BRANCH_NOT_FOUND';
137
+ if (msg.includes('uncommitted changes') || msg.includes('local changes'))
138
+ return 'UNCOMMITTED_CHANGES';
139
+ if (msg.includes('nothing to commit'))
140
+ return 'NOTHING_TO_COMMIT';
141
+ if (exitCode === 128)
142
+ return 'NOT_A_REPO';
143
+ return 'UNKNOWN';
144
+ }
145
+ makeError(stderr, exitCode) {
146
+ const errorCode = this.detectErrorCode(stderr, exitCode);
147
+ return {
148
+ success: false,
149
+ message: stderr.trim() || `Git operation failed (exit code ${exitCode})`,
150
+ errorCode,
151
+ };
152
+ }
153
+ // ── Status parsing (porcelain v2) ──
154
+ parseStatus(raw) {
155
+ const status = {
156
+ isRepo: true,
157
+ isDetached: false,
158
+ hasConflicts: false,
159
+ branch: null,
160
+ upstream: null,
161
+ ahead: 0,
162
+ behind: 0,
163
+ files: [],
164
+ stagedCount: 0,
165
+ unstagedCount: 0,
166
+ untrackedCount: 0,
167
+ conflictedCount: 0,
168
+ };
169
+ const lines = raw.split('\n');
170
+ for (const line of lines) {
171
+ if (!line)
172
+ continue;
173
+ // Branch headers
174
+ if (line.startsWith('# branch.head ')) {
175
+ const head = line.slice('# branch.head '.length);
176
+ if (head === '(detached)') {
177
+ status.isDetached = true;
178
+ status.branch = null;
179
+ }
180
+ else {
181
+ status.branch = head;
182
+ }
183
+ continue;
184
+ }
185
+ if (line.startsWith('# branch.upstream ')) {
186
+ status.upstream = line.slice('# branch.upstream '.length);
187
+ continue;
188
+ }
189
+ if (line.startsWith('# branch.ab ')) {
190
+ const match = line.match(/\+(\d+) -(\d+)/);
191
+ if (match) {
192
+ status.ahead = parseInt(match[1], 10);
193
+ status.behind = parseInt(match[2], 10);
194
+ }
195
+ continue;
196
+ }
197
+ // Changed entries (ordinary): 1 XY N1 N2 N3 hH hI path
198
+ if (line.startsWith('1 ')) {
199
+ const parts = line.split(' ');
200
+ if (parts.length >= 9) {
201
+ const xy = parts[1];
202
+ const filePath = parts.slice(8).join(' ');
203
+ const indexChar = xy[0];
204
+ const workChar = xy[1];
205
+ // Staged change
206
+ if (indexChar !== '.') {
207
+ status.files.push({
208
+ path: filePath,
209
+ originalPath: null,
210
+ indexStatus: this.charToStatus(indexChar),
211
+ workTreeStatus: this.charToStatus(workChar),
212
+ area: 'staged',
213
+ });
214
+ status.stagedCount++;
215
+ }
216
+ // Unstaged change (not already counted if also staged)
217
+ if (workChar !== '.' && indexChar === '.') {
218
+ status.files.push({
219
+ path: filePath,
220
+ originalPath: null,
221
+ indexStatus: this.charToStatus(indexChar),
222
+ workTreeStatus: this.charToStatus(workChar),
223
+ area: 'unstaged',
224
+ });
225
+ status.unstagedCount++;
226
+ }
227
+ else if (workChar !== '.' && indexChar !== '.') {
228
+ // Both staged and unstaged changes
229
+ status.files.push({
230
+ path: filePath,
231
+ originalPath: null,
232
+ indexStatus: this.charToStatus(indexChar),
233
+ workTreeStatus: this.charToStatus(workChar),
234
+ area: 'unstaged',
235
+ });
236
+ status.unstagedCount++;
237
+ }
238
+ }
239
+ continue;
240
+ }
241
+ // Renamed entries: 2 XY N1 N2 N3 hH hI Rxx path\torigPath
242
+ if (line.startsWith('2 ')) {
243
+ const parts = line.split('\t');
244
+ if (parts.length >= 2) {
245
+ const firstParts = parts[0].split(' ');
246
+ const xy = firstParts[1];
247
+ const filePath = parts[0].split(' ').slice(9).join(' ');
248
+ const originalPath = parts[1];
249
+ const indexChar = xy[0];
250
+ if (indexChar !== '.') {
251
+ status.files.push({
252
+ path: filePath,
253
+ originalPath,
254
+ indexStatus: 'renamed',
255
+ workTreeStatus: this.charToStatus(xy[1]),
256
+ area: 'staged',
257
+ });
258
+ status.stagedCount++;
259
+ }
260
+ }
261
+ continue;
262
+ }
263
+ // Unmerged entries: u XY N1 N2 N3 N4 h1 h2 h3 path
264
+ if (line.startsWith('u ')) {
265
+ const parts = line.split(' ');
266
+ if (parts.length >= 11) {
267
+ const filePath = parts.slice(10).join(' ');
268
+ status.files.push({
269
+ path: filePath,
270
+ originalPath: null,
271
+ indexStatus: 'unmerged',
272
+ workTreeStatus: 'unmerged',
273
+ area: 'conflicted',
274
+ });
275
+ status.conflictedCount++;
276
+ status.hasConflicts = true;
277
+ }
278
+ continue;
279
+ }
280
+ // Untracked entries: ? path
281
+ if (line.startsWith('? ')) {
282
+ const filePath = line.slice(2);
283
+ status.files.push({
284
+ path: filePath,
285
+ originalPath: null,
286
+ indexStatus: 'untracked',
287
+ workTreeStatus: 'untracked',
288
+ area: 'untracked',
289
+ });
290
+ status.untrackedCount++;
291
+ continue;
292
+ }
293
+ }
294
+ return status;
295
+ }
296
+ charToStatus(c) {
297
+ switch (c) {
298
+ case 'A': return 'added';
299
+ case 'M': return 'modified';
300
+ case 'D': return 'deleted';
301
+ case 'R': return 'renamed';
302
+ case 'C': return 'copied';
303
+ case '?': return 'untracked';
304
+ case '!': return 'ignored';
305
+ case 'U': return 'unmerged';
306
+ default: return 'modified';
307
+ }
308
+ }
309
+ // ── Branch parsing ──
310
+ parseBranches(raw) {
311
+ const branches = [];
312
+ const lines = raw.split('\n').filter(l => l.trim());
313
+ for (const line of lines) {
314
+ // Format: "branchname upstream:short upstream:track"
315
+ // e.g. "main origin/main [ahead 1]"
316
+ // or "* main origin/main [ahead 1, behind 2]"
317
+ const isCurrent = line.startsWith('* ');
318
+ const clean = isCurrent ? line.slice(2) : line;
319
+ const parts = clean.trim().split(/\s+/);
320
+ const name = parts[0];
321
+ if (!name)
322
+ continue;
323
+ let upstream = null;
324
+ let ahead = 0;
325
+ let behind = 0;
326
+ if (parts.length > 1 && parts[1] && !parts[1].startsWith('[')) {
327
+ upstream = parts[1];
328
+ }
329
+ // Parse ahead/behind from track info
330
+ const trackMatch = clean.match(/\[ahead (\d+)(?:, behind (\d+))?\]/);
331
+ if (trackMatch) {
332
+ ahead = parseInt(trackMatch[1], 10);
333
+ behind = trackMatch[2] ? parseInt(trackMatch[2], 10) : 0;
334
+ }
335
+ const behindMatch = clean.match(/\[behind (\d+)\]/);
336
+ if (behindMatch && ahead === 0) {
337
+ behind = parseInt(behindMatch[1], 10);
338
+ }
339
+ branches.push({ name, isCurrent, upstream, ahead, behind });
340
+ }
341
+ return branches;
342
+ }
343
+ // ── Public API: Core status ──
344
+ async getStatus(workDir) {
345
+ return this.withMutex(workDir, async () => {
346
+ // Check if it's a git repo
347
+ const check = await this.execGit(workDir, ['rev-parse', '--is-inside-work-tree']);
348
+ if (check.exitCode !== 0) {
349
+ return {
350
+ isRepo: false,
351
+ isDetached: false,
352
+ hasConflicts: false,
353
+ branch: null,
354
+ upstream: null,
355
+ ahead: 0,
356
+ behind: 0,
357
+ files: [],
358
+ stagedCount: 0,
359
+ unstagedCount: 0,
360
+ untrackedCount: 0,
361
+ conflictedCount: 0,
362
+ };
363
+ }
364
+ const result = await this.execGit(workDir, ['status', '--porcelain=v2', '--branch']);
365
+ return this.parseStatus(result.stdout);
366
+ });
367
+ }
368
+ async getBranches(workDir) {
369
+ return this.withMutex(workDir, async () => {
370
+ const result = await this.execGit(workDir, [
371
+ 'branch', '--list', '--format=%(if)%(HEAD)%(then)* %(end)%(refname:short) %(upstream:short) %(upstream:track)',
372
+ ]);
373
+ if (result.exitCode !== 0)
374
+ return [];
375
+ return this.parseBranches(result.stdout);
376
+ });
377
+ }
378
+ // ── Public API: Staging ──
379
+ async stageFiles(workDir, files) {
380
+ return this.withMutex(workDir, async () => {
381
+ const result = await this.execGit(workDir, ['add', '--', ...files]);
382
+ if (result.exitCode !== 0)
383
+ return this.makeError(result.stderr, result.exitCode);
384
+ return { success: true, message: `Staged ${files.length} file(s)`, errorCode: null };
385
+ });
386
+ }
387
+ async unstageFiles(workDir, files) {
388
+ return this.withMutex(workDir, async () => {
389
+ const result = await this.execGit(workDir, ['reset', 'HEAD', '--', ...files]);
390
+ if (result.exitCode !== 0)
391
+ return this.makeError(result.stderr, result.exitCode);
392
+ return { success: true, message: `Unstaged ${files.length} file(s)`, errorCode: null };
393
+ });
394
+ }
395
+ async stageAll(workDir) {
396
+ return this.withMutex(workDir, async () => {
397
+ const result = await this.execGit(workDir, ['add', '-A']);
398
+ if (result.exitCode !== 0)
399
+ return this.makeError(result.stderr, result.exitCode);
400
+ return { success: true, message: 'Staged all changes', errorCode: null };
401
+ });
402
+ }
403
+ async unstageAll(workDir) {
404
+ return this.withMutex(workDir, async () => {
405
+ const result = await this.execGit(workDir, ['reset', 'HEAD']);
406
+ if (result.exitCode !== 0)
407
+ return this.makeError(result.stderr, result.exitCode);
408
+ return { success: true, message: 'Unstaged all changes', errorCode: null };
409
+ });
410
+ }
411
+ // ── Public API: Commit ──
412
+ async commit(request) {
413
+ return this.withMutex(request.workingDirectory, async () => {
414
+ const result = await this.execGit(request.workingDirectory, ['commit', '-m', request.message]);
415
+ if (result.exitCode !== 0)
416
+ return this.makeError(result.stderr, result.exitCode);
417
+ // Parse commit hash from output
418
+ const hashMatch = result.stdout.match(/\[[\w\-/]+ ([a-f0-9]+)\]/);
419
+ const shortHash = hashMatch ? hashMatch[1] : 'unknown';
420
+ // Create checkpoint if requested
421
+ if (request.createCheckpoint && request.sessionId && this.checkpointManager) {
422
+ try {
423
+ await this.checkpointManager.createCheckpoint({
424
+ sessionId: request.sessionId,
425
+ name: `git: ${shortHash} ${request.message}`.slice(0, 50),
426
+ description: `Git commit: ${request.message}\n\nHash: ${shortHash}`,
427
+ tags: ['git-commit'],
428
+ });
429
+ }
430
+ catch (err) {
431
+ console.warn('[GitManager] Failed to create checkpoint:', err);
432
+ }
433
+ }
434
+ return { success: true, message: `Committed: ${shortHash} ${request.message}`, errorCode: null };
435
+ });
436
+ }
437
+ async generateMessage(workDir) {
438
+ return this.withMutex(workDir, async () => {
439
+ // Get staged file stats
440
+ const statResult = await this.execGit(workDir, ['diff', '--cached', '--numstat']);
441
+ const nameResult = await this.execGit(workDir, ['diff', '--cached', '--name-only']);
442
+ if (!nameResult.stdout.trim()) {
443
+ return {
444
+ message: 'chore: update files',
445
+ type: 'chore',
446
+ scope: null,
447
+ description: 'update files',
448
+ confidence: 'low',
449
+ reasoning: 'No staged changes found',
450
+ };
451
+ }
452
+ const files = nameResult.stdout.trim().split('\n').filter(f => f);
453
+ let totalInsertions = 0;
454
+ let totalDeletions = 0;
455
+ // Parse numstat
456
+ const statLines = statResult.stdout.trim().split('\n').filter(l => l);
457
+ for (const line of statLines) {
458
+ const parts = line.split('\t');
459
+ if (parts.length >= 2) {
460
+ const ins = parseInt(parts[0], 10);
461
+ const del = parseInt(parts[1], 10);
462
+ if (!isNaN(ins))
463
+ totalInsertions += ins;
464
+ if (!isNaN(del))
465
+ totalDeletions += del;
466
+ }
467
+ }
468
+ // Analyze file types
469
+ const extensions = files.map(f => path.extname(f).toLowerCase());
470
+ const directories = files.map(f => {
471
+ const dir = path.dirname(f);
472
+ return dir === '.' ? '' : dir.split(/[/\\]/)[0];
473
+ });
474
+ // Detect type using heuristics
475
+ const { type, confidence, reasoning } = this.inferCommitType(files, extensions, totalInsertions, totalDeletions);
476
+ // Detect scope
477
+ const scope = this.inferScope(directories);
478
+ // Generate description
479
+ const description = this.generateDescription(files, extensions, totalInsertions, totalDeletions);
480
+ const message = scope
481
+ ? `${type}(${scope}): ${description}`
482
+ : `${type}: ${description}`;
483
+ return { message, type, scope, description, confidence, reasoning };
484
+ });
485
+ }
486
+ inferCommitType(files, _extensions, insertions, deletions) {
487
+ // Test files
488
+ if (files.every(f => f.includes('.test.') || f.includes('.spec.') || f.includes('__tests__/'))) {
489
+ return { type: 'test', confidence: 'high', reasoning: 'All changed files are test files' };
490
+ }
491
+ // Documentation files
492
+ if (files.every(f => {
493
+ const ext = path.extname(f).toLowerCase();
494
+ return ['.md', '.txt', '.rst'].includes(ext) || f.startsWith('docs/');
495
+ })) {
496
+ return { type: 'docs', confidence: 'high', reasoning: 'All changed files are documentation' };
497
+ }
498
+ // CI/CD files
499
+ if (files.every(f => f.includes('Dockerfile') || f.endsWith('.yml') || f.endsWith('.yaml') ||
500
+ f.startsWith('.github/') || f.includes('Jenkinsfile'))) {
501
+ return { type: 'ci', confidence: 'high', reasoning: 'All changed files are CI/CD configuration' };
502
+ }
503
+ // Style files (balanced changes)
504
+ if (files.every(f => {
505
+ const ext = path.extname(f).toLowerCase();
506
+ return ['.css', '.scss', '.less'].includes(ext);
507
+ })) {
508
+ const ratio = insertions > 0 ? deletions / insertions : 0;
509
+ if (ratio > 0.8 && ratio < 1.2) {
510
+ return { type: 'style', confidence: 'medium', reasoning: 'All changed files are stylesheets with balanced changes' };
511
+ }
512
+ }
513
+ // Build files
514
+ if (files.every(f => {
515
+ const name = path.basename(f).toLowerCase();
516
+ return name === 'package.json' || f.endsWith('.lock') ||
517
+ name.startsWith('webpack.') || name.startsWith('tsconfig.') || name.startsWith('vite.');
518
+ })) {
519
+ return { type: 'build', confidence: 'medium', reasoning: 'All changed files are build configuration' };
520
+ }
521
+ // Refactor (heavy deletions)
522
+ if (deletions > insertions * 2 && deletions > 50) {
523
+ return { type: 'refactor', confidence: 'medium', reasoning: `Deletions (${deletions}) significantly exceed insertions (${insertions})` };
524
+ }
525
+ // New feature (mostly additions)
526
+ if (files.every(f => !f.includes('.test.') && !f.includes('.spec.')) && insertions > 0 && deletions === 0) {
527
+ return { type: 'feat', confidence: 'medium', reasoning: 'Only additions, no modifications to existing files' };
528
+ }
529
+ // Small fix
530
+ if (files.length <= 3 && (insertions + deletions) < 20) {
531
+ return { type: 'fix', confidence: 'low', reasoning: `Small change: ${files.length} file(s), ${insertions + deletions} lines` };
532
+ }
533
+ return { type: 'chore', confidence: 'low', reasoning: 'No specific pattern detected' };
534
+ }
535
+ inferScope(directories) {
536
+ const nonEmpty = directories.filter(d => d);
537
+ if (nonEmpty.length === 0)
538
+ return null;
539
+ // Count occurrences
540
+ const counts = new Map();
541
+ for (const dir of nonEmpty) {
542
+ counts.set(dir, (counts.get(dir) || 0) + 1);
543
+ }
544
+ // If all in same directory, use it
545
+ if (counts.size === 1) {
546
+ return nonEmpty[0];
547
+ }
548
+ // If most files in one directory (>60%)
549
+ const sorted = [...counts.entries()].sort((a, b) => b[1] - a[1]);
550
+ if (sorted[0][1] / nonEmpty.length > 0.6) {
551
+ return sorted[0][0];
552
+ }
553
+ return null;
554
+ }
555
+ generateDescription(files, extensions, insertions, deletions) {
556
+ const count = files.length;
557
+ // Determine primary file type
558
+ const extCounts = new Map();
559
+ for (const ext of extensions) {
560
+ if (ext)
561
+ extCounts.set(ext, (extCounts.get(ext) || 0) + 1);
562
+ }
563
+ const primaryExt = [...extCounts.entries()].sort((a, b) => b[1] - a[1])[0];
564
+ const fileTypeMap = {
565
+ '.ts': 'TypeScript',
566
+ '.tsx': 'TypeScript',
567
+ '.js': 'JavaScript',
568
+ '.jsx': 'JavaScript',
569
+ '.css': 'CSS',
570
+ '.scss': 'SCSS',
571
+ '.md': 'markdown',
572
+ '.json': 'JSON',
573
+ '.py': 'Python',
574
+ '.rs': 'Rust',
575
+ '.go': 'Go',
576
+ };
577
+ const fileType = primaryExt ? (fileTypeMap[primaryExt[0]] || primaryExt[0].slice(1)) : 'files';
578
+ // Determine verb
579
+ let verb = 'update';
580
+ if (deletions === 0 && insertions > 0)
581
+ verb = 'add';
582
+ else if (insertions === 0 && deletions > 0)
583
+ verb = 'remove';
584
+ else if (deletions > insertions * 2)
585
+ verb = 'refactor';
586
+ if (count === 1) {
587
+ const basename = path.basename(files[0]);
588
+ return `${verb} ${basename}`;
589
+ }
590
+ return `${verb} ${count} ${fileType} files`;
591
+ }
592
+ // ── Public API: Remote operations ──
593
+ async push(workDir, setUpstream) {
594
+ return this.withMutex(workDir, async () => {
595
+ // Get current branch
596
+ const branchResult = await this.execGit(workDir, ['rev-parse', '--abbrev-ref', 'HEAD']);
597
+ const branch = branchResult.stdout.trim();
598
+ const args = setUpstream
599
+ ? ['push', '-u', 'origin', branch]
600
+ : ['push', 'origin', branch];
601
+ const result = await this.execGit(workDir, args);
602
+ if (result.exitCode !== 0)
603
+ return this.makeError(result.stderr, result.exitCode);
604
+ return { success: true, message: `Pushed to origin/${branch}`, errorCode: null };
605
+ });
606
+ }
607
+ async pull(workDir) {
608
+ return this.withMutex(workDir, async () => {
609
+ const result = await this.execGit(workDir, ['pull']);
610
+ if (result.exitCode !== 0)
611
+ return this.makeError(result.stderr, result.exitCode);
612
+ return { success: true, message: result.stdout.trim() || 'Pulled successfully', errorCode: null };
613
+ });
614
+ }
615
+ async fetch(workDir) {
616
+ return this.withMutex(workDir, async () => {
617
+ const result = await this.execGit(workDir, ['fetch', 'origin']);
618
+ if (result.exitCode !== 0)
619
+ return this.makeError(result.stderr, result.exitCode);
620
+ return { success: true, message: 'Fetched from origin', errorCode: null };
621
+ });
622
+ }
623
+ // ── Public API: Branches ──
624
+ async switchBranch(workDir, branch) {
625
+ return this.withMutex(workDir, async () => {
626
+ const result = await this.execGit(workDir, ['checkout', branch]);
627
+ if (result.exitCode !== 0)
628
+ return this.makeError(result.stderr, result.exitCode);
629
+ return { success: true, message: `Switched to branch '${branch}'`, errorCode: null };
630
+ });
631
+ }
632
+ async createBranch(workDir, branch) {
633
+ return this.withMutex(workDir, async () => {
634
+ const result = await this.execGit(workDir, ['checkout', '-b', branch]);
635
+ if (result.exitCode !== 0)
636
+ return this.makeError(result.stderr, result.exitCode);
637
+ return { success: true, message: `Created and switched to branch '${branch}'`, errorCode: null };
638
+ });
639
+ }
640
+ // ── Public API: History & Diff ──
641
+ async log(workDir, count = 50) {
642
+ return this.withMutex(workDir, async () => {
643
+ const result = await this.execGit(workDir, [
644
+ 'log', `--format=%H|%h|%an|%ae|%aI|%s`, `-${count}`, '--shortstat',
645
+ ]);
646
+ if (result.exitCode !== 0)
647
+ return [];
648
+ const commits = [];
649
+ const lines = result.stdout.split('\n');
650
+ let current = null;
651
+ for (const line of lines) {
652
+ if (!line.trim())
653
+ continue;
654
+ // Check if it's a commit line (starts with hash)
655
+ if (line.includes('|') && line.match(/^[a-f0-9]{40}\|/)) {
656
+ if (current)
657
+ commits.push(current);
658
+ const parts = line.split('|');
659
+ current = {
660
+ hash: parts[0],
661
+ shortHash: parts[1],
662
+ authorName: parts[2],
663
+ authorEmail: parts[3],
664
+ date: parts[4],
665
+ subject: parts.slice(5).join('|'),
666
+ filesChanged: 0,
667
+ insertions: 0,
668
+ deletions: 0,
669
+ };
670
+ }
671
+ else if (current && line.includes('file')) {
672
+ // Parse shortstat: " 3 files changed, 45 insertions(+), 12 deletions(-)"
673
+ const filesMatch = line.match(/(\d+) files? changed/);
674
+ const insMatch = line.match(/(\d+) insertions?\(\+\)/);
675
+ const delMatch = line.match(/(\d+) deletions?\(-\)/);
676
+ if (filesMatch)
677
+ current.filesChanged = parseInt(filesMatch[1], 10);
678
+ if (insMatch)
679
+ current.insertions = parseInt(insMatch[1], 10);
680
+ if (delMatch)
681
+ current.deletions = parseInt(delMatch[1], 10);
682
+ }
683
+ }
684
+ if (current)
685
+ commits.push(current);
686
+ return commits;
687
+ });
688
+ }
689
+ async diff(workDir, filePath, staged) {
690
+ return this.withMutex(workDir, async () => {
691
+ const args = staged
692
+ ? ['diff', '--cached', '--', filePath]
693
+ : ['diff', '--', filePath];
694
+ const result = await this.execGit(workDir, args);
695
+ const totalSize = Buffer.byteLength(result.stdout, 'utf-8');
696
+ const isTruncated = totalSize > this.maxDiffSizeBytes;
697
+ const diff = isTruncated
698
+ ? result.stdout.slice(0, this.maxDiffSizeBytes)
699
+ : result.stdout;
700
+ return { filePath, diff, isTruncated, totalSizeBytes: totalSize };
701
+ });
702
+ }
703
+ async commitDiff(workDir, hash) {
704
+ return this.withMutex(workDir, async () => {
705
+ // Get commit info
706
+ const logResult = await this.execGit(workDir, [
707
+ 'log', `--format=%H|%h|%an|%ae|%aI|%s`, '-1', hash,
708
+ ]);
709
+ const statResult = await this.execGit(workDir, [
710
+ 'diff-tree', '--no-commit-id', '-r', '--stat', hash,
711
+ ]);
712
+ const parts = logResult.stdout.trim().split('|');
713
+ const commit = {
714
+ hash: parts[0] || hash,
715
+ shortHash: parts[1] || hash.slice(0, 7),
716
+ authorName: parts[2] || '',
717
+ authorEmail: parts[3] || '',
718
+ date: parts[4] || '',
719
+ subject: parts.slice(5).join('|') || '',
720
+ filesChanged: 0,
721
+ insertions: 0,
722
+ deletions: 0,
723
+ };
724
+ // Parse stat output for file count
725
+ const statLines = statResult.stdout.trim().split('\n').filter(l => l.trim());
726
+ if (statLines.length > 0) {
727
+ const summaryLine = statLines[statLines.length - 1];
728
+ const filesMatch = summaryLine.match(/(\d+) files? changed/);
729
+ const insMatch = summaryLine.match(/(\d+) insertions?\(\+\)/);
730
+ const delMatch = summaryLine.match(/(\d+) deletions?\(-\)/);
731
+ if (filesMatch)
732
+ commit.filesChanged = parseInt(filesMatch[1], 10);
733
+ if (insMatch)
734
+ commit.insertions = parseInt(insMatch[1], 10);
735
+ if (delMatch)
736
+ commit.deletions = parseInt(delMatch[1], 10);
737
+ }
738
+ return commit;
739
+ });
740
+ }
741
+ // ── Public API: Discard ──
742
+ async discardFile(workDir, filePath) {
743
+ return this.withMutex(workDir, async () => {
744
+ const result = await this.execGit(workDir, ['checkout', '--', filePath]);
745
+ if (result.exitCode !== 0)
746
+ return this.makeError(result.stderr, result.exitCode);
747
+ return { success: true, message: `Discarded changes to ${filePath}`, errorCode: null };
748
+ });
749
+ }
750
+ async discardAll(workDir) {
751
+ return this.withMutex(workDir, async () => {
752
+ const result = await this.execGit(workDir, ['checkout', '--', '.']);
753
+ if (result.exitCode !== 0)
754
+ return this.makeError(result.stderr, result.exitCode);
755
+ return { success: true, message: 'Discarded all unstaged changes', errorCode: null };
756
+ });
757
+ }
758
+ // ── Public API: Init ──
759
+ async init(workDir) {
760
+ return this.withMutex(workDir, async () => {
761
+ const result = await this.execGit(workDir, ['init']);
762
+ if (result.exitCode !== 0)
763
+ return this.makeError(result.stderr, result.exitCode);
764
+ return { success: true, message: 'Initialized git repository', errorCode: null };
765
+ });
766
+ }
767
+ // ── Public API: Watching ──
768
+ startWatching(workDir) {
769
+ if (this.watchers.has(workDir))
770
+ return true;
771
+ const gitDir = path.join(workDir, '.git');
772
+ if (!fs.existsSync(gitDir))
773
+ return false;
774
+ try {
775
+ const watcher = fs.watch(gitDir, { recursive: false }, () => {
776
+ this.debouncedRefresh(workDir);
777
+ });
778
+ // Also watch the index file specifically
779
+ const indexPath = path.join(gitDir, 'index');
780
+ if (fs.existsSync(indexPath)) {
781
+ try {
782
+ const indexWatcher = fs.watch(indexPath, () => {
783
+ this.debouncedRefresh(workDir);
784
+ });
785
+ // Store with a modified key
786
+ this.watchers.set(workDir + ':index', indexWatcher);
787
+ }
788
+ catch {
789
+ // Index watch failed, git dir watch is enough
790
+ }
791
+ }
792
+ this.watchers.set(workDir, watcher);
793
+ watcher.on('error', (err) => {
794
+ console.warn('[GitManager] Watch error for', workDir, err);
795
+ this.stopWatching(workDir);
796
+ });
797
+ return true;
798
+ }
799
+ catch (err) {
800
+ console.warn('[GitManager] Failed to watch', workDir, err);
801
+ return false;
802
+ }
803
+ }
804
+ stopWatching(workDir) {
805
+ const watcher = this.watchers.get(workDir);
806
+ if (watcher) {
807
+ watcher.close();
808
+ this.watchers.delete(workDir);
809
+ }
810
+ const indexWatcher = this.watchers.get(workDir + ':index');
811
+ if (indexWatcher) {
812
+ indexWatcher.close();
813
+ this.watchers.delete(workDir + ':index');
814
+ }
815
+ const timer = this.debounceTimers.get(workDir);
816
+ if (timer) {
817
+ clearTimeout(timer);
818
+ this.debounceTimers.delete(workDir);
819
+ }
820
+ return true;
821
+ }
822
+ debouncedRefresh(workDir) {
823
+ const existing = this.debounceTimers.get(workDir);
824
+ if (existing)
825
+ clearTimeout(existing);
826
+ const timer = setTimeout(async () => {
827
+ this.debounceTimers.delete(workDir);
828
+ try {
829
+ const status = await this.getStatus(workDir);
830
+ if (this.emitter) {
831
+ this.emitter.emit('onGitStatusChanged', status);
832
+ }
833
+ }
834
+ catch (err) {
835
+ console.warn('[GitManager] Failed to refresh status for', workDir, err);
836
+ }
837
+ }, 500);
838
+ this.debounceTimers.set(workDir, timer);
839
+ }
840
+ }
841
+ exports.GitManager = GitManager;