diffstalker 0.2.2 → 0.2.4

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 (47) hide show
  1. package/.dependency-cruiser.cjs +2 -2
  2. package/dist/App.js +299 -664
  3. package/dist/KeyBindings.js +125 -39
  4. package/dist/ModalController.js +166 -0
  5. package/dist/MouseHandlers.js +43 -25
  6. package/dist/NavigationController.js +290 -0
  7. package/dist/StagingOperations.js +199 -0
  8. package/dist/config.js +39 -0
  9. package/dist/core/CompareManager.js +134 -0
  10. package/dist/core/ExplorerStateManager.js +27 -40
  11. package/dist/core/GitStateManager.js +28 -630
  12. package/dist/core/HistoryManager.js +72 -0
  13. package/dist/core/RemoteOperationManager.js +109 -0
  14. package/dist/core/WorkingTreeManager.js +412 -0
  15. package/dist/git/status.js +95 -0
  16. package/dist/index.js +59 -54
  17. package/dist/state/FocusRing.js +40 -0
  18. package/dist/state/UIState.js +82 -48
  19. package/dist/types/remote.js +5 -0
  20. package/dist/ui/PaneRenderers.js +11 -4
  21. package/dist/ui/modals/BaseBranchPicker.js +4 -7
  22. package/dist/ui/modals/CommitActionConfirm.js +66 -0
  23. package/dist/ui/modals/DiscardConfirm.js +4 -7
  24. package/dist/ui/modals/FileFinder.js +33 -27
  25. package/dist/ui/modals/HotkeysModal.js +32 -13
  26. package/dist/ui/modals/Modal.js +1 -0
  27. package/dist/ui/modals/RepoPicker.js +109 -0
  28. package/dist/ui/modals/ThemePicker.js +4 -7
  29. package/dist/ui/widgets/CommitPanel.js +52 -14
  30. package/dist/ui/widgets/CompareListView.js +1 -11
  31. package/dist/ui/widgets/DiffView.js +2 -27
  32. package/dist/ui/widgets/ExplorerContent.js +1 -4
  33. package/dist/ui/widgets/ExplorerView.js +1 -11
  34. package/dist/ui/widgets/FileList.js +2 -8
  35. package/dist/ui/widgets/Footer.js +1 -0
  36. package/dist/ui/widgets/Header.js +37 -3
  37. package/dist/utils/ansi.js +38 -0
  38. package/dist/utils/ansiTruncate.js +1 -5
  39. package/dist/utils/displayRows.js +72 -59
  40. package/dist/utils/fileCategories.js +7 -0
  41. package/dist/utils/fileResolution.js +23 -0
  42. package/dist/utils/languageDetection.js +3 -2
  43. package/dist/utils/logger.js +32 -0
  44. package/metrics/v0.2.3.json +243 -0
  45. package/metrics/v0.2.4.json +236 -0
  46. package/package.json +5 -2
  47. package/dist/utils/layoutCalculations.js +0 -100
