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.
- package/.dependency-cruiser.cjs +67 -0
- package/.githooks/pre-commit +2 -0
- package/.githooks/pre-push +15 -0
- package/README.md +43 -35
- package/bun.lock +60 -4
- package/dist/App.js +495 -131
- package/dist/KeyBindings.js +134 -10
- package/dist/MouseHandlers.js +67 -20
- package/dist/core/ExplorerStateManager.js +37 -75
- package/dist/core/GitStateManager.js +252 -46
- package/dist/git/diff.js +99 -18
- package/dist/git/status.js +111 -54
- package/dist/git/test-helpers.js +67 -0
- package/dist/index.js +54 -43
- package/dist/ipc/CommandClient.js +6 -7
- package/dist/state/UIState.js +22 -0
- package/dist/types/remote.js +5 -0
- package/dist/ui/PaneRenderers.js +45 -15
- package/dist/ui/modals/BranchPicker.js +157 -0
- package/dist/ui/modals/CommitActionConfirm.js +66 -0
- package/dist/ui/modals/FileFinder.js +45 -75
- package/dist/ui/modals/HotkeysModal.js +35 -3
- package/dist/ui/modals/SoftResetConfirm.js +68 -0
- package/dist/ui/modals/StashListModal.js +98 -0
- package/dist/ui/modals/ThemePicker.js +1 -2
- package/dist/ui/widgets/CommitPanel.js +113 -7
- package/dist/ui/widgets/CompareListView.js +44 -23
- package/dist/ui/widgets/DiffView.js +216 -170
- package/dist/ui/widgets/ExplorerView.js +50 -54
- package/dist/ui/widgets/FileList.js +62 -95
- package/dist/ui/widgets/FlatFileList.js +65 -0
- package/dist/ui/widgets/Footer.js +25 -15
- package/dist/ui/widgets/Header.js +51 -9
- package/dist/ui/widgets/fileRowFormatters.js +73 -0
- package/dist/utils/ansiTruncate.js +0 -1
- package/dist/utils/displayRows.js +101 -21
- package/dist/utils/flatFileList.js +67 -0
- package/dist/utils/layoutCalculations.js +5 -3
- package/eslint.metrics.js +0 -1
- package/metrics/v0.2.2.json +229 -0
- package/metrics/v0.2.3.json +243 -0
- package/package.json +10 -3
package/dist/git/status.js
CHANGED
|
@@ -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
|
-
|
|
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
|
+
}
|