docrev 0.9.13 → 0.9.15
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/.claude/settings.local.json +9 -9
- package/.gitattributes +1 -1
- package/CHANGELOG.md +149 -149
- package/PLAN-tables-and-postprocess.md +850 -850
- package/README.md +411 -391
- package/bin/rev.js +11 -11
- package/bin/rev.ts +145 -145
- package/completions/rev.bash +127 -127
- package/completions/rev.ps1 +210 -210
- package/completions/rev.zsh +207 -207
- package/dev_notes/stress2/build_adversarial.ts +186 -186
- package/dev_notes/stress2/drift_matcher.ts +62 -62
- package/dev_notes/stress2/probe_anchors.ts +35 -35
- package/dev_notes/stress2/project/discussion.before.md +3 -3
- package/dev_notes/stress2/project/discussion.md +3 -3
- package/dev_notes/stress2/project/methods.before.md +20 -20
- package/dev_notes/stress2/project/methods.md +20 -20
- package/dev_notes/stress2/project/rev.yaml +5 -5
- package/dev_notes/stress2/project/sections.yaml +4 -4
- package/dev_notes/stress2/sections.yaml +5 -5
- package/dev_notes/stress2/trace_placement.ts +50 -50
- package/dev_notes/stresstest_boundaries.ts +27 -27
- package/dev_notes/stresstest_drift_apply.ts +43 -43
- package/dev_notes/stresstest_drift_compare.ts +43 -43
- package/dev_notes/stresstest_drift_v2.ts +54 -54
- package/dev_notes/stresstest_inspect.ts +54 -54
- package/dev_notes/stresstest_pstyle.ts +55 -55
- package/dev_notes/stresstest_section_debug.ts +23 -23
- package/dev_notes/stresstest_split.ts +70 -70
- package/dev_notes/stresstest_trace.ts +19 -19
- package/dev_notes/stresstest_verify_no_overwrite.ts +40 -40
- package/dist/lib/build.d.ts +38 -1
- package/dist/lib/build.d.ts.map +1 -1
- package/dist/lib/build.js +68 -30
- package/dist/lib/build.js.map +1 -1
- package/dist/lib/commands/build.d.ts.map +1 -1
- package/dist/lib/commands/build.js +38 -5
- package/dist/lib/commands/build.js.map +1 -1
- package/dist/lib/commands/utilities.js +164 -164
- package/dist/lib/commands/word-tools.js +8 -8
- package/dist/lib/grammar.js +3 -3
- package/dist/lib/pdf-comments.js +44 -44
- package/dist/lib/plugins.js +57 -57
- package/dist/lib/pptx-themes.js +115 -115
- package/dist/lib/spelling.js +2 -2
- package/dist/lib/templates.js +387 -387
- package/dist/lib/themes.js +51 -51
- package/eslint.config.js +27 -27
- package/lib/anchor-match.ts +276 -276
- package/lib/annotations.ts +644 -644
- package/lib/build.ts +1300 -1251
- package/lib/citations.ts +160 -160
- package/lib/commands/build.ts +833 -801
- package/lib/commands/citations.ts +515 -515
- package/lib/commands/comments.ts +1050 -1050
- package/lib/commands/context.ts +174 -174
- package/lib/commands/core.ts +309 -309
- package/lib/commands/doi.ts +435 -435
- package/lib/commands/file-ops.ts +372 -372
- package/lib/commands/history.ts +320 -320
- package/lib/commands/index.ts +87 -87
- package/lib/commands/init.ts +259 -259
- package/lib/commands/merge-resolve.ts +378 -378
- package/lib/commands/preview.ts +178 -178
- package/lib/commands/project-info.ts +244 -244
- package/lib/commands/quality.ts +517 -517
- package/lib/commands/response.ts +454 -454
- package/lib/commands/section-boundaries.ts +82 -82
- package/lib/commands/sections.ts +451 -451
- package/lib/commands/sync.ts +706 -706
- package/lib/commands/text-ops.ts +449 -449
- package/lib/commands/utilities.ts +448 -448
- package/lib/commands/verify-anchors.ts +272 -272
- package/lib/commands/word-tools.ts +340 -340
- package/lib/comment-realign.ts +517 -517
- package/lib/config.ts +84 -84
- package/lib/crossref.ts +781 -781
- package/lib/csl.ts +191 -191
- package/lib/dependencies.ts +98 -98
- package/lib/diff-engine.ts +465 -465
- package/lib/doi-cache.ts +115 -115
- package/lib/doi.ts +897 -897
- package/lib/equations.ts +506 -506
- package/lib/errors.ts +346 -346
- package/lib/format.ts +541 -541
- package/lib/git.ts +326 -326
- package/lib/grammar.ts +303 -303
- package/lib/image-registry.ts +180 -180
- package/lib/import.ts +911 -911
- package/lib/journals.ts +543 -543
- package/lib/merge.ts +633 -633
- package/lib/orcid.ts +144 -144
- package/lib/pdf-comments.ts +263 -263
- package/lib/pdf-import.ts +524 -524
- package/lib/plugins.ts +362 -362
- package/lib/postprocess.ts +188 -188
- package/lib/pptx-color-filter.lua +37 -37
- package/lib/pptx-template.ts +469 -469
- package/lib/pptx-themes.ts +483 -483
- package/lib/protect-restore.ts +520 -520
- package/lib/rate-limiter.ts +94 -94
- package/lib/response.ts +197 -197
- package/lib/restore-references.ts +240 -240
- package/lib/review.ts +327 -327
- package/lib/schema.ts +417 -417
- package/lib/scientific-words.ts +73 -73
- package/lib/sections.ts +335 -335
- package/lib/slides.ts +756 -756
- package/lib/spelling.ts +334 -334
- package/lib/templates.ts +526 -526
- package/lib/themes.ts +742 -742
- package/lib/trackchanges.ts +247 -247
- package/lib/tui.ts +450 -450
- package/lib/types.ts +550 -550
- package/lib/undo.ts +250 -250
- package/lib/utils.ts +69 -69
- package/lib/variables.ts +179 -179
- package/lib/word-extraction.ts +806 -806
- package/lib/word.ts +643 -643
- package/lib/wordcomments.ts +817 -817
- package/package.json +137 -137
- package/scripts/postbuild.js +28 -28
- package/skill/REFERENCE.md +473 -431
- package/skill/SKILL.md +274 -258
- package/tsconfig.json +26 -26
- package/types/index.d.ts +525 -525
package/lib/git.ts
CHANGED
|
@@ -1,326 +1,326 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Git integration utilities
|
|
3
|
-
* Compare sections against git history
|
|
4
|
-
*/
|
|
5
|
-
|
|
6
|
-
import { execSync } from 'child_process';
|
|
7
|
-
import * as fs from 'fs';
|
|
8
|
-
import * as path from 'path';
|
|
9
|
-
import { diffWords } from 'diff';
|
|
10
|
-
import type { FileChange, CommitInfo, ChangedFile, BlameEntry, AuthorStats, ContributorStats } from './types.js';
|
|
11
|
-
|
|
12
|
-
/**
|
|
13
|
-
* Check if current directory is a git repository
|
|
14
|
-
*/
|
|
15
|
-
export function isGitRepo(): boolean {
|
|
16
|
-
try {
|
|
17
|
-
execSync('git rev-parse --is-inside-work-tree', { stdio: 'pipe' });
|
|
18
|
-
return true;
|
|
19
|
-
} catch {
|
|
20
|
-
return false;
|
|
21
|
-
}
|
|
22
|
-
}
|
|
23
|
-
|
|
24
|
-
/**
|
|
25
|
-
* Get the current git branch
|
|
26
|
-
*/
|
|
27
|
-
export function getCurrentBranch(): string | null {
|
|
28
|
-
try {
|
|
29
|
-
return execSync('git rev-parse --abbrev-ref HEAD', { stdio: 'pipe' })
|
|
30
|
-
.toString()
|
|
31
|
-
.trim();
|
|
32
|
-
} catch {
|
|
33
|
-
return null;
|
|
34
|
-
}
|
|
35
|
-
}
|
|
36
|
-
|
|
37
|
-
/**
|
|
38
|
-
* Get the default branch (main or master)
|
|
39
|
-
*/
|
|
40
|
-
export function getDefaultBranch(): string {
|
|
41
|
-
try {
|
|
42
|
-
// Try to get the remote default branch
|
|
43
|
-
const remote = execSync('git remote show origin', { stdio: 'pipe' })
|
|
44
|
-
.toString();
|
|
45
|
-
const match = remote.match(/HEAD branch:\s*(\S+)/);
|
|
46
|
-
if (match?.[1]) return match[1];
|
|
47
|
-
} catch {
|
|
48
|
-
// Fall through
|
|
49
|
-
}
|
|
50
|
-
|
|
51
|
-
// Check if main or master exists
|
|
52
|
-
try {
|
|
53
|
-
execSync('git rev-parse --verify main', { stdio: 'pipe' });
|
|
54
|
-
return 'main';
|
|
55
|
-
} catch {
|
|
56
|
-
try {
|
|
57
|
-
execSync('git rev-parse --verify master', { stdio: 'pipe' });
|
|
58
|
-
return 'master';
|
|
59
|
-
} catch {
|
|
60
|
-
return 'main'; // Default fallback
|
|
61
|
-
}
|
|
62
|
-
}
|
|
63
|
-
}
|
|
64
|
-
|
|
65
|
-
/**
|
|
66
|
-
* Get file content from a specific git ref
|
|
67
|
-
* @param filePath - Path to file
|
|
68
|
-
* @param ref - Git reference (branch, tag, commit)
|
|
69
|
-
*/
|
|
70
|
-
export function getFileAtRef(filePath: string, ref: string): string | null {
|
|
71
|
-
try {
|
|
72
|
-
return execSync(`git show ${ref}:${filePath}`, { stdio: 'pipe' }).toString();
|
|
73
|
-
} catch {
|
|
74
|
-
return null; // File doesn't exist at that ref
|
|
75
|
-
}
|
|
76
|
-
}
|
|
77
|
-
|
|
78
|
-
/**
|
|
79
|
-
* Get list of changed files between refs
|
|
80
|
-
* @param fromRef - Starting reference
|
|
81
|
-
* @param toRef - Ending reference (default: HEAD)
|
|
82
|
-
*/
|
|
83
|
-
export function getChangedFiles(fromRef: string, toRef: string = 'HEAD'): ChangedFile[] {
|
|
84
|
-
try {
|
|
85
|
-
const output = execSync(`git diff --name-status ${fromRef}..${toRef}`, { stdio: 'pipe' })
|
|
86
|
-
.toString()
|
|
87
|
-
.trim();
|
|
88
|
-
|
|
89
|
-
if (!output) return [];
|
|
90
|
-
|
|
91
|
-
return output.split('\n').map(line => {
|
|
92
|
-
const parts = line.split('\t');
|
|
93
|
-
const status = parts[0];
|
|
94
|
-
const file = parts[1] ?? '';
|
|
95
|
-
return {
|
|
96
|
-
file,
|
|
97
|
-
status: (status === 'A' ? 'added' : status === 'D' ? 'deleted' : 'modified') as 'added' | 'deleted' | 'modified',
|
|
98
|
-
};
|
|
99
|
-
});
|
|
100
|
-
} catch {
|
|
101
|
-
return [];
|
|
102
|
-
}
|
|
103
|
-
}
|
|
104
|
-
|
|
105
|
-
/**
|
|
106
|
-
* Run git log with a given format and optional file path, parse pipe-delimited output
|
|
107
|
-
*/
|
|
108
|
-
function runGitLog(
|
|
109
|
-
format: string,
|
|
110
|
-
limit: number,
|
|
111
|
-
fields: (keyof CommitInfo)[],
|
|
112
|
-
filePath?: string,
|
|
113
|
-
): CommitInfo[] {
|
|
114
|
-
try {
|
|
115
|
-
const fileArg = filePath ? ` -- "${filePath}"` : '';
|
|
116
|
-
const output = execSync(
|
|
117
|
-
`git log --format="${format}" -n ${limit}${fileArg}`,
|
|
118
|
-
{ stdio: 'pipe' }
|
|
119
|
-
).toString().trim();
|
|
120
|
-
|
|
121
|
-
if (!output) return [];
|
|
122
|
-
|
|
123
|
-
return output.split('\n').map(line => {
|
|
124
|
-
const parts = line.split('|');
|
|
125
|
-
const entry: CommitInfo = { hash: '', date: '', author: '', message: '' };
|
|
126
|
-
for (let i = 0; i < fields.length; i++) {
|
|
127
|
-
entry[fields[i]] = parts[i] ?? '';
|
|
128
|
-
}
|
|
129
|
-
return entry;
|
|
130
|
-
});
|
|
131
|
-
} catch {
|
|
132
|
-
return [];
|
|
133
|
-
}
|
|
134
|
-
}
|
|
135
|
-
|
|
136
|
-
/**
|
|
137
|
-
* Get commit history for a file
|
|
138
|
-
* @param filePath - Path to file
|
|
139
|
-
* @param limit - Maximum number of commits to return
|
|
140
|
-
*/
|
|
141
|
-
export function getFileHistory(filePath: string, limit: number = 10): CommitInfo[] {
|
|
142
|
-
return runGitLog('%h|%ci|%s', limit, ['hash', 'date', 'message'], filePath);
|
|
143
|
-
}
|
|
144
|
-
|
|
145
|
-
/**
|
|
146
|
-
* Compare file content between two refs
|
|
147
|
-
* @param filePath - Path to file
|
|
148
|
-
* @param fromRef - Starting reference
|
|
149
|
-
* @param toRef - Ending reference (default: HEAD)
|
|
150
|
-
*/
|
|
151
|
-
export function compareFileVersions(filePath: string, fromRef: string, toRef: string = 'HEAD'): FileChange {
|
|
152
|
-
const oldContent = getFileAtRef(filePath, fromRef) || '';
|
|
153
|
-
const newContent = toRef === 'HEAD'
|
|
154
|
-
? fs.existsSync(filePath) ? fs.readFileSync(filePath, 'utf-8') : ''
|
|
155
|
-
: getFileAtRef(filePath, toRef) || '';
|
|
156
|
-
|
|
157
|
-
const diffs = diffWords(oldContent, newContent);
|
|
158
|
-
|
|
159
|
-
let added = 0;
|
|
160
|
-
let removed = 0;
|
|
161
|
-
const changes: Array<{ added?: boolean; removed?: boolean; value: string }> = [];
|
|
162
|
-
|
|
163
|
-
for (const part of diffs) {
|
|
164
|
-
if (part.added) {
|
|
165
|
-
added += part.value.split(/\s+/).filter(w => w).length;
|
|
166
|
-
changes.push({ added: true, value: part.value });
|
|
167
|
-
} else if (part.removed) {
|
|
168
|
-
removed += part.value.split(/\s+/).filter(w => w).length;
|
|
169
|
-
changes.push({ removed: true, value: part.value });
|
|
170
|
-
}
|
|
171
|
-
}
|
|
172
|
-
|
|
173
|
-
return { added, removed, changes };
|
|
174
|
-
}
|
|
175
|
-
|
|
176
|
-
/**
|
|
177
|
-
* Get word count difference between refs
|
|
178
|
-
* @param files - Array of file paths
|
|
179
|
-
* @param fromRef - Starting reference
|
|
180
|
-
* @param toRef - Ending reference (default: HEAD)
|
|
181
|
-
*/
|
|
182
|
-
export function getWordCountDiff(
|
|
183
|
-
files: string[],
|
|
184
|
-
fromRef: string,
|
|
185
|
-
toRef: string = 'HEAD'
|
|
186
|
-
): { total: { added: number; removed: number }; byFile: Record<string, { added: number; removed: number }> } {
|
|
187
|
-
let totalAdded = 0;
|
|
188
|
-
let totalRemoved = 0;
|
|
189
|
-
const byFile: Record<string, { added: number; removed: number }> = {};
|
|
190
|
-
|
|
191
|
-
for (const file of files) {
|
|
192
|
-
const { added, removed } = compareFileVersions(file, fromRef, toRef);
|
|
193
|
-
totalAdded += added;
|
|
194
|
-
totalRemoved += removed;
|
|
195
|
-
byFile[file] = { added, removed };
|
|
196
|
-
}
|
|
197
|
-
|
|
198
|
-
return {
|
|
199
|
-
total: { added: totalAdded, removed: totalRemoved },
|
|
200
|
-
byFile,
|
|
201
|
-
};
|
|
202
|
-
}
|
|
203
|
-
|
|
204
|
-
/**
|
|
205
|
-
* Get recent commits
|
|
206
|
-
* @param limit - Maximum number of commits to return
|
|
207
|
-
*/
|
|
208
|
-
export function getRecentCommits(limit: number = 10): CommitInfo[] {
|
|
209
|
-
return runGitLog('%h|%ci|%an|%s', limit, ['hash', 'date', 'author', 'message']);
|
|
210
|
-
}
|
|
211
|
-
|
|
212
|
-
/**
|
|
213
|
-
* Check if there are uncommitted changes
|
|
214
|
-
*/
|
|
215
|
-
export function hasUncommittedChanges(): boolean {
|
|
216
|
-
try {
|
|
217
|
-
const output = execSync('git status --porcelain', { stdio: 'pipe' }).toString();
|
|
218
|
-
return output.trim().length > 0;
|
|
219
|
-
} catch {
|
|
220
|
-
return false;
|
|
221
|
-
}
|
|
222
|
-
}
|
|
223
|
-
|
|
224
|
-
/**
|
|
225
|
-
* Get tags
|
|
226
|
-
*/
|
|
227
|
-
export function getTags(): string[] {
|
|
228
|
-
try {
|
|
229
|
-
return execSync('git tag --sort=-creatordate', { stdio: 'pipe' })
|
|
230
|
-
.toString()
|
|
231
|
-
.trim()
|
|
232
|
-
.split('\n')
|
|
233
|
-
.filter(t => t);
|
|
234
|
-
} catch {
|
|
235
|
-
return [];
|
|
236
|
-
}
|
|
237
|
-
}
|
|
238
|
-
|
|
239
|
-
/**
|
|
240
|
-
* Get blame information for a file
|
|
241
|
-
* Returns author and commit info for each line
|
|
242
|
-
* @param filePath - Path to file
|
|
243
|
-
*/
|
|
244
|
-
export function getFileBlame(filePath: string): BlameEntry[] {
|
|
245
|
-
try {
|
|
246
|
-
const output = execSync(
|
|
247
|
-
`git blame --line-porcelain "${filePath}"`,
|
|
248
|
-
{ stdio: 'pipe', maxBuffer: 10 * 1024 * 1024 }
|
|
249
|
-
).toString();
|
|
250
|
-
|
|
251
|
-
const lines = output.split('\n');
|
|
252
|
-
const result: BlameEntry[] = [];
|
|
253
|
-
let current: Partial<BlameEntry> = {};
|
|
254
|
-
let lineNumber = 0;
|
|
255
|
-
|
|
256
|
-
for (const line of lines) {
|
|
257
|
-
if (/^[0-9a-f]{40}/.test(line)) {
|
|
258
|
-
// New blame entry: hash original-line final-line [count]
|
|
259
|
-
const parts = line.split(' ');
|
|
260
|
-
current.hash = parts[0]?.slice(0, 7) ?? '';
|
|
261
|
-
lineNumber = parseInt(parts[2] ?? '0', 10);
|
|
262
|
-
} else if (line.startsWith('author ')) {
|
|
263
|
-
current.author = line.slice(7);
|
|
264
|
-
} else if (line.startsWith('author-time ')) {
|
|
265
|
-
const timestamp = parseInt(line.slice(12), 10);
|
|
266
|
-
current.date = new Date(timestamp * 1000).toISOString().slice(0, 10);
|
|
267
|
-
} else if (line.startsWith('\t')) {
|
|
268
|
-
// Actual content line (prefixed with tab)
|
|
269
|
-
current.content = line.slice(1);
|
|
270
|
-
current.line = lineNumber;
|
|
271
|
-
result.push(current as BlameEntry);
|
|
272
|
-
current = {};
|
|
273
|
-
}
|
|
274
|
-
}
|
|
275
|
-
|
|
276
|
-
return result;
|
|
277
|
-
} catch {
|
|
278
|
-
return [];
|
|
279
|
-
}
|
|
280
|
-
}
|
|
281
|
-
|
|
282
|
-
/**
|
|
283
|
-
* Get author statistics for a file
|
|
284
|
-
* @param filePath - Path to file
|
|
285
|
-
*/
|
|
286
|
-
export function getAuthorStats(filePath: string): Record<string, AuthorStats> {
|
|
287
|
-
const blame = getFileBlame(filePath);
|
|
288
|
-
if (blame.length === 0) return {};
|
|
289
|
-
|
|
290
|
-
const counts: Record<string, number> = {};
|
|
291
|
-
for (const entry of blame) {
|
|
292
|
-
counts[entry.author] = (counts[entry.author] || 0) + 1;
|
|
293
|
-
}
|
|
294
|
-
|
|
295
|
-
const total = blame.length;
|
|
296
|
-
const stats: Record<string, AuthorStats> = {};
|
|
297
|
-
for (const [author, lines] of Object.entries(counts)) {
|
|
298
|
-
stats[author] = {
|
|
299
|
-
lines,
|
|
300
|
-
percentage: Math.round((lines / total) * 100),
|
|
301
|
-
};
|
|
302
|
-
}
|
|
303
|
-
|
|
304
|
-
return stats;
|
|
305
|
-
}
|
|
306
|
-
|
|
307
|
-
/**
|
|
308
|
-
* Get contributors across multiple files
|
|
309
|
-
* @param files - Array of file paths
|
|
310
|
-
*/
|
|
311
|
-
export function getContributors(files: string[]): Record<string, ContributorStats> {
|
|
312
|
-
const contributors: Record<string, ContributorStats> = {};
|
|
313
|
-
|
|
314
|
-
for (const file of files) {
|
|
315
|
-
const stats = getAuthorStats(file);
|
|
316
|
-
for (const [author, data] of Object.entries(stats)) {
|
|
317
|
-
if (!contributors[author]) {
|
|
318
|
-
contributors[author] = { lines: 0, files: 0 };
|
|
319
|
-
}
|
|
320
|
-
contributors[author].lines += data.lines;
|
|
321
|
-
contributors[author].files += 1;
|
|
322
|
-
}
|
|
323
|
-
}
|
|
324
|
-
|
|
325
|
-
return contributors;
|
|
326
|
-
}
|
|
1
|
+
/**
|
|
2
|
+
* Git integration utilities
|
|
3
|
+
* Compare sections against git history
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
import { execSync } from 'child_process';
|
|
7
|
+
import * as fs from 'fs';
|
|
8
|
+
import * as path from 'path';
|
|
9
|
+
import { diffWords } from 'diff';
|
|
10
|
+
import type { FileChange, CommitInfo, ChangedFile, BlameEntry, AuthorStats, ContributorStats } from './types.js';
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* Check if current directory is a git repository
|
|
14
|
+
*/
|
|
15
|
+
export function isGitRepo(): boolean {
|
|
16
|
+
try {
|
|
17
|
+
execSync('git rev-parse --is-inside-work-tree', { stdio: 'pipe' });
|
|
18
|
+
return true;
|
|
19
|
+
} catch {
|
|
20
|
+
return false;
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* Get the current git branch
|
|
26
|
+
*/
|
|
27
|
+
export function getCurrentBranch(): string | null {
|
|
28
|
+
try {
|
|
29
|
+
return execSync('git rev-parse --abbrev-ref HEAD', { stdio: 'pipe' })
|
|
30
|
+
.toString()
|
|
31
|
+
.trim();
|
|
32
|
+
} catch {
|
|
33
|
+
return null;
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* Get the default branch (main or master)
|
|
39
|
+
*/
|
|
40
|
+
export function getDefaultBranch(): string {
|
|
41
|
+
try {
|
|
42
|
+
// Try to get the remote default branch
|
|
43
|
+
const remote = execSync('git remote show origin', { stdio: 'pipe' })
|
|
44
|
+
.toString();
|
|
45
|
+
const match = remote.match(/HEAD branch:\s*(\S+)/);
|
|
46
|
+
if (match?.[1]) return match[1];
|
|
47
|
+
} catch {
|
|
48
|
+
// Fall through
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
// Check if main or master exists
|
|
52
|
+
try {
|
|
53
|
+
execSync('git rev-parse --verify main', { stdio: 'pipe' });
|
|
54
|
+
return 'main';
|
|
55
|
+
} catch {
|
|
56
|
+
try {
|
|
57
|
+
execSync('git rev-parse --verify master', { stdio: 'pipe' });
|
|
58
|
+
return 'master';
|
|
59
|
+
} catch {
|
|
60
|
+
return 'main'; // Default fallback
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/**
|
|
66
|
+
* Get file content from a specific git ref
|
|
67
|
+
* @param filePath - Path to file
|
|
68
|
+
* @param ref - Git reference (branch, tag, commit)
|
|
69
|
+
*/
|
|
70
|
+
export function getFileAtRef(filePath: string, ref: string): string | null {
|
|
71
|
+
try {
|
|
72
|
+
return execSync(`git show ${ref}:${filePath}`, { stdio: 'pipe' }).toString();
|
|
73
|
+
} catch {
|
|
74
|
+
return null; // File doesn't exist at that ref
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/**
|
|
79
|
+
* Get list of changed files between refs
|
|
80
|
+
* @param fromRef - Starting reference
|
|
81
|
+
* @param toRef - Ending reference (default: HEAD)
|
|
82
|
+
*/
|
|
83
|
+
export function getChangedFiles(fromRef: string, toRef: string = 'HEAD'): ChangedFile[] {
|
|
84
|
+
try {
|
|
85
|
+
const output = execSync(`git diff --name-status ${fromRef}..${toRef}`, { stdio: 'pipe' })
|
|
86
|
+
.toString()
|
|
87
|
+
.trim();
|
|
88
|
+
|
|
89
|
+
if (!output) return [];
|
|
90
|
+
|
|
91
|
+
return output.split('\n').map(line => {
|
|
92
|
+
const parts = line.split('\t');
|
|
93
|
+
const status = parts[0];
|
|
94
|
+
const file = parts[1] ?? '';
|
|
95
|
+
return {
|
|
96
|
+
file,
|
|
97
|
+
status: (status === 'A' ? 'added' : status === 'D' ? 'deleted' : 'modified') as 'added' | 'deleted' | 'modified',
|
|
98
|
+
};
|
|
99
|
+
});
|
|
100
|
+
} catch {
|
|
101
|
+
return [];
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
/**
|
|
106
|
+
* Run git log with a given format and optional file path, parse pipe-delimited output
|
|
107
|
+
*/
|
|
108
|
+
function runGitLog(
|
|
109
|
+
format: string,
|
|
110
|
+
limit: number,
|
|
111
|
+
fields: (keyof CommitInfo)[],
|
|
112
|
+
filePath?: string,
|
|
113
|
+
): CommitInfo[] {
|
|
114
|
+
try {
|
|
115
|
+
const fileArg = filePath ? ` -- "${filePath}"` : '';
|
|
116
|
+
const output = execSync(
|
|
117
|
+
`git log --format="${format}" -n ${limit}${fileArg}`,
|
|
118
|
+
{ stdio: 'pipe' }
|
|
119
|
+
).toString().trim();
|
|
120
|
+
|
|
121
|
+
if (!output) return [];
|
|
122
|
+
|
|
123
|
+
return output.split('\n').map(line => {
|
|
124
|
+
const parts = line.split('|');
|
|
125
|
+
const entry: CommitInfo = { hash: '', date: '', author: '', message: '' };
|
|
126
|
+
for (let i = 0; i < fields.length; i++) {
|
|
127
|
+
entry[fields[i]] = parts[i] ?? '';
|
|
128
|
+
}
|
|
129
|
+
return entry;
|
|
130
|
+
});
|
|
131
|
+
} catch {
|
|
132
|
+
return [];
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
/**
|
|
137
|
+
* Get commit history for a file
|
|
138
|
+
* @param filePath - Path to file
|
|
139
|
+
* @param limit - Maximum number of commits to return
|
|
140
|
+
*/
|
|
141
|
+
export function getFileHistory(filePath: string, limit: number = 10): CommitInfo[] {
|
|
142
|
+
return runGitLog('%h|%ci|%s', limit, ['hash', 'date', 'message'], filePath);
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
/**
|
|
146
|
+
* Compare file content between two refs
|
|
147
|
+
* @param filePath - Path to file
|
|
148
|
+
* @param fromRef - Starting reference
|
|
149
|
+
* @param toRef - Ending reference (default: HEAD)
|
|
150
|
+
*/
|
|
151
|
+
export function compareFileVersions(filePath: string, fromRef: string, toRef: string = 'HEAD'): FileChange {
|
|
152
|
+
const oldContent = getFileAtRef(filePath, fromRef) || '';
|
|
153
|
+
const newContent = toRef === 'HEAD'
|
|
154
|
+
? fs.existsSync(filePath) ? fs.readFileSync(filePath, 'utf-8') : ''
|
|
155
|
+
: getFileAtRef(filePath, toRef) || '';
|
|
156
|
+
|
|
157
|
+
const diffs = diffWords(oldContent, newContent);
|
|
158
|
+
|
|
159
|
+
let added = 0;
|
|
160
|
+
let removed = 0;
|
|
161
|
+
const changes: Array<{ added?: boolean; removed?: boolean; value: string }> = [];
|
|
162
|
+
|
|
163
|
+
for (const part of diffs) {
|
|
164
|
+
if (part.added) {
|
|
165
|
+
added += part.value.split(/\s+/).filter(w => w).length;
|
|
166
|
+
changes.push({ added: true, value: part.value });
|
|
167
|
+
} else if (part.removed) {
|
|
168
|
+
removed += part.value.split(/\s+/).filter(w => w).length;
|
|
169
|
+
changes.push({ removed: true, value: part.value });
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
return { added, removed, changes };
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
/**
|
|
177
|
+
* Get word count difference between refs
|
|
178
|
+
* @param files - Array of file paths
|
|
179
|
+
* @param fromRef - Starting reference
|
|
180
|
+
* @param toRef - Ending reference (default: HEAD)
|
|
181
|
+
*/
|
|
182
|
+
export function getWordCountDiff(
|
|
183
|
+
files: string[],
|
|
184
|
+
fromRef: string,
|
|
185
|
+
toRef: string = 'HEAD'
|
|
186
|
+
): { total: { added: number; removed: number }; byFile: Record<string, { added: number; removed: number }> } {
|
|
187
|
+
let totalAdded = 0;
|
|
188
|
+
let totalRemoved = 0;
|
|
189
|
+
const byFile: Record<string, { added: number; removed: number }> = {};
|
|
190
|
+
|
|
191
|
+
for (const file of files) {
|
|
192
|
+
const { added, removed } = compareFileVersions(file, fromRef, toRef);
|
|
193
|
+
totalAdded += added;
|
|
194
|
+
totalRemoved += removed;
|
|
195
|
+
byFile[file] = { added, removed };
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
return {
|
|
199
|
+
total: { added: totalAdded, removed: totalRemoved },
|
|
200
|
+
byFile,
|
|
201
|
+
};
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
/**
|
|
205
|
+
* Get recent commits
|
|
206
|
+
* @param limit - Maximum number of commits to return
|
|
207
|
+
*/
|
|
208
|
+
export function getRecentCommits(limit: number = 10): CommitInfo[] {
|
|
209
|
+
return runGitLog('%h|%ci|%an|%s', limit, ['hash', 'date', 'author', 'message']);
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
/**
|
|
213
|
+
* Check if there are uncommitted changes
|
|
214
|
+
*/
|
|
215
|
+
export function hasUncommittedChanges(): boolean {
|
|
216
|
+
try {
|
|
217
|
+
const output = execSync('git status --porcelain', { stdio: 'pipe' }).toString();
|
|
218
|
+
return output.trim().length > 0;
|
|
219
|
+
} catch {
|
|
220
|
+
return false;
|
|
221
|
+
}
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
/**
|
|
225
|
+
* Get tags
|
|
226
|
+
*/
|
|
227
|
+
export function getTags(): string[] {
|
|
228
|
+
try {
|
|
229
|
+
return execSync('git tag --sort=-creatordate', { stdio: 'pipe' })
|
|
230
|
+
.toString()
|
|
231
|
+
.trim()
|
|
232
|
+
.split('\n')
|
|
233
|
+
.filter(t => t);
|
|
234
|
+
} catch {
|
|
235
|
+
return [];
|
|
236
|
+
}
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
/**
|
|
240
|
+
* Get blame information for a file
|
|
241
|
+
* Returns author and commit info for each line
|
|
242
|
+
* @param filePath - Path to file
|
|
243
|
+
*/
|
|
244
|
+
export function getFileBlame(filePath: string): BlameEntry[] {
|
|
245
|
+
try {
|
|
246
|
+
const output = execSync(
|
|
247
|
+
`git blame --line-porcelain "${filePath}"`,
|
|
248
|
+
{ stdio: 'pipe', maxBuffer: 10 * 1024 * 1024 }
|
|
249
|
+
).toString();
|
|
250
|
+
|
|
251
|
+
const lines = output.split('\n');
|
|
252
|
+
const result: BlameEntry[] = [];
|
|
253
|
+
let current: Partial<BlameEntry> = {};
|
|
254
|
+
let lineNumber = 0;
|
|
255
|
+
|
|
256
|
+
for (const line of lines) {
|
|
257
|
+
if (/^[0-9a-f]{40}/.test(line)) {
|
|
258
|
+
// New blame entry: hash original-line final-line [count]
|
|
259
|
+
const parts = line.split(' ');
|
|
260
|
+
current.hash = parts[0]?.slice(0, 7) ?? '';
|
|
261
|
+
lineNumber = parseInt(parts[2] ?? '0', 10);
|
|
262
|
+
} else if (line.startsWith('author ')) {
|
|
263
|
+
current.author = line.slice(7);
|
|
264
|
+
} else if (line.startsWith('author-time ')) {
|
|
265
|
+
const timestamp = parseInt(line.slice(12), 10);
|
|
266
|
+
current.date = new Date(timestamp * 1000).toISOString().slice(0, 10);
|
|
267
|
+
} else if (line.startsWith('\t')) {
|
|
268
|
+
// Actual content line (prefixed with tab)
|
|
269
|
+
current.content = line.slice(1);
|
|
270
|
+
current.line = lineNumber;
|
|
271
|
+
result.push(current as BlameEntry);
|
|
272
|
+
current = {};
|
|
273
|
+
}
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
return result;
|
|
277
|
+
} catch {
|
|
278
|
+
return [];
|
|
279
|
+
}
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
/**
|
|
283
|
+
* Get author statistics for a file
|
|
284
|
+
* @param filePath - Path to file
|
|
285
|
+
*/
|
|
286
|
+
export function getAuthorStats(filePath: string): Record<string, AuthorStats> {
|
|
287
|
+
const blame = getFileBlame(filePath);
|
|
288
|
+
if (blame.length === 0) return {};
|
|
289
|
+
|
|
290
|
+
const counts: Record<string, number> = {};
|
|
291
|
+
for (const entry of blame) {
|
|
292
|
+
counts[entry.author] = (counts[entry.author] || 0) + 1;
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
const total = blame.length;
|
|
296
|
+
const stats: Record<string, AuthorStats> = {};
|
|
297
|
+
for (const [author, lines] of Object.entries(counts)) {
|
|
298
|
+
stats[author] = {
|
|
299
|
+
lines,
|
|
300
|
+
percentage: Math.round((lines / total) * 100),
|
|
301
|
+
};
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
return stats;
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
/**
|
|
308
|
+
* Get contributors across multiple files
|
|
309
|
+
* @param files - Array of file paths
|
|
310
|
+
*/
|
|
311
|
+
export function getContributors(files: string[]): Record<string, ContributorStats> {
|
|
312
|
+
const contributors: Record<string, ContributorStats> = {};
|
|
313
|
+
|
|
314
|
+
for (const file of files) {
|
|
315
|
+
const stats = getAuthorStats(file);
|
|
316
|
+
for (const [author, data] of Object.entries(stats)) {
|
|
317
|
+
if (!contributors[author]) {
|
|
318
|
+
contributors[author] = { lines: 0, files: 0 };
|
|
319
|
+
}
|
|
320
|
+
contributors[author].lines += data.lines;
|
|
321
|
+
contributors[author].files += 1;
|
|
322
|
+
}
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
return contributors;
|
|
326
|
+
}
|