@@ -0,0 +1,72 @@
1
+ import { EventEmitter } from 'node:events';
2
+ import { getCommitHistory, getHeadMessage } from '../git/status.js';
3
+ import { getCommitDiff } from '../git/diff.js';
4
+ /**
5
+ * Manages commit history state: loading, selection, and diff display.
6
+ */
7
+ export class HistoryManager extends EventEmitter {
8
+ repoPath;
9
+ queue;
10
+ _historyState = {
11
+ commits: [],
12
+ selectedCommit: null,
13
+ commitDiff: null,
14
+ isLoading: false,
15
+ };
16
+ constructor(repoPath, queue) {
17
+ super();
18
+ this.repoPath = repoPath;
19
+ this.queue = queue;
20
+ }
21
+ get historyState() {
22
+ return this._historyState;
23
+ }
24
+ updateHistoryState(partial) {
25
+ this._historyState = { ...this._historyState, ...partial };
26
+ this.emit('history-state-change', this._historyState);
27
+ }
28
+ /**
29
+ * Refresh history if it was previously loaded (has commits).
30
+ * Called by the cascade refresh after file changes.
31
+ */
32
+ async refreshIfLoaded(count = 100) {
33
+ if (this._historyState.commits.length > 0) {
34
+ await this.doLoadHistory(count);
35
+ }
36
+ }
37
+ /**
38
+ * Load commit history for the history view.
39
+ */
40
+ async loadHistory(count = 100) {
41
+ this.updateHistoryState({ isLoading: true });
42
+ try {
43
+ await this.queue.enqueue(() => this.doLoadHistory(count));
44
+ }
45
+ catch (err) {
46
+ this.updateHistoryState({ isLoading: false });
47
+ throw err;
48
+ }
49
+ }
50
+ async doLoadHistory(count = 100) {
51
+ const commits = await getCommitHistory(this.repoPath, count);
52
+ this.updateHistoryState({ commits, selectedCommit: null, commitDiff: null, isLoading: false });
53
+ }
54
+ /**
55
+ * Select a commit in history view and load its diff.
56
+ */
57
+ async selectHistoryCommit(commit) {
58
+ this.updateHistoryState({ selectedCommit: commit, commitDiff: null });
59
+ if (!commit)
60
+ return;
61
+ await this.queue.enqueue(async () => {
62
+ const diff = await getCommitDiff(this.repoPath, commit.hash);
63
+ this.updateHistoryState({ commitDiff: diff });
64
+ });
65
+ }
66
+ /**
67
+ * Get the HEAD commit message.
68
+ */
69
+ async getHeadCommitMessage() {
70
+ return this.queue.enqueue(() => getHeadMessage(this.repoPath));
71
+ }
72
+ }
@@ -0,0 +1,109 @@
1
+ import { EventEmitter } from 'node:events';
2
+ import { push as gitPush, fetchRemote as gitFetchRemote, pullRebase as gitPullRebase, stashSave as gitStashSave, stashPop as gitStashPop, getLocalBranches as gitGetLocalBranches, switchBranch as gitSwitchBranch, createBranch as gitCreateBranch, softResetHead as gitSoftResetHead, cherryPick as gitCherryPick, revertCommit as gitRevertCommit, } from '../git/status.js';
3
+ /**
4
+ * Manages remote operations (push/pull/fetch), stash, branch switching, and undo operations.
5
+ * Uses callbacks for cross-manager coordination (refresh, stash list, compare reset).
6
+ */
7
+ export class RemoteOperationManager extends EventEmitter {
8
+ repoPath;
9
+ queue;
10
+ callbacks;
11
+ _remoteState = {
12
+ operation: null,
13
+ inProgress: false,
14
+ error: null,
15
+ lastResult: null,
16
+ };
17
+ constructor(repoPath, queue, callbacks) {
18
+ super();
19
+ this.repoPath = repoPath;
20
+ this.queue = queue;
21
+ this.callbacks = callbacks;
22
+ }
23
+ get remoteState() {
24
+ return this._remoteState;
25
+ }
26
+ updateRemoteState(partial) {
27
+ this._remoteState = { ...this._remoteState, ...partial };
28
+ this.emit('remote-state-change', this._remoteState);
29
+ }
30
+ async runRemoteOperation(operation, fn) {
31
+ this.updateRemoteState({ operation, inProgress: true, error: null, lastResult: null });
32
+ try {
33
+ const result = await this.queue.enqueue(fn);
34
+ this.updateRemoteState({ inProgress: false, lastResult: result });
35
+ this.callbacks.scheduleRefresh();
36
+ }
37
+ catch (err) {
38
+ const message = err instanceof Error ? err.message : String(err);
39
+ this.updateRemoteState({ inProgress: false, error: message });
40
+ }
41
+ }
42
+ /**
43
+ * Clear the remote state (e.g. after auto-clear timeout).
44
+ */
45
+ clearRemoteState() {
46
+ this.updateRemoteState({ operation: null, error: null, lastResult: null });
47
+ }
48
+ // --- Remote operations ---
49
+ async push() {
50
+ if (this._remoteState.inProgress)
51
+ return;
52
+ await this.runRemoteOperation('push', () => gitPush(this.repoPath));
53
+ }
54
+ async fetchRemote() {
55
+ if (this._remoteState.inProgress)
56
+ return;
57
+ await this.runRemoteOperation('fetch', () => gitFetchRemote(this.repoPath));
58
+ }
59
+ async pullRebase() {
60
+ if (this._remoteState.inProgress)
61
+ return;
62
+ await this.runRemoteOperation('pull', () => gitPullRebase(this.repoPath));
63
+ }
64
+ // --- Stash operations ---
65
+ async stash(message) {
66
+ if (this._remoteState.inProgress)
67
+ return;
68
+ await this.runRemoteOperation('stash', () => gitStashSave(this.repoPath, message));
69
+ await this.callbacks.loadStashList();
70
+ }
71
+ async stashPop(index = 0) {
72
+ if (this._remoteState.inProgress)
73
+ return;
74
+ await this.runRemoteOperation('stashPop', () => gitStashPop(this.repoPath, index));
75
+ await this.callbacks.loadStashList();
76
+ }
77
+ // --- Branch operations ---
78
+ async getLocalBranches() {
79
+ return this.queue.enqueue(() => gitGetLocalBranches(this.repoPath));
80
+ }
81
+ async switchBranch(name) {
82
+ if (this._remoteState.inProgress)
83
+ return;
84
+ await this.runRemoteOperation('branchSwitch', () => gitSwitchBranch(this.repoPath, name));
85
+ this.callbacks.resetCompareBaseBranch();
86
+ }
87
+ async createBranch(name) {
88
+ if (this._remoteState.inProgress)
89
+ return;
90
+ await this.runRemoteOperation('branchCreate', () => gitCreateBranch(this.repoPath, name));
91
+ this.callbacks.resetCompareBaseBranch();
92
+ }
93
+ // --- Undo operations ---
94
+ async softReset(count = 1) {
95
+ if (this._remoteState.inProgress)
96
+ return;
97
+ await this.runRemoteOperation('softReset', () => gitSoftResetHead(this.repoPath, count));
98
+ }
99
+ async cherryPick(hash) {
100
+ if (this._remoteState.inProgress)
101
+ return;
102
+ await this.runRemoteOperation('cherryPick', () => gitCherryPick(this.repoPath, hash));
103
+ }
104
+ async revertCommit(hash) {
105
+ if (this._remoteState.inProgress)
106
+ return;
107
+ await this.runRemoteOperation('revert', () => gitRevertCommit(this.repoPath, hash));
108
+ }
109
+ }
@@ -0,0 +1,412 @@
1
+ import * as path from 'node:path';
2
+ import * as fs from 'node:fs';
3
+ import { execFileSync } from 'node:child_process';
4
+ import { watch } from 'chokidar';
5
+ import { EventEmitter } from 'node:events';
6
+ import ignore from 'ignore';
7
+ import * as logger from '../utils/logger.js';
8
+ import { getStatus, stageFile, unstageFile, stageAll as gitStageAll, unstageAll as gitUnstageAll, discardChanges as gitDiscardChanges, commit as gitCommit, stageHunk as gitStageHunk, unstageHunk as gitUnstageHunk, getStashList as gitGetStashList, } from '../git/status.js';
9
+ import { getDiff, getDiffForUntracked, getStagedDiff, countHunksPerFile, } from '../git/diff.js';
10
+ /**
11
+ * Manages the working tree: file watching, status, diffs, staging, and commits.
12
+ * Accepts an onRefresh callback for cascading refreshes to history/compare managers.
13
+ */
14
+ export class WorkingTreeManager extends EventEmitter {
15
+ repoPath;
16
+ queue;
17
+ onRefresh;
18
+ gitWatcher = null;
19
+ workingDirWatcher = null;
20
+ ignorers = new Map();
21
+ diffDebounceTimer = null;
22
+ _state = {
23
+ status: null,
24
+ diff: null,
25
+ combinedFileDiffs: null,
26
+ selectedFile: null,
27
+ isLoading: false,
28
+ error: null,
29
+ hunkCounts: null,
30
+ stashList: [],
31
+ };
32
+ constructor(repoPath, queue, onRefresh) {
33
+ super();
34
+ this.repoPath = repoPath;
35
+ this.queue = queue;
36
+ this.onRefresh = onRefresh ?? null;
37
+ }
38
+ get state() {
39
+ return this._state;
40
+ }
41
+ updateState(partial) {
42
+ this._state = { ...this._state, ...partial };
43
+ this.emit('state-change', this._state);
44
+ }
45
+ // --- Gitignore loading ---
46
+ loadGitignores() {
47
+ const ignorers = new Map();
48
+ const rootIg = ignore();
49
+ rootIg.add('.git');
50
+ const rootGitignorePath = path.join(this.repoPath, '.gitignore');
51
+ if (fs.existsSync(rootGitignorePath)) {
52
+ rootIg.add(fs.readFileSync(rootGitignorePath, 'utf-8'));
53
+ }
54
+ const excludePath = path.join(this.repoPath, '.git', 'info', 'exclude');
55
+ if (fs.existsSync(excludePath)) {
56
+ rootIg.add(fs.readFileSync(excludePath, 'utf-8'));
57
+ }
58
+ ignorers.set('', rootIg);
59
+ try {
60
+ const output = execFileSync('git', ['ls-files', '-z', '--cached', '--others', '**/.gitignore'], { cwd: this.repoPath, encoding: 'utf-8' });
61
+ for (const entry of output.split('\0')) {
62
+ if (!entry || entry === '.gitignore')
63
+ continue;
64
+ if (!entry.endsWith('.gitignore'))
65
+ continue;
66
+ const dir = path.dirname(entry);
67
+ const absPath = path.join(this.repoPath, entry);
68
+ try {
69
+ const content = fs.readFileSync(absPath, 'utf-8');
70
+ const ig = ignore();
71
+ ig.add(content);
72
+ ignorers.set(dir, ig);
73
+ }
74
+ catch (err) {
75
+ logger.warn(`Failed to read ${absPath}: ${err instanceof Error ? err.message : err}`);
76
+ }
77
+ }
78
+ }
79
+ catch {
80
+ // git ls-files failed — we still have the root ignorer
81
+ }
82
+ return ignorers;
83
+ }
84
+ // --- File watching ---
85
+ startWatching() {
86
+ const gitDir = path.join(this.repoPath, '.git');
87
+ if (!fs.existsSync(gitDir))
88
+ return;
89
+ const indexFile = path.join(gitDir, 'index');
90
+ const headFile = path.join(gitDir, 'HEAD');
91
+ const refsDir = path.join(gitDir, 'refs');
92
+ const gitignorePath = path.join(this.repoPath, '.gitignore');
93
+ this.gitWatcher = watch([indexFile, headFile, refsDir, gitignorePath], {
94
+ persistent: true,
95
+ ignoreInitial: true,
96
+ usePolling: true,
97
+ interval: 100,
98
+ });
99
+ this.ignorers = this.loadGitignores();
100
+ this.workingDirWatcher = watch(this.repoPath, {
101
+ persistent: true,
102
+ ignoreInitial: true,
103
+ ignored: (filePath) => {
104
+ const relativePath = path.relative(this.repoPath, filePath);
105
+ if (!relativePath)
106
+ return false;
107
+ const parts = relativePath.split('/');
108
+ for (let depth = 0; depth < parts.length; depth++) {
109
+ const dir = depth === 0 ? '' : parts.slice(0, depth).join('/');
110
+ const ig = this.ignorers.get(dir);
111
+ if (ig) {
112
+ const relToDir = depth === 0 ? relativePath : parts.slice(depth).join('/');
113
+ if (ig.ignores(relToDir))
114
+ return true;
115
+ }
116
+ }
117
+ return false;
118
+ },
119
+ awaitWriteFinish: {
120
+ stabilityThreshold: 100,
121
+ pollInterval: 50,
122
+ },
123
+ });
124
+ const scheduleRefresh = () => this.scheduleRefresh();
125
+ this.gitWatcher.on('change', (filePath) => {
126
+ if (filePath === gitignorePath) {
127
+ this.ignorers = this.loadGitignores();
128
+ }
129
+ scheduleRefresh();
130
+ });
131
+ this.gitWatcher.on('add', scheduleRefresh);
132
+ this.gitWatcher.on('unlink', scheduleRefresh);
133
+ this.gitWatcher.on('error', (err) => {
134
+ const message = err instanceof Error ? err.message : String(err);
135
+ this.emit('error', `Git watcher error: ${message}`);
136
+ });
137
+ this.workingDirWatcher.on('change', scheduleRefresh);
138
+ this.workingDirWatcher.on('add', scheduleRefresh);
139
+ this.workingDirWatcher.on('unlink', scheduleRefresh);
140
+ this.workingDirWatcher.on('error', (err) => {
141
+ const message = err instanceof Error ? err.message : String(err);
142
+ this.emit('error', `Working dir watcher error: ${message}`);
143
+ });
144
+ }
145
+ dispose() {
146
+ if (this.diffDebounceTimer)
147
+ clearTimeout(this.diffDebounceTimer);
148
+ this.gitWatcher?.close();
149
+ this.workingDirWatcher?.close();
150
+ }
151
+ // --- Refresh ---
152
+ scheduleRefresh() {
153
+ this.queue.scheduleRefresh(async () => {
154
+ await this.doRefresh();
155
+ // Cascade refresh to history and compare if loaded
156
+ if (this.onRefresh) {
157
+ await this.onRefresh();
158
+ }
159
+ });
160
+ }
161
+ scheduleStatusRefresh() {
162
+ this.queue.scheduleRefresh(async () => {
163
+ const newStatus = await getStatus(this.repoPath);
164
+ if (!newStatus.isRepo) {
165
+ this.updateState({
166
+ status: newStatus,
167
+ diff: null,
168
+ isLoading: false,
169
+ error: 'Not a git repository',
170
+ });
171
+ return;
172
+ }
173
+ this.updateState({ status: newStatus, isLoading: false });
174
+ });
175
+ }
176
+ async refresh() {
177
+ await this.queue.enqueue(() => this.doRefresh());
178
+ }
179
+ async doRefresh() {
180
+ this.updateState({ isLoading: true, error: null });
181
+ try {
182
+ const newStatus = await getStatus(this.repoPath);
183
+ if (!newStatus.isRepo) {
184
+ this.updateState({
185
+ status: newStatus,
186
+ diff: null,
187
+ isLoading: false,
188
+ error: 'Not a git repository',
189
+ });
190
+ return;
191
+ }
192
+ const [allUnstagedDiff, allStagedDiff] = await Promise.all([
193
+ getDiff(this.repoPath, undefined, false),
194
+ getDiff(this.repoPath, undefined, true),
195
+ ]);
196
+ const hunkCounts = {
197
+ unstaged: countHunksPerFile(allUnstagedDiff.raw),
198
+ staged: countHunksPerFile(allStagedDiff.raw),
199
+ };
200
+ const { displayDiff, combinedFileDiffs } = await this.resolveFileDiffs(newStatus, allUnstagedDiff);
201
+ this.updateState({
202
+ status: newStatus,
203
+ diff: displayDiff,
204
+ combinedFileDiffs,
205
+ hunkCounts,
206
+ isLoading: false,
207
+ });
208
+ }
209
+ catch (err) {
210
+ this.updateState({
211
+ isLoading: false,
212
+ error: err instanceof Error ? err.message : 'Unknown error',
213
+ });
214
+ }
215
+ }
216
+ async resolveFileDiffs(newStatus, fallbackDiff) {
217
+ const currentSelectedFile = this._state.selectedFile;
218
+ if (!currentSelectedFile) {
219
+ return { displayDiff: fallbackDiff, combinedFileDiffs: null };
220
+ }
221
+ const currentFile = newStatus.files.find((f) => f.path === currentSelectedFile.path && f.staged === currentSelectedFile.staged) ?? newStatus.files.find((f) => f.path === currentSelectedFile.path);
222
+ if (!currentFile) {
223
+ this.updateState({ selectedFile: null });
224
+ return { displayDiff: fallbackDiff, combinedFileDiffs: null };
225
+ }
226
+ if (currentFile.status === 'untracked') {
227
+ const displayDiff = await getDiffForUntracked(this.repoPath, currentFile.path);
228
+ return {
229
+ displayDiff,
230
+ combinedFileDiffs: { unstaged: displayDiff, staged: { raw: '', lines: [] } },
231
+ };
232
+ }
233
+ const [unstagedFileDiff, stagedFileDiff] = await Promise.all([
234
+ getDiff(this.repoPath, currentFile.path, false),
235
+ getDiff(this.repoPath, currentFile.path, true),
236
+ ]);
237
+ const displayDiff = currentFile.staged ? stagedFileDiff : unstagedFileDiff;
238
+ return {
239
+ displayDiff,
240
+ combinedFileDiffs: { unstaged: unstagedFileDiff, staged: stagedFileDiff },
241
+ };
242
+ }
243
+ // --- File selection ---
244
+ selectFile(file) {
245
+ this.updateState({ selectedFile: file });
246
+ if (!this._state.status?.isRepo)
247
+ return;
248
+ if (this.diffDebounceTimer) {
249
+ clearTimeout(this.diffDebounceTimer);
250
+ this.diffDebounceTimer = setTimeout(() => {
251
+ this.diffDebounceTimer = null;
252
+ this.fetchDiffForSelection();
253
+ }, 20);
254
+ }
255
+ else {
256
+ this.fetchDiffForSelection();
257
+ this.diffDebounceTimer = setTimeout(() => {
258
+ this.diffDebounceTimer = null;
259
+ }, 20);
260
+ }
261
+ }
262
+ fetchDiffForSelection() {
263
+ const file = this._state.selectedFile;
264
+ this.queue
265
+ .enqueue(async () => {
266
+ if (file !== this._state.selectedFile)
267
+ return;
268
+ await this.doFetchDiffForFile(file);
269
+ })
270
+ .catch((err) => {
271
+ this.updateState({
272
+ error: `Failed to load diff: ${err instanceof Error ? err.message : String(err)}`,
273
+ });
274
+ });
275
+ }
276
+ async doFetchDiffForFile(file) {
277
+ if (!file) {
278
+ const allDiff = await getStagedDiff(this.repoPath);
279
+ if (this._state.selectedFile === null) {
280
+ this.updateState({ diff: allDiff, combinedFileDiffs: null });
281
+ }
282
+ return;
283
+ }
284
+ if (file.status === 'untracked') {
285
+ const fileDiff = await getDiffForUntracked(this.repoPath, file.path);
286
+ if (file === this._state.selectedFile) {
287
+ this.updateState({
288
+ diff: fileDiff,
289
+ combinedFileDiffs: { unstaged: fileDiff, staged: { raw: '', lines: [] } },
290
+ });
291
+ }
292
+ return;
293
+ }
294
+ const [unstagedDiff, stagedDiff] = await Promise.all([
295
+ getDiff(this.repoPath, file.path, false),
296
+ getDiff(this.repoPath, file.path, true),
297
+ ]);
298
+ if (file === this._state.selectedFile) {
299
+ const displayDiff = file.staged ? stagedDiff : unstagedDiff;
300
+ this.updateState({
301
+ diff: displayDiff,
302
+ combinedFileDiffs: { unstaged: unstagedDiff, staged: stagedDiff },
303
+ });
304
+ }
305
+ }
306
+ // --- Staging operations ---
307
+ async stage(file) {
308
+ try {
309
+ await this.queue.enqueueMutation(() => stageFile(this.repoPath, file.path));
310
+ this.scheduleStatusRefresh();
311
+ }
312
+ catch (err) {
313
+ await this.refresh();
314
+ this.updateState({
315
+ error: `Failed to stage ${file.path}: ${err instanceof Error ? err.message : String(err)}`,
316
+ });
317
+ }
318
+ }
319
+ async unstage(file) {
320
+ try {
321
+ await this.queue.enqueueMutation(() => unstageFile(this.repoPath, file.path));
322
+ this.scheduleStatusRefresh();
323
+ }
324
+ catch (err) {
325
+ await this.refresh();
326
+ this.updateState({
327
+ error: `Failed to unstage ${file.path}: ${err instanceof Error ? err.message : String(err)}`,
328
+ });
329
+ }
330
+ }
331
+ async stageHunk(patch) {
332
+ try {
333
+ await this.queue.enqueueMutation(async () => gitStageHunk(this.repoPath, patch));
334
+ this.scheduleRefresh();
335
+ }
336
+ catch (err) {
337
+ await this.refresh();
338
+ this.updateState({
339
+ error: `Failed to stage hunk: ${err instanceof Error ? err.message : String(err)}`,
340
+ });
341
+ }
342
+ }
343
+ async unstageHunk(patch) {
344
+ try {
345
+ await this.queue.enqueueMutation(async () => gitUnstageHunk(this.repoPath, patch));
346
+ this.scheduleRefresh();
347
+ }
348
+ catch (err) {
349
+ await this.refresh();
350
+ this.updateState({
351
+ error: `Failed to unstage hunk: ${err instanceof Error ? err.message : String(err)}`,
352
+ });
353
+ }
354
+ }
355
+ async discard(file) {
356
+ if (file.staged || file.status === 'untracked')
357
+ return;
358
+ try {
359
+ await this.queue.enqueueMutation(() => gitDiscardChanges(this.repoPath, file.path));
360
+ await this.refresh();
361
+ }
362
+ catch (err) {
363
+ this.updateState({
364
+ error: `Failed to discard ${file.path}: ${err instanceof Error ? err.message : String(err)}`,
365
+ });
366
+ }
367
+ }
368
+ async stageAll() {
369
+ try {
370
+ await this.queue.enqueueMutation(() => gitStageAll(this.repoPath));
371
+ await this.refresh();
372
+ }
373
+ catch (err) {
374
+ this.updateState({
375
+ error: `Failed to stage all: ${err instanceof Error ? err.message : String(err)}`,
376
+ });
377
+ }
378
+ }
379
+ async unstageAll() {
380
+ try {
381
+ await this.queue.enqueueMutation(() => gitUnstageAll(this.repoPath));
382
+ await this.refresh();
383
+ }
384
+ catch (err) {
385
+ this.updateState({
386
+ error: `Failed to unstage all: ${err instanceof Error ? err.message : String(err)}`,
387
+ });
388
+ }
389
+ }
390
+ // --- Commit ---
391
+ async commit(message, amend = false) {
392
+ try {
393
+ await this.queue.enqueue(() => gitCommit(this.repoPath, message, amend));
394
+ await this.refresh();
395
+ }
396
+ catch (err) {
397
+ this.updateState({
398
+ error: `Failed to commit: ${err instanceof Error ? err.message : String(err)}`,
399
+ });
400
+ }
401
+ }
402
+ // --- Stash list ---
403
+ async loadStashList() {
404
+ try {
405
+ const stashList = await this.queue.enqueue(() => gitGetStashList(this.repoPath));
406
+ this.updateState({ stashList });
407
+ }
408
+ catch {
409
+ // Silently ignore — stash list is non-critical
410
+ }
411
+ }
412
+ }
@@ -180,6 +180,30 @@ export function unstageHunk(repoPath, patch) {
180
180
  encoding: 'utf-8',
181
181
  });
