diffstalker 0.2.1 → 0.2.3

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 (42) hide show
  1. package/.dependency-cruiser.cjs +67 -0
  2. package/.githooks/pre-commit +2 -0
  3. package/.githooks/pre-push +15 -0
  4. package/README.md +43 -35
  5. package/bun.lock +60 -4
  6. package/dist/App.js +495 -131
  7. package/dist/KeyBindings.js +134 -10
  8. package/dist/MouseHandlers.js +67 -20
  9. package/dist/core/ExplorerStateManager.js +37 -75
  10. package/dist/core/GitStateManager.js +252 -46
  11. package/dist/git/diff.js +99 -18
  12. package/dist/git/status.js +111 -54
  13. package/dist/git/test-helpers.js +67 -0
  14. package/dist/index.js +54 -43
  15. package/dist/ipc/CommandClient.js +6 -7
  16. package/dist/state/UIState.js +22 -0
  17. package/dist/types/remote.js +5 -0
  18. package/dist/ui/PaneRenderers.js +45 -15
  19. package/dist/ui/modals/BranchPicker.js +157 -0
  20. package/dist/ui/modals/CommitActionConfirm.js +66 -0
  21. package/dist/ui/modals/FileFinder.js +45 -75
  22. package/dist/ui/modals/HotkeysModal.js +35 -3
  23. package/dist/ui/modals/SoftResetConfirm.js +68 -0
  24. package/dist/ui/modals/StashListModal.js +98 -0
  25. package/dist/ui/modals/ThemePicker.js +1 -2
  26. package/dist/ui/widgets/CommitPanel.js +113 -7
  27. package/dist/ui/widgets/CompareListView.js +44 -23
  28. package/dist/ui/widgets/DiffView.js +216 -170
  29. package/dist/ui/widgets/ExplorerView.js +50 -54
  30. package/dist/ui/widgets/FileList.js +62 -95
  31. package/dist/ui/widgets/FlatFileList.js +65 -0
  32. package/dist/ui/widgets/Footer.js +25 -15
  33. package/dist/ui/widgets/Header.js +51 -9
  34. package/dist/ui/widgets/fileRowFormatters.js +73 -0
  35. package/dist/utils/ansiTruncate.js +0 -1
  36. package/dist/utils/displayRows.js +101 -21
  37. package/dist/utils/flatFileList.js +67 -0
  38. package/dist/utils/layoutCalculations.js +5 -3
  39. package/eslint.metrics.js +0 -1
  40. package/metrics/v0.2.2.json +229 -0
  41. package/metrics/v0.2.3.json +243 -0
  42. package/package.json +10 -3
@@ -1,4 +1,5 @@
1
1
  import { simpleGit } from 'simple-git';
2
+ import { execFileSync } from 'node:child_process';
2
3
  import * as fs from 'node:fs';
3
4
  import * as path from 'node:path';
4
5
  import { getIgnoredFiles } from './ignoreUtils.js';
@@ -60,62 +61,12 @@ export async function getStatus(repoPath) {
60
61
  };
61
62
  }
62
63
  const status = await git.status();
63
- const files = [];
64
- // Process staged files
65
- for (const file of status.staged) {
66
- files.push({
67
- path: file,
68
- status: 'added',
69
- staged: true,
70
- });
71
- }
72
- // Process modified staged files
73
- for (const file of status.modified) {
74
- // Check if it's in the index (staged)
75
- const existingStaged = files.find((f) => f.path === file && f.staged);
76
- if (!existingStaged) {
77
- files.push({
78
- path: file,
79
- status: 'modified',
80
- staged: false,
81
- });
82
- }
83
- }
84
- // Process deleted files
85
- for (const file of status.deleted) {
86
- files.push({
87
- path: file,
88
- status: 'deleted',
89
- staged: false,
90
- });
91
- }
92
- // Process untracked files
93
- for (const file of status.not_added) {
94
- files.push({
95
- path: file,
96
- status: 'untracked',
97
- staged: false,
98
- });
99
- }
100
- // Process renamed files
101
- for (const file of status.renamed) {
102
- files.push({
103
- path: file.to,
104
- originalPath: file.from,
105
- status: 'renamed',
106
- staged: true,
107
- });
108
- }
109
- // Use the files array from status for more accurate staging info
110
- // The status.files array has detailed index/working_dir info
64
+ // Build processed file list, filtering ignored files
111
65
  const processedFiles = [];
112
66
  const seen = new Set();
113
- // Collect untracked files to check if they're ignored
114
67
  const untrackedPaths = status.files.filter((f) => f.working_dir === '?').map((f) => f.path);
115
- // Get the set of ignored files
116
68
  const ignoredFiles = await getIgnoredFiles(repoPath, untrackedPaths);
