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.
- package/.github/workflows/ci.yml +44 -2
- package/CLAUDE.md +36 -3
- package/PHASE_1_IMPLEMENTATION.md +313 -0
- package/PHASE_2_PARTIAL_IMPLEMENTATION.md +286 -0
- package/dist/main/cli-manager.js +67 -2
- package/dist/main/command-registry.js +196 -0
- package/dist/main/git-manager.js +841 -0
- package/dist/main/index.js +25 -1
- package/dist/main/ipc-handlers.js +347 -3
- package/dist/main/layout-presets-manager.js +233 -0
- package/dist/main/model-history-manager.js +187 -0
- package/dist/main/session-manager.js +83 -26
- package/dist/main/session-persistence.js +1 -0
- package/dist/main/session-pool.js +40 -9
- package/dist/main/settings-persistence.js +67 -12
- package/dist/renderer/assets/index-BNeYLqV4.css +32 -0
- package/dist/renderer/assets/index-D5O5Ljoo.js +17189 -0
- package/dist/renderer/index.html +2 -2
- package/dist/shared/ipc-contract.js +79 -0
- package/dist/shared/model-detector.js +83 -0
- package/dist/shared/types/command-types.js +5 -0
- package/dist/shared/types/git-types.js +2 -0
- package/dist/types/layout-presets.js +11 -0
- package/docs/git-integration-implementation-tasks.md +974 -0
- package/docs/git-integration-product-spec.md +916 -0
- package/docs/git-integration-ui-spec.md +1464 -0
- package/docs/repo-index.md +83 -8
- package/e2e/app-launch.spec.ts +31 -0
- package/e2e/fixtures/electron.ts +34 -0
- package/e2e/keyboard-shortcuts.spec.ts +50 -0
- package/e2e/session.spec.ts +34 -0
- package/e2e/split-view.spec.ts +21 -0
- package/package.json +16 -3
- package/playwright.config.ts +15 -0
- package/src/main/cli-manager.ts +74 -3
- package/src/main/command-registry.ts +221 -0
- package/src/main/git-manager.test.ts +374 -0
- package/src/main/git-manager.ts +909 -0
- package/src/main/index.ts +31 -1
- package/src/main/ipc-emitter.test.ts +60 -0
- package/src/main/ipc-handlers.ts +295 -3
- package/src/main/ipc-registry.test.ts +75 -0
- package/src/main/layout-presets-manager.ts +268 -0
- package/src/main/model-history-manager.ts +196 -0
- package/src/main/session-manager.ts +102 -30
- package/src/main/session-persistence.test.ts +215 -0
- package/src/main/session-persistence.ts +1 -0
- package/src/main/session-pool.ts +31 -9
- package/src/main/settings-persistence.ts +74 -12
- package/src/renderer/App.tsx +215 -43
- package/src/renderer/components/CustomLayoutBuilder.tsx +143 -0
- package/src/renderer/components/GitPanel.test.tsx +181 -0
- package/src/renderer/components/GitPanel.tsx +1407 -0
- package/src/renderer/components/LayoutPicker.tsx +182 -0
- package/src/renderer/components/LayoutPreviewCard.tsx +175 -0
- package/src/renderer/components/ModelHistoryPanel.tsx +435 -0
- package/src/renderer/components/PaneHeader.test.tsx +96 -0
- package/src/renderer/components/PaneHeader.tsx +28 -0
- package/src/renderer/components/SplitLayout.test.tsx +153 -0
- package/src/renderer/components/SplitLayout.tsx +36 -1
- package/src/renderer/components/Terminal.tsx +10 -10
- package/src/renderer/components/WelcomeWizard.tsx +143 -0
- package/src/renderer/components/WizardStepper.tsx +135 -0
- package/src/renderer/components/ui/ClaudeReadinessProgress.tsx +168 -0
- package/src/renderer/components/ui/CommitDialog.test.tsx +134 -0
- package/src/renderer/components/ui/CommitDialog.tsx +464 -0
- package/src/renderer/components/ui/EmptyState.test.tsx +87 -0
- package/src/renderer/components/ui/EmptyState.tsx +115 -86
- package/src/renderer/components/ui/FeatureShowcase.tsx +187 -0
- package/src/renderer/components/ui/FuelGaugeBar.tsx +59 -0
- package/src/renderer/components/ui/FuelStatusIndicator.tsx +358 -0
- package/src/renderer/components/ui/FuelTooltip.tsx +267 -0
- package/src/renderer/components/ui/HelpButton.tsx +43 -0
- package/src/renderer/components/ui/ModelBadge.tsx +72 -0
- package/src/renderer/components/ui/ModelSwitcher.tsx +180 -0
- package/src/renderer/components/ui/PanelFooter.tsx +90 -0
- package/src/renderer/components/ui/PanelHeader.tsx +87 -0
- package/src/renderer/components/ui/PanelHelpOverlay.tsx +274 -0
- package/src/renderer/components/ui/QuickActionCard.tsx +103 -0
- package/src/renderer/components/ui/RecentSessionsList.tsx +154 -0
- package/src/renderer/components/ui/SessionStatusIndicator.tsx +104 -0
- package/src/renderer/components/ui/SettingsDialog.tsx +94 -0
- package/src/renderer/components/ui/ShortcutsPanel.tsx +433 -0
- package/src/renderer/components/ui/StatusPopover.tsx +344 -0
- package/src/renderer/components/ui/TabBar.test.tsx +124 -0
- package/src/renderer/components/ui/TabBar.tsx +152 -168
- package/src/renderer/components/ui/ToolbarDropdown.tsx +227 -0
- package/src/renderer/components/ui/ToolsDropdown.tsx +119 -0
- package/src/renderer/components/ui/TooltipCoach.tsx +217 -0
- package/src/renderer/components/ui/WelcomeHero.tsx +85 -0
- package/src/renderer/components/ui/index.ts +5 -0
- package/src/renderer/components/wizard/Step1_Welcome.tsx +166 -0
- package/src/renderer/components/wizard/Step2_LayoutPicker.tsx +246 -0
- package/src/renderer/components/wizard/Step3_Features.tsx +278 -0
- package/src/renderer/components/wizard/Step4_Ready.tsx +279 -0
- package/src/renderer/hooks/useGit.test.ts +140 -0
- package/src/renderer/hooks/useGit.ts +395 -0
- package/src/renderer/hooks/useLayoutPicker.ts +77 -0
- package/src/renderer/hooks/useModelHistory.ts +69 -0
- package/src/renderer/hooks/useSessionManager.test.ts +146 -0
- package/src/renderer/hooks/useSessionManager.ts +5 -0
- package/src/renderer/hooks/useSplitView.test.ts +168 -0
- package/src/renderer/hooks/useSplitView.ts +126 -128
- package/src/renderer/styles/globals.css +505 -0
- package/src/renderer/utils/fuzzy-search.test.ts +121 -0
- package/src/renderer/utils/layout-tree.test.ts +310 -0
- package/src/renderer/utils/layout-tree.ts +170 -0
- package/src/renderer/utils/variable-resolver.test.ts +102 -0
- package/src/shared/ipc-contract.ts +157 -0
- package/src/shared/ipc-types.ts +52 -1
- package/src/shared/message-parser.test.ts +79 -0
- package/src/shared/model-detector.test.ts +90 -0
- package/src/shared/model-detector.ts +97 -0
- package/src/shared/types/command-types.ts +26 -0
- package/src/shared/types/git-types.ts +126 -0
- package/src/types/layout-presets.ts +22 -0
- package/test/helpers/electron-api-mock.ts +52 -0
- package/test/setup-main.ts +61 -0
- package/test/setup-renderer.ts +8 -0
- package/tsconfig.json +1 -0
- package/tsconfig.main.json +2 -1
- package/vitest.workspace.ts +37 -0
- package/dist/renderer/assets/index-CR22a7j2.css +0 -32
- 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;
|