182
182
  }
183
+ export async function push(repoPath) {
184
+ const git = simpleGit(repoPath);
185
+ const result = await git.push();
186
+ // Build a summary string from the push result
187
+ const pushed = result.pushed;
188
+ if (pushed.length === 0)
189
+ return 'Everything up-to-date';
190
+ return pushed.map((p) => `${p.local} → ${p.remote}`).join(', ');
191
+ }
192
+ export async function fetchRemote(repoPath) {
193
+ const git = simpleGit(repoPath);
194
+ await git.fetch();
195
+ return 'Fetch complete';
196
+ }
197
+ export async function pullRebase(repoPath) {
198
+ const git = simpleGit(repoPath);
199
+ const result = await git.pull(['--rebase']);
200
+ if (result.summary.changes === 0 &&
201
+ result.summary.insertions === 0 &&
202
+ result.summary.deletions === 0) {
203
+ return 'Already up-to-date';
204
+ }
205
+ return `${result.summary.changes} file(s) changed`;
206
+ }
183
207
  export async function getCommitHistory(repoPath, count = 50) {
184
208
  const git = simpleGit(repoPath);
185
209
  try {
@@ -197,3 +221,74 @@ export async function getCommitHistory(repoPath, count = 50) {
197
221
  return [];
198
222
  }
199
223
  }
224
+ export async function getStashList(repoPath) {
225
+ const git = simpleGit(repoPath);
226
+ try {
227
+ const result = await git.stashList();
228
+ return result.all.map((entry, i) => ({
229
+ index: i,
230
+ message: entry.message,
231
+ }));
232
+ }
233
+ catch {
234
+ return [];
235
+ }
236
+ }
237
+ export async function stashSave(repoPath, message) {
238
+ const git = simpleGit(repoPath);
239
+ const args = ['push'];
240
+ if (message)
241
+ args.push('-m', message);
242
+ await git.stash(args);
243
+ return 'Stashed';
244
+ }
245
+ export async function stashPop(repoPath, index = 0) {
246
+ const git = simpleGit(repoPath);
247
+ await git.stash(['pop', `stash@{${index}}`]);
248
+ return 'Stash popped';
249
+ }
250
+ export async function getLocalBranches(repoPath) {
251
+ const git = simpleGit(repoPath);
252
+ const result = await git.branchLocal();
253
+ return result.all.map((name) => ({
254
+ name,
255
+ current: name === result.current,
256
+ tracking: result.branches[name]?.label || undefined,
257
+ }));
258
+ }
259
+ export async function switchBranch(repoPath, name) {
260
+ const git = simpleGit(repoPath);
261
+ await git.checkout(name);
262
+ return `Switched to ${name}`;
263
+ }
264
+ export async function createBranch(repoPath, name) {
265
+ const git = simpleGit(repoPath);
266
+ await git.checkoutLocalBranch(name);
267
+ return `Created ${name}`;
268
+ }
269
+ // Undo operations
270
+ export async function softResetHead(repoPath, count = 1) {
271
+ const git = simpleGit(repoPath);
272
+ await git.reset(['--soft', `HEAD~${count}`]);
273
+ return 'Reset done';
274
+ }
275
+ // History actions
276
+ export async function cherryPick(repoPath, hash) {
277
+ const git = simpleGit(repoPath);
278
+ await git.raw(['cherry-pick', hash]);
279
+ return 'Cherry-picked';
280
+ }
281
+ export async function revertCommit(repoPath, hash) {
282
+ const git = simpleGit(repoPath);
283
+ await git.revert(hash);
284
+ return 'Reverted';
285
+ }
286
+ /**
287
+ * List all files in the repo: tracked files + untracked (not ignored) files.
288
+ * Uses git ls-files which is fast (git already has the index in memory).
289
+ */
290
+ export async function listAllFiles(repoPath) {
291
+ const git = simpleGit(repoPath);
292
+ const result = await git.raw(['ls-files', '-z', '--cached', '--others', '--exclude-standard']);
293
+ return result.split('\0').filter((f) => f.length > 0);
294
+ }