117
69
  for (const file of status.files) {
118
- // Skip ignored files (marked with '!' in either column, or detected by check-ignore)
119
70
  if (file.index === '!' || file.working_dir === '!' || ignoredFiles.has(file.path)) {
120
71
  continue;
121
72
  }
@@ -123,7 +74,6 @@ export async function getStatus(repoPath) {
123
74
  if (seen.has(key))
124
75
  continue;
125
76
  seen.add(key);
126
- // Staged changes (index column)
127
77
  if (file.index && file.index !== ' ' && file.index !== '?') {
128
78
  processedFiles.push({
129
79
  path: file.path,
@@ -131,7 +81,6 @@ export async function getStatus(repoPath) {
131
81
  staged: true,
132
82
  });
133
83
  }
134
- // Unstaged changes (working_dir column)
135
84
  if (file.working_dir && file.working_dir !== ' ') {
136
85
  processedFiles.push({
137
86
  path: file.path,
@@ -147,7 +96,6 @@ export async function getStatus(repoPath) {
147
96
  ]);
148
97
  const stagedStats = parseNumstat(stagedNumstat);
149
98
  const unstagedStats = parseNumstat(unstagedNumstat);
150
- // Apply stats to files
151
99
  for (const file of processedFiles) {
152
100
  const stats = file.staged ? stagedStats.get(file.path) : unstagedStats.get(file.path);
153
101
  if (stats) {
@@ -218,6 +166,44 @@ export async function getHeadMessage(repoPath) {
218
166
  return '';
219
167
  }
220
168
  }
169
+ export function stageHunk(repoPath, patch) {
170
+ execFileSync('git', ['apply', '--cached', '--unidiff-zero'], {
171
+ cwd: repoPath,
172
+ input: patch,
173
+ encoding: 'utf-8',
174
+ });
175
+ }
176
+ export function unstageHunk(repoPath, patch) {
177
+ execFileSync('git', ['apply', '--cached', '--reverse', '--unidiff-zero'], {
178
+ cwd: repoPath,
179
+ input: patch,
180
+ encoding: 'utf-8',
181
+ });
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
+ }
221
207
  export async function getCommitHistory(repoPath, count = 50) {
222
208
  const git = simpleGit(repoPath);
223
209
  try {
@@ -235,3 +221,74 @@ export async function getCommitHistory(repoPath, count = 50) {
235
221
  return [];
236
222
  }
237
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
+ }
@@ -0,0 +1,67 @@
1
+ import { execSync } from 'node:child_process';
2
+ import * as fs from 'node:fs';
3
+ import * as path from 'node:path';
4
+ const FIXTURES_DIR = path.resolve(import.meta.dirname, '../../test-fixtures');
5
+ /**
6
+ * Create a fixture git repo with initial config.
7
+ */
8
+ export function createFixtureRepo(name) {
9
+ const repoPath = path.join(FIXTURES_DIR, name);
10
+ fs.mkdirSync(repoPath, { recursive: true });
11
+ gitExec(repoPath, 'init --initial-branch=main');
12
+ gitExec(repoPath, 'config user.email "test@test.com"');
13
+ gitExec(repoPath, 'config user.name "Test User"');
14
+ return repoPath;
15
+ }
16
+ /**
17
+ * Remove a fixture repo directory.
18
+ */
19
+ export function removeFixtureRepo(name) {
20
+ const repoPath = path.join(FIXTURES_DIR, name);
21
+ fs.rmSync(repoPath, { recursive: true, force: true });
22
+ }
23
+ /**
24
+ * Write a file inside a fixture repo, creating parent directories as needed.
25
+ */
26
+ export function writeFixtureFile(repoPath, filePath, content) {
27
+ const fullPath = path.join(repoPath, filePath);
28
+ fs.mkdirSync(path.dirname(fullPath), { recursive: true });
29
+ fs.writeFileSync(fullPath, content);
30
+ }
31
+ /**
32
+ * Run a git command in a fixture repo.
33
+ */
34
+ export function gitExec(repoPath, command) {
35
+ return execSync(`git ${command}`, {
36
+ cwd: repoPath,
37
+ encoding: 'utf-8',
38
+ env: { ...process.env, GIT_TERMINAL_PROMPT: '0' },
39
+ });
40
+ }
41
+ /**
42
+ * Create a bare remote repo and a cloned working repo for branch comparison tests.
43
+ * Returns { remotePath, repoPath }.
44
+ */
45
+ export function createRepoWithRemote(name) {
46
+ const remotePath = path.join(FIXTURES_DIR, `${name}-remote`);
47
+ const repoPath = path.join(FIXTURES_DIR, name);
48
+ // Create bare remote
49
+ fs.mkdirSync(remotePath, { recursive: true });
50
+ gitExec(remotePath, 'init --bare --initial-branch=main');
51
+ // Clone it
52
+ execSync(`git clone "${remotePath}" "${repoPath}"`, {
53
+ encoding: 'utf-8',
54
+ env: { ...process.env, GIT_TERMINAL_PROMPT: '0' },
55
+ });
56
+ // Configure cloned repo
57
+ gitExec(repoPath, 'config user.email "test@test.com"');
58
+ gitExec(repoPath, 'config user.name "Test User"');
59
+ return { remotePath, repoPath };
60
+ }
61
+ /**
62
+ * Clean up both a repo and its remote.
63
+ */
64
+ export function removeRepoWithRemote(name) {
65
+ removeFixtureRepo(name);
66
+ removeFixtureRepo(`${name}-remote`);
67
+ }