diff-easy 0.1.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/LICENSE +22 -0
- package/README.md +142 -0
- package/dist/cli/cmux.d.ts +9 -0
- package/dist/cli/cmux.js +274 -0
- package/dist/cli/comment.d.ts +2 -0
- package/dist/cli/comment.js +91 -0
- package/dist/cli/github.d.ts +84 -0
- package/dist/cli/github.js +364 -0
- package/dist/cli/index.d.ts +2 -0
- package/dist/cli/index.js +397 -0
- package/dist/cli/turns.d.ts +14 -0
- package/dist/cli/turns.js +333 -0
- package/dist/cli/utils.d.ts +27 -0
- package/dist/cli/utils.js +204 -0
- package/dist/client/assets/arc-CHYjdFTk.js +1 -0
- package/dist/client/assets/architecture-7EHR7CIX-weHBYDgM.js +1 -0
- package/dist/client/assets/architectureDiagram-3BPJPVTR-DRa2msuJ.js +36 -0
- package/dist/client/assets/array-BifhSqXX.js +1 -0
- package/dist/client/assets/blockDiagram-GPEHLZMM-BTdKsXy5.js +132 -0
- package/dist/client/assets/c4Diagram-AAUBKEIU-CwTvCkOg.js +10 -0
- package/dist/client/assets/channel-Cv40nQW1.js +1 -0
- package/dist/client/assets/chunk-2J33WTMH-BFsjrTtc.js +1 -0
- package/dist/client/assets/chunk-3OPIFGDE-V_xVw1dq.js +62 -0
- package/dist/client/assets/chunk-4BX2VUAB-DoLO6LLy.js +1 -0
- package/dist/client/assets/chunk-55IACEB6-CHgBwbdB.js +1 -0
- package/dist/client/assets/chunk-5ZQYHXKU-DMixAgQG.js +2 -0
- package/dist/client/assets/chunk-727SXJPM-C-3FZTmm.js +206 -0
- package/dist/client/assets/chunk-AQP2D5EJ-DpQZhXt-.js +231 -0
- package/dist/client/assets/chunk-BSJP7CBP-BnO9E_G4.js +1 -0
- package/dist/client/assets/chunk-CSCIHK7Q-BxItphyO.js +124 -0
- package/dist/client/assets/chunk-FMBD7UC4-4hEcfvfT.js +15 -0
- package/dist/client/assets/chunk-KSCS5N6A-DnArkpOI.js +10 -0
- package/dist/client/assets/chunk-L5ZTLDWV-DQoYJ1BW.js +1 -0
- package/dist/client/assets/chunk-LZXEDZCA-CdCevBX-.js +2 -0
- package/dist/client/assets/chunk-ND2GUHAM-Dt89U_nU.js +1 -0
- package/dist/client/assets/chunk-NNHCCRGN-DlpIbxXb.js +159 -0
- package/dist/client/assets/chunk-NZK2D7GU-C2Ev_f_w.js +1 -0
- package/dist/client/assets/chunk-O5CBEL6O-BeZ3MFnJ.js +70 -0
- package/dist/client/assets/chunk-QZHKN3VN-BmDOSWHp.js +1 -0
- package/dist/client/assets/chunk-WU5MYG2G-gLuInTk4.js +1 -0
- package/dist/client/assets/chunk-XPW4576I-DPbDc8_T.js +32 -0
- package/dist/client/assets/classDiagram-4FO5ZUOK-DOpVKUrb.js +1 -0
- package/dist/client/assets/classDiagram-v2-Q7XG4LA2-DOpVKUrb.js +1 -0
- package/dist/client/assets/cose-bilkent-S5V4N54A-CCPyalms.js +1 -0
- package/dist/client/assets/cytoscape.esm-FqbQrHcz.js +321 -0
- package/dist/client/assets/dagre-BM42HDAG-6jrYMjX3.js +4 -0
- package/dist/client/assets/dagre-Bx709z4p.js +1 -0
- package/dist/client/assets/defaultLocale-C8Fc0cco.js +1 -0
- package/dist/client/assets/diagram-2AECGRRQ-dCQ_KoXl.js +43 -0
- package/dist/client/assets/diagram-5GNKFQAL-B3Xj4-XE.js +10 -0
- package/dist/client/assets/diagram-KO2AKTUF-DaGgF2yL.js +3 -0
- package/dist/client/assets/diagram-LMA3HP47-CF_c4tPT.js +24 -0
- package/dist/client/assets/diagram-OG6HWLK6-BgBHbJkT.js +24 -0
- package/dist/client/assets/dist-7wVkzZne.js +1 -0
- package/dist/client/assets/erDiagram-TEJ5UH35-BZgX1grR.js +85 -0
- package/dist/client/assets/eventmodeling-FCH6USID-BaI6RNKr.js +1 -0
- package/dist/client/assets/flowDiagram-I6XJVG4X-BFNUa12V.js +162 -0
- package/dist/client/assets/ganttDiagram-6RSMTGT7-C8t6BThI.js +292 -0
- package/dist/client/assets/gitGraph-WXDBUCRP-BlicfDkM.js +1 -0
- package/dist/client/assets/gitGraphDiagram-PVQCEYII-BmnIXuXP.js +106 -0
- package/dist/client/assets/graphlib-B8gBHxth.js +1 -0
- package/dist/client/assets/index-BfVtbKTZ.css +2 -0
- package/dist/client/assets/index-p8zzfjdu.js +87 -0
- package/dist/client/assets/info-J43DQDTF-DHLG38N3.js +1 -0
- package/dist/client/assets/infoDiagram-5YYISTIA-oQ-lq-45.js +2 -0
- package/dist/client/assets/init-D6jRqBbL.js +1 -0
- package/dist/client/assets/ishikawaDiagram-YF4QCWOH-DrODlBLD.js +70 -0
- package/dist/client/assets/journeyDiagram-JHISSGLW-Bxg5wCQz.js +139 -0
- package/dist/client/assets/kanban-definition-UN3LZRKU-DAdvUDIB.js +89 -0
- package/dist/client/assets/katex-Vhh-h91d.js +257 -0
- package/dist/client/assets/line-D8kNd46u.js +1 -0
- package/dist/client/assets/linear-jwGZSrg4.js +1 -0
- package/dist/client/assets/mermaid-parser.core-PVPfUfyU.js +4 -0
- package/dist/client/assets/mermaid.core-DwdP6CFg.js +9 -0
- package/dist/client/assets/mindmap-definition-RKZ34NQL-DeOoL56Q.js +96 -0
- package/dist/client/assets/ordinal-hYBb2elL.js +1 -0
- package/dist/client/assets/packet-YPE3B663-BBEaxdJP.js +1 -0
- package/dist/client/assets/path-BWPyau1x.js +1 -0
- package/dist/client/assets/pie-LRSECV5Y-fvTRFm-2.js +1 -0
- package/dist/client/assets/pieDiagram-4H26LBE5-oep1px2r.js +30 -0
- package/dist/client/assets/prism-bash-D6zCJ74D.js +1 -0
- package/dist/client/assets/prism-clojure-_z-zoNcE.js +1 -0
- package/dist/client/assets/prism-csharp-XPn2bw0d.js +1 -0
- package/dist/client/assets/prism-dart-CDv16Fbu.js +1 -0
- package/dist/client/assets/prism-elixir-DhHhV5Rj.js +1 -0
- package/dist/client/assets/prism-haskell-5hq1Nh9c.js +1 -0
- package/dist/client/assets/prism-hcl-B6Z0Ouoo.js +1 -0
- package/dist/client/assets/prism-java-6wtusvIl.js +1 -0
- package/dist/client/assets/prism-markup-templating-KkSMBj6Y.js +1 -0
- package/dist/client/assets/prism-nix-C88dTX93.js +1 -0
- package/dist/client/assets/prism-perl-Lsbi34sH.js +1 -0
- package/dist/client/assets/prism-php-Co4fM40K.js +1 -0
- package/dist/client/assets/prism-protobuf-yzSHlhwy.js +1 -0
- package/dist/client/assets/prism-ruby-DFCASkuD.js +1 -0
- package/dist/client/assets/prism-scala-VkM3wCOL.js +1 -0
- package/dist/client/assets/prism-solidity-DTom4k5p.js +1 -0
- package/dist/client/assets/prism-sql-CYhVDMEE.js +1 -0
- package/dist/client/assets/prism-vim-BgpkjCK5.js +1 -0
- package/dist/client/assets/quadrantDiagram-W4KKPZXB-D9l66CHb.js +7 -0
- package/dist/client/assets/radar-GUYGQ44K-DAU5EPVd.js +1 -0
- package/dist/client/assets/requirementDiagram-4Y6WPE33-helpQlwb.js +84 -0
- package/dist/client/assets/rough.esm-CSKSodPl.js +1 -0
- package/dist/client/assets/sankeyDiagram-5OEKKPKP-C9sNuuvA.js +40 -0
- package/dist/client/assets/sequenceDiagram-3UESZ5HK-BP0bhCwQ.js +162 -0
- package/dist/client/assets/src-CDAPGf2l.js +1 -0
- package/dist/client/assets/stateDiagram-AJRCARHV-CsFizpla.js +1 -0
- package/dist/client/assets/stateDiagram-v2-BHNVJYJU-Bvn8tanl.js +1 -0
- package/dist/client/assets/timeline-definition-PNZ67QCA-BnkmHs1F.js +120 -0
- package/dist/client/assets/treeView-BLDUP644-DD9HZO5W.js +1 -0
- package/dist/client/assets/treemap-LRROVOQU-BBKuQ1lo.js +1 -0
- package/dist/client/assets/vennDiagram-CIIHVFJN-BPFTHflF.js +34 -0
- package/dist/client/assets/wardley-L42UT6IY-y88gDkwb.js +1 -0
- package/dist/client/assets/wardleyDiagram-YWT4CUSO-BlcA7IVh.js +78 -0
- package/dist/client/assets/xychartDiagram-2RQKCTM6-DX5vUCRH.js +7 -0
- package/dist/client/favicon.png +0 -0
- package/dist/client/index.html +14 -0
- package/dist/client/logo.png +0 -0
- package/dist/client/site-data/blobs/080c0e6/cHVibGljL3NpdGUtZGF0YS9vZy1pbWFnZS5wbmc.png +0 -0
- package/dist/client/site-data/blobs/55f23a1/bGFuZGluZy9wdWJsaWMvZGlmaXQvbG9nby5wbmc.png +0 -0
- package/dist/client/site-data/blobs/66ff7c6/cHVibGljL2xvZ28ucG5n.png +0 -0
- package/dist/client/site-data/blobs/e6977fe/cHVibGljL2xvZ28ucG5n.png +0 -0
- package/dist/client/site-data/manifest.json +1 -0
- package/dist/client/site-data/og-image.png +0 -0
- package/dist/client/site-data/snapshots/55f23a1...080c0e6.json +1 -0
- package/dist/client/site-data/snapshots/66ff7c6...e6977fe.json +1 -0
- package/dist/client/site-data/snapshots/7d40fd4...a72112f-comments.json +1 -0
- package/dist/client/site-data/snapshots/7d40fd4...a72112f.json +1 -0
- package/dist/server/file-watcher.d.ts +33 -0
- package/dist/server/file-watcher.js +340 -0
- package/dist/server/generated-file-check.d.ts +17 -0
- package/dist/server/generated-file-check.js +280 -0
- package/dist/server/git-diff.d.ts +94 -0
- package/dist/server/git-diff.js +909 -0
- package/dist/server/grouping/cache.d.ts +3 -0
- package/dist/server/grouping/cache.js +22 -0
- package/dist/server/grouping/claudeCli.d.ts +10 -0
- package/dist/server/grouping/claudeCli.js +95 -0
- package/dist/server/grouping/digest.d.ts +15 -0
- package/dist/server/grouping/digest.js +38 -0
- package/dist/server/grouping/reconcile.d.ts +6 -0
- package/dist/server/grouping/reconcile.js +34 -0
- package/dist/server/grouping/types.d.ts +9 -0
- package/dist/server/grouping/types.js +1 -0
- package/dist/server/server.d.ts +37 -0
- package/dist/server/server.js +1283 -0
- package/dist/shared/grouping.d.ts +4 -0
- package/dist/shared/grouping.js +170 -0
- package/dist/types/diff.d.ts +263 -0
- package/dist/types/diff.js +1 -0
- package/dist/types/watch.d.ts +59 -0
- package/dist/types/watch.js +8 -0
- package/dist/utils/commentFormatting.d.ts +6 -0
- package/dist/utils/commentFormatting.js +90 -0
- package/dist/utils/commentImports.d.ts +11 -0
- package/dist/utils/commentImports.js +386 -0
- package/dist/utils/createId.d.ts +1 -0
- package/dist/utils/createId.js +5 -0
- package/dist/utils/diffMode.d.ts +3 -0
- package/dist/utils/diffMode.js +13 -0
- package/dist/utils/diffSelection.d.ts +13 -0
- package/dist/utils/diffSelection.js +39 -0
- package/dist/utils/editorOptions.d.ts +60 -0
- package/dist/utils/editorOptions.js +171 -0
- package/dist/utils/fileUtils.d.ts +12 -0
- package/dist/utils/fileUtils.js +21 -0
- package/dist/utils/hubConfig.d.ts +32 -0
- package/dist/utils/hubConfig.js +51 -0
- package/dist/utils/suggestionUtils.d.ts +19 -0
- package/dist/utils/suggestionUtils.js +31 -0
- package/dist/utils/turnLog.d.ts +47 -0
- package/dist/utils/turnLog.js +133 -0
- package/package.json +105 -0
|
@@ -0,0 +1,909 @@
|
|
|
1
|
+
import { simpleGit } from 'simple-git';
|
|
2
|
+
import { isAbsolute, resolve, sep } from 'path';
|
|
3
|
+
import { validateDiffArguments, shortHash, createCommitRangeString, findUntrackedFiles, } from '../cli/utils.js';
|
|
4
|
+
import { getMergeBaseTargetRef, normalizeBaseMode } from '../utils/diffSelection.js';
|
|
5
|
+
import { isGeneratedFile } from './generated-file-check.js';
|
|
6
|
+
/**
|
|
7
|
+
* Run `fn` over `items` with at most `limit` in flight at once, preserving input
|
|
8
|
+
* order in the results. Keeps unbounded fan-out (one git spawn per file) from
|
|
9
|
+
* exhausting processes/memory while staying non-blocking.
|
|
10
|
+
*/
|
|
11
|
+
async function mapWithConcurrency(items, limit, fn) {
|
|
12
|
+
const results = new Array(items.length);
|
|
13
|
+
let next = 0;
|
|
14
|
+
const worker = async () => {
|
|
15
|
+
while (next < items.length) {
|
|
16
|
+
const index = next++;
|
|
17
|
+
results[index] = await fn(items[index]);
|
|
18
|
+
}
|
|
19
|
+
};
|
|
20
|
+
await Promise.all(Array.from({ length: Math.min(limit, items.length) }, worker));
|
|
21
|
+
return results;
|
|
22
|
+
}
|
|
23
|
+
export class GitDiffParser {
|
|
24
|
+
git;
|
|
25
|
+
repoPath;
|
|
26
|
+
includeUntracked;
|
|
27
|
+
resolvedCommitCache = new Map();
|
|
28
|
+
static RESOLVED_COMMIT_CACHE_TTL_MS = 5_000;
|
|
29
|
+
static GENERATED_HEADER_SCAN_BYTES = 4 * 1024;
|
|
30
|
+
static GITATTRIBUTES_CHECK_CHUNK_SIZE = 200;
|
|
31
|
+
static UNTRACKED_DIFF_MAX_BUFFER = 100 * 1024 * 1024; // 100MB
|
|
32
|
+
// Bound the per-fetch untracked work so a repo full of untracked files (fresh
|
|
33
|
+
// clone, build output) can't spawn thousands of git processes or hog memory.
|
|
34
|
+
static UNTRACKED_DIFF_CONCURRENCY = 8;
|
|
35
|
+
static MAX_UNTRACKED_FILES = 2000;
|
|
36
|
+
// `includeUntracked` is opt-in: the hub opens arbitrary repos at runtime where
|
|
37
|
+
// it cannot show difit's interactive "include untracked?" prompt, so it asks
|
|
38
|
+
// for read-only synthesis instead (see getUntrackedFileDiffs). The CLI leaves
|
|
39
|
+
// it off and keeps owning untracked files via its intent-to-add prompt.
|
|
40
|
+
constructor(repoPath = process.cwd(), options = {}) {
|
|
41
|
+
this.repoPath = repoPath;
|
|
42
|
+
this.git = simpleGit(repoPath);
|
|
43
|
+
this.includeUntracked = options.includeUntracked ?? false;
|
|
44
|
+
}
|
|
45
|
+
normalizeRepositoryRelativePath(filepath) {
|
|
46
|
+
if (filepath.length === 0) {
|
|
47
|
+
throw new Error('Invalid file path');
|
|
48
|
+
}
|
|
49
|
+
const normalizedFilepath = filepath.replace(/\\/g, '/');
|
|
50
|
+
const hasParentTraversal = normalizedFilepath.split('/').some((segment) => segment === '..');
|
|
51
|
+
if (isAbsolute(filepath) || normalizedFilepath.startsWith('/') || hasParentTraversal) {
|
|
52
|
+
throw new Error('File path outside repository');
|
|
53
|
+
}
|
|
54
|
+
const repositoryPath = resolve(this.repoPath);
|
|
55
|
+
const resolvedPath = resolve(repositoryPath, normalizedFilepath);
|
|
56
|
+
if (resolvedPath !== repositoryPath && !resolvedPath.startsWith(`${repositoryPath}${sep}`)) {
|
|
57
|
+
throw new Error('File path outside repository');
|
|
58
|
+
}
|
|
59
|
+
return normalizedFilepath;
|
|
60
|
+
}
|
|
61
|
+
async resolveBaseCommitish(selection) {
|
|
62
|
+
if (normalizeBaseMode(selection.baseMode) !== 'merge-base') {
|
|
63
|
+
return selection.baseCommitish;
|
|
64
|
+
}
|
|
65
|
+
const targetRef = getMergeBaseTargetRef(selection.targetCommitish);
|
|
66
|
+
const mergeBase = await this.git.raw(['merge-base', targetRef, selection.baseCommitish]);
|
|
67
|
+
return mergeBase.trim();
|
|
68
|
+
}
|
|
69
|
+
async parseDiff(selection, ignoreWhitespace = false, contextLines) {
|
|
70
|
+
const { targetCommitish, baseCommitish } = selection;
|
|
71
|
+
const requestedBaseMode = normalizeBaseMode(selection.baseMode) === 'merge-base' ? 'merge-base' : undefined;
|
|
72
|
+
try {
|
|
73
|
+
// diff-easy: a turn that produced no changes has identical before/after
|
|
74
|
+
// snapshots (git dedups equal trees), so base === target. Treat that as an
|
|
75
|
+
// empty diff instead of erroring ("Cannot compare X with itself").
|
|
76
|
+
if (baseCommitish === targetCommitish) {
|
|
77
|
+
const hash = shortHash(baseCommitish);
|
|
78
|
+
return {
|
|
79
|
+
commit: 'No changes in this turn',
|
|
80
|
+
files: [],
|
|
81
|
+
isEmpty: true,
|
|
82
|
+
baseCommitish: hash,
|
|
83
|
+
targetCommitish: hash,
|
|
84
|
+
requestedBaseCommitish: baseCommitish,
|
|
85
|
+
requestedTargetCommitish: targetCommitish,
|
|
86
|
+
requestedBaseMode,
|
|
87
|
+
};
|
|
88
|
+
}
|
|
89
|
+
// Validate arguments
|
|
90
|
+
const validation = validateDiffArguments(targetCommitish, baseCommitish);
|
|
91
|
+
if (!validation.valid) {
|
|
92
|
+
throw new Error(validation.error);
|
|
93
|
+
}
|
|
94
|
+
const effectiveBaseCommitish = await this.resolveBaseCommitish(selection);
|
|
95
|
+
let resolvedCommit;
|
|
96
|
+
let diffArgs;
|
|
97
|
+
let resolvedBaseCommitish = effectiveBaseCommitish;
|
|
98
|
+
let resolvedTargetCommitish = targetCommitish;
|
|
99
|
+
let attributesRef = targetCommitish;
|
|
100
|
+
// Handle target special chars (base is always a regular commit)
|
|
101
|
+
if (targetCommitish === 'working') {
|
|
102
|
+
// Show unstaged changes (working vs staged)
|
|
103
|
+
resolvedCommit = 'Working Directory (unstaged changes)';
|
|
104
|
+
diffArgs = [];
|
|
105
|
+
}
|
|
106
|
+
else if (targetCommitish === 'staged') {
|
|
107
|
+
// Show staged changes against base commit
|
|
108
|
+
const baseHash = await this.git.revparse([effectiveBaseCommitish]);
|
|
109
|
+
resolvedCommit = `${shortHash(baseHash)} vs Staging Area (staged changes)`;
|
|
110
|
+
resolvedBaseCommitish = shortHash(baseHash);
|
|
111
|
+
diffArgs = ['--cached', effectiveBaseCommitish];
|
|
112
|
+
}
|
|
113
|
+
else if (targetCommitish === '.') {
|
|
114
|
+
// Show all uncommitted changes against base commit
|
|
115
|
+
const baseHash = await this.git.revparse([effectiveBaseCommitish]);
|
|
116
|
+
resolvedCommit = `${shortHash(baseHash)} vs Working Directory (all uncommitted changes)`;
|
|
117
|
+
resolvedBaseCommitish = shortHash(baseHash);
|
|
118
|
+
diffArgs = [effectiveBaseCommitish];
|
|
119
|
+
}
|
|
120
|
+
else {
|
|
121
|
+
// Both are regular commits: standard commit-to-commit comparison
|
|
122
|
+
const targetHash = await this.git.revparse([targetCommitish]);
|
|
123
|
+
const baseHash = await this.git.revparse([effectiveBaseCommitish]);
|
|
124
|
+
resolvedCommit = createCommitRangeString(shortHash(baseHash), shortHash(targetHash));
|
|
125
|
+
resolvedBaseCommitish = shortHash(baseHash);
|
|
126
|
+
resolvedTargetCommitish = shortHash(targetHash);
|
|
127
|
+
attributesRef = targetHash;
|
|
128
|
+
diffArgs = [baseHash, targetHash];
|
|
129
|
+
}
|
|
130
|
+
if (ignoreWhitespace) {
|
|
131
|
+
diffArgs.push('-w');
|
|
132
|
+
}
|
|
133
|
+
if (contextLines !== undefined) {
|
|
134
|
+
diffArgs.push(`-U${contextLines}`);
|
|
135
|
+
}
|
|
136
|
+
// Ignore external diff-tools to unify output.
|
|
137
|
+
// https://github.com/yoshiko-pg/difit/issues/19
|
|
138
|
+
diffArgs.push('--no-ext-diff', '--color=never');
|
|
139
|
+
// Single git invocation for better startup latency on large repositories.
|
|
140
|
+
let diffRaw = await this.git.diff(diffArgs);
|
|
141
|
+
// `git diff` never reports untracked files, so brand-new files are invisible
|
|
142
|
+
// in the working ('working') and all-uncommitted ('.') views. When enabled
|
|
143
|
+
// (hub mode), synthesize new-file diffs for them — read-only, no index
|
|
144
|
+
// mutation. The staged ('--cached') view correctly excludes them.
|
|
145
|
+
if (this.includeUntracked && (targetCommitish === 'working' || targetCommitish === '.')) {
|
|
146
|
+
const untrackedDiff = await this.getUntrackedFileDiffs(ignoreWhitespace, contextLines);
|
|
147
|
+
if (untrackedDiff) {
|
|
148
|
+
diffRaw =
|
|
149
|
+
diffRaw && !diffRaw.endsWith('\n')
|
|
150
|
+
? `${diffRaw}\n${untrackedDiff}`
|
|
151
|
+
: `${diffRaw}${untrackedDiff}`;
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
const files = await this.markGitattributesGeneratedFiles(this.parseUnifiedDiff(diffRaw), attributesRef);
|
|
155
|
+
return {
|
|
156
|
+
commit: resolvedCommit,
|
|
157
|
+
files,
|
|
158
|
+
isEmpty: files.length === 0,
|
|
159
|
+
baseCommitish: resolvedBaseCommitish,
|
|
160
|
+
targetCommitish: resolvedTargetCommitish,
|
|
161
|
+
requestedBaseCommitish: baseCommitish,
|
|
162
|
+
requestedTargetCommitish: targetCommitish,
|
|
163
|
+
requestedBaseMode,
|
|
164
|
+
};
|
|
165
|
+
}
|
|
166
|
+
catch (error) {
|
|
167
|
+
throw new Error(`Failed to parse diff for ${targetCommitish} vs ${baseCommitish}: ${error instanceof Error ? error.message : 'Unknown error'}`);
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
/**
|
|
171
|
+
* Build synthetic unified diffs for untracked (new, not-yet-added) files.
|
|
172
|
+
*
|
|
173
|
+
* difit's own untracked support works by mutating the index (`git add
|
|
174
|
+
* --intent-to-add`) so the normal `git diff` picks the files up. The hub can't
|
|
175
|
+
* prompt for that and must not mutate repos it merely views, so instead we
|
|
176
|
+
* reuse difit's read-only enumerator (`findUntrackedFiles`) and produce a
|
|
177
|
+
* new-file diff per file with `git diff --no-index -- /dev/null <file>`, which
|
|
178
|
+
* never touches the index.
|
|
179
|
+
*/
|
|
180
|
+
async getUntrackedFileDiffs(ignoreWhitespace, contextLines) {
|
|
181
|
+
let files = await findUntrackedFiles(this.git);
|
|
182
|
+
if (files.length === 0) {
|
|
183
|
+
return '';
|
|
184
|
+
}
|
|
185
|
+
// Guard against repos with a huge number of untracked files (fresh clone,
|
|
186
|
+
// build artifacts): cap the work and tell the operator what was dropped
|
|
187
|
+
// rather than silently spawning thousands of git processes.
|
|
188
|
+
if (files.length > GitDiffParser.MAX_UNTRACKED_FILES) {
|
|
189
|
+
console.warn(`difit: ${files.length - GitDiffParser.MAX_UNTRACKED_FILES} untracked file(s) beyond ${GitDiffParser.MAX_UNTRACKED_FILES} are not shown in the diff`);
|
|
190
|
+
files = files.slice(0, GitDiffParser.MAX_UNTRACKED_FILES);
|
|
191
|
+
}
|
|
192
|
+
const { execFile } = await import('child_process');
|
|
193
|
+
const synthesizeOne = (file) => {
|
|
194
|
+
const args = ['diff', '--no-index', '--no-ext-diff', '--color=never'];
|
|
195
|
+
if (ignoreWhitespace) {
|
|
196
|
+
args.push('-w');
|
|
197
|
+
}
|
|
198
|
+
if (contextLines !== undefined) {
|
|
199
|
+
args.push(`-U${contextLines}`);
|
|
200
|
+
}
|
|
201
|
+
args.push('--', '/dev/null', file);
|
|
202
|
+
// Async (non-blocking) so a hub fetch over many untracked files never
|
|
203
|
+
// stalls the event loop — other requests, SSE pings and watcher callbacks
|
|
204
|
+
// keep flowing while git runs.
|
|
205
|
+
return new Promise((resolve) => {
|
|
206
|
+
execFile('git', args, {
|
|
207
|
+
cwd: this.repoPath,
|
|
208
|
+
encoding: 'utf8',
|
|
209
|
+
maxBuffer: GitDiffParser.UNTRACKED_DIFF_MAX_BUFFER,
|
|
210
|
+
}, (error, stdout) => {
|
|
211
|
+
// `git diff --no-index` exits 1 when the files differ (always here,
|
|
212
|
+
// since we diff against /dev/null) and still writes the diff to
|
|
213
|
+
// stdout. Treat exit 1 as success; skip anything else (e.g. the file
|
|
214
|
+
// vanished between listing and diffing) rather than failing the fetch.
|
|
215
|
+
const code = error?.code;
|
|
216
|
+
if (error && code !== 1) {
|
|
217
|
+
resolve('');
|
|
218
|
+
return;
|
|
219
|
+
}
|
|
220
|
+
resolve((stdout ?? '').toString());
|
|
221
|
+
});
|
|
222
|
+
});
|
|
223
|
+
};
|
|
224
|
+
const outputs = await mapWithConcurrency(files, GitDiffParser.UNTRACKED_DIFF_CONCURRENCY, synthesizeOne);
|
|
225
|
+
return outputs
|
|
226
|
+
.filter((output) => output.trim())
|
|
227
|
+
.map((output) => (output.endsWith('\n') ? output : `${output}\n`))
|
|
228
|
+
.join('');
|
|
229
|
+
}
|
|
230
|
+
parseUnifiedDiff(diffText) {
|
|
231
|
+
const files = [];
|
|
232
|
+
const fileBlocks = diffText.split(/^diff --git /m).slice(1);
|
|
233
|
+
for (const fileBlock of fileBlocks) {
|
|
234
|
+
const block = `diff --git ${fileBlock}`;
|
|
235
|
+
const file = this.parseFileBlock(block);
|
|
236
|
+
if (file) {
|
|
237
|
+
files.push(file);
|
|
238
|
+
}
|
|
239
|
+
}
|
|
240
|
+
return files;
|
|
241
|
+
}
|
|
242
|
+
getGitattributesSourceArgs(ref) {
|
|
243
|
+
if (ref === 'working' || ref === '.') {
|
|
244
|
+
return [];
|
|
245
|
+
}
|
|
246
|
+
if (ref === 'staged') {
|
|
247
|
+
return ['--cached'];
|
|
248
|
+
}
|
|
249
|
+
return ['--source', ref];
|
|
250
|
+
}
|
|
251
|
+
parseGitattributesGeneratedOutput(output) {
|
|
252
|
+
const generatedPaths = new Set();
|
|
253
|
+
const fields = output.split('\0');
|
|
254
|
+
for (let i = 0; i + 2 < fields.length; i += 3) {
|
|
255
|
+
const [path, attribute, value] = fields.slice(i, i + 3);
|
|
256
|
+
if (attribute === 'linguist-generated' && value === 'true') {
|
|
257
|
+
generatedPaths.add(path);
|
|
258
|
+
}
|
|
259
|
+
}
|
|
260
|
+
return generatedPaths;
|
|
261
|
+
}
|
|
262
|
+
async getGitattributesGeneratedPaths(filepaths, ref) {
|
|
263
|
+
if (filepaths.length === 0) {
|
|
264
|
+
return new Set();
|
|
265
|
+
}
|
|
266
|
+
const generatedPaths = new Set();
|
|
267
|
+
try {
|
|
268
|
+
for (let i = 0; i < filepaths.length; i += GitDiffParser.GITATTRIBUTES_CHECK_CHUNK_SIZE) {
|
|
269
|
+
const chunk = filepaths.slice(i, i + GitDiffParser.GITATTRIBUTES_CHECK_CHUNK_SIZE);
|
|
270
|
+
const output = await this.git.raw([
|
|
271
|
+
'check-attr',
|
|
272
|
+
'-z',
|
|
273
|
+
...this.getGitattributesSourceArgs(ref),
|
|
274
|
+
'linguist-generated',
|
|
275
|
+
'--',
|
|
276
|
+
...chunk,
|
|
277
|
+
]);
|
|
278
|
+
if (typeof output !== 'string') {
|
|
279
|
+
continue;
|
|
280
|
+
}
|
|
281
|
+
for (const path of this.parseGitattributesGeneratedOutput(output)) {
|
|
282
|
+
generatedPaths.add(path);
|
|
283
|
+
}
|
|
284
|
+
}
|
|
285
|
+
return generatedPaths;
|
|
286
|
+
}
|
|
287
|
+
catch {
|
|
288
|
+
return new Set();
|
|
289
|
+
}
|
|
290
|
+
}
|
|
291
|
+
async markGitattributesGeneratedFiles(files, ref) {
|
|
292
|
+
const candidates = files.filter((file) => !file.isGenerated).map((file) => file.path);
|
|
293
|
+
const generatedPaths = await this.getGitattributesGeneratedPaths(candidates, ref);
|
|
294
|
+
if (generatedPaths.size === 0) {
|
|
295
|
+
return files;
|
|
296
|
+
}
|
|
297
|
+
return files.map((file) => generatedPaths.has(file.path) ? { ...file, isGenerated: true } : file);
|
|
298
|
+
}
|
|
299
|
+
decodeGitPath(rawPath) {
|
|
300
|
+
if (typeof rawPath !== 'string') {
|
|
301
|
+
return undefined;
|
|
302
|
+
}
|
|
303
|
+
const trimmed = rawPath.startsWith('"') && rawPath.endsWith('"') ? rawPath.slice(1, -1) : rawPath;
|
|
304
|
+
const gitPrefixes = ['a/', 'b/', 'c/', 'i/', 'w/'];
|
|
305
|
+
let withoutPrefix = trimmed;
|
|
306
|
+
for (const prefix of gitPrefixes) {
|
|
307
|
+
if (withoutPrefix.startsWith(prefix)) {
|
|
308
|
+
withoutPrefix = withoutPrefix.slice(prefix.length);
|
|
309
|
+
break;
|
|
310
|
+
}
|
|
311
|
+
}
|
|
312
|
+
const tabIndex = withoutPrefix.indexOf('\t');
|
|
313
|
+
if (tabIndex !== -1) {
|
|
314
|
+
withoutPrefix = withoutPrefix.slice(0, tabIndex);
|
|
315
|
+
}
|
|
316
|
+
if (withoutPrefix === '/dev/null') {
|
|
317
|
+
return undefined;
|
|
318
|
+
}
|
|
319
|
+
const bytes = [];
|
|
320
|
+
for (let i = 0; i < withoutPrefix.length; i++) {
|
|
321
|
+
const char = withoutPrefix[i];
|
|
322
|
+
if (char === '\\' && i + 1 < withoutPrefix.length) {
|
|
323
|
+
const next = withoutPrefix[i + 1];
|
|
324
|
+
if (/[0-7]/.test(next)) {
|
|
325
|
+
let octal = next;
|
|
326
|
+
let read = 1;
|
|
327
|
+
while (read < 3 && i + 1 + read < withoutPrefix.length) {
|
|
328
|
+
const candidate = withoutPrefix[i + 1 + read];
|
|
329
|
+
if (!/[0-7]/.test(candidate)) {
|
|
330
|
+
break;
|
|
331
|
+
}
|
|
332
|
+
octal += candidate;
|
|
333
|
+
read++;
|
|
334
|
+
}
|
|
335
|
+
bytes.push(parseInt(octal, 8));
|
|
336
|
+
i += read; // Skip consumed digits
|
|
337
|
+
continue;
|
|
338
|
+
}
|
|
339
|
+
switch (next) {
|
|
340
|
+
case 't':
|
|
341
|
+
bytes.push(0x09);
|
|
342
|
+
break;
|
|
343
|
+
case 'n':
|
|
344
|
+
bytes.push(0x0a);
|
|
345
|
+
break;
|
|
346
|
+
case 'r':
|
|
347
|
+
bytes.push(0x0d);
|
|
348
|
+
break;
|
|
349
|
+
case 'b':
|
|
350
|
+
bytes.push(0x08);
|
|
351
|
+
break;
|
|
352
|
+
case 'f':
|
|
353
|
+
bytes.push(0x0c);
|
|
354
|
+
break;
|
|
355
|
+
case 'v':
|
|
356
|
+
bytes.push(0x0b);
|
|
357
|
+
break;
|
|
358
|
+
case 'a':
|
|
359
|
+
bytes.push(0x07);
|
|
360
|
+
break;
|
|
361
|
+
case '\\':
|
|
362
|
+
bytes.push(0x5c);
|
|
363
|
+
break;
|
|
364
|
+
case '"':
|
|
365
|
+
bytes.push(0x22);
|
|
366
|
+
break;
|
|
367
|
+
case ' ':
|
|
368
|
+
bytes.push(0x20);
|
|
369
|
+
break;
|
|
370
|
+
default:
|
|
371
|
+
bytes.push(...Buffer.from(next, 'utf8'));
|
|
372
|
+
break;
|
|
373
|
+
}
|
|
374
|
+
i++; // Skip the escaped character
|
|
375
|
+
continue;
|
|
376
|
+
}
|
|
377
|
+
bytes.push(...Buffer.from(char, 'utf8'));
|
|
378
|
+
}
|
|
379
|
+
return Buffer.from(bytes).toString('utf8');
|
|
380
|
+
}
|
|
381
|
+
extractPathFromLine(line, prefix) {
|
|
382
|
+
if (!line?.startsWith(prefix)) {
|
|
383
|
+
return undefined;
|
|
384
|
+
}
|
|
385
|
+
return this.decodeGitPath(line.slice(prefix.length));
|
|
386
|
+
}
|
|
387
|
+
parseDiffHeaderPaths(headerLine) {
|
|
388
|
+
if (!headerLine.startsWith('diff --git ')) {
|
|
389
|
+
return null;
|
|
390
|
+
}
|
|
391
|
+
const raw = headerLine.slice('diff --git '.length);
|
|
392
|
+
const segments = [];
|
|
393
|
+
let current = '';
|
|
394
|
+
let inQuotes = false;
|
|
395
|
+
for (let i = 0; i < raw.length; i++) {
|
|
396
|
+
const char = raw[i];
|
|
397
|
+
const prevChar = i > 0 ? raw[i - 1] : null;
|
|
398
|
+
if (char === '"' && prevChar !== '\\') {
|
|
399
|
+
inQuotes = !inQuotes;
|
|
400
|
+
current += char;
|
|
401
|
+
continue;
|
|
402
|
+
}
|
|
403
|
+
if (char === ' ' && !inQuotes && prevChar !== '\\') {
|
|
404
|
+
if (current) {
|
|
405
|
+
segments.push(current);
|
|
406
|
+
current = '';
|
|
407
|
+
}
|
|
408
|
+
continue;
|
|
409
|
+
}
|
|
410
|
+
current += char;
|
|
411
|
+
}
|
|
412
|
+
if (current) {
|
|
413
|
+
segments.push(current);
|
|
414
|
+
}
|
|
415
|
+
if (segments.length !== 2) {
|
|
416
|
+
return null;
|
|
417
|
+
}
|
|
418
|
+
const [rawOldPath, rawNewPath] = segments;
|
|
419
|
+
return {
|
|
420
|
+
oldPath: this.decodeGitPath(rawOldPath),
|
|
421
|
+
newPath: this.decodeGitPath(rawNewPath),
|
|
422
|
+
};
|
|
423
|
+
}
|
|
424
|
+
parseFileBlock(block) {
|
|
425
|
+
const lines = block.split('\n');
|
|
426
|
+
const headerLine = lines[0];
|
|
427
|
+
const headerPaths = this.parseDiffHeaderPaths(headerLine);
|
|
428
|
+
const minusLine = lines.find((line) => line.startsWith('--- '));
|
|
429
|
+
const plusLine = lines.find((line) => line.startsWith('+++ '));
|
|
430
|
+
const renameFromLine = lines.find((line) => line.startsWith('rename from '));
|
|
431
|
+
const renameToLine = lines.find((line) => line.startsWith('rename to '));
|
|
432
|
+
const plusPath = this.extractPathFromLine(plusLine, '+++ ');
|
|
433
|
+
const minusPath = this.extractPathFromLine(minusLine, '--- ');
|
|
434
|
+
const renameFromPath = this.extractPathFromLine(renameFromLine, 'rename from ');
|
|
435
|
+
const renameToPath = this.extractPathFromLine(renameToLine, 'rename to ');
|
|
436
|
+
const newPath = renameToPath ?? plusPath ?? headerPaths?.newPath;
|
|
437
|
+
const oldPath = renameFromPath ?? minusPath ?? headerPaths?.oldPath ?? newPath;
|
|
438
|
+
if (!newPath) {
|
|
439
|
+
return null;
|
|
440
|
+
}
|
|
441
|
+
const path = newPath;
|
|
442
|
+
let status = 'modified';
|
|
443
|
+
// Check for new file mode (added files)
|
|
444
|
+
const newFileMode = lines.find((line) => line.startsWith('new file mode'));
|
|
445
|
+
const deletedFileMode = lines.find((line) => line.startsWith('deleted file mode'));
|
|
446
|
+
// Check for /dev/null which indicates added or deleted files
|
|
447
|
+
if (newFileMode || minusLine?.includes('/dev/null')) {
|
|
448
|
+
status = 'added';
|
|
449
|
+
}
|
|
450
|
+
else if (deletedFileMode || plusLine?.includes('/dev/null')) {
|
|
451
|
+
status = 'deleted';
|
|
452
|
+
}
|
|
453
|
+
else if (oldPath !== newPath) {
|
|
454
|
+
status = 'renamed';
|
|
455
|
+
}
|
|
456
|
+
// Common properties for all files
|
|
457
|
+
const baseFile = {
|
|
458
|
+
path,
|
|
459
|
+
oldPath: status === 'renamed' && oldPath !== newPath ? oldPath : undefined,
|
|
460
|
+
status,
|
|
461
|
+
};
|
|
462
|
+
// Parse chunks
|
|
463
|
+
const chunks = this.parseChunks(lines);
|
|
464
|
+
const { additions, deletions } = this.countLinesFromChunks(chunks);
|
|
465
|
+
return {
|
|
466
|
+
...baseFile,
|
|
467
|
+
additions,
|
|
468
|
+
deletions,
|
|
469
|
+
chunks,
|
|
470
|
+
isGenerated: isGeneratedFile(path).isGenerated,
|
|
471
|
+
};
|
|
472
|
+
}
|
|
473
|
+
countLinesFromChunks(chunks) {
|
|
474
|
+
let additions = 0;
|
|
475
|
+
let deletions = 0;
|
|
476
|
+
for (const chunk of chunks) {
|
|
477
|
+
for (const line of chunk.lines) {
|
|
478
|
+
if (line.type === 'add')
|
|
479
|
+
additions++;
|
|
480
|
+
else if (line.type === 'delete')
|
|
481
|
+
deletions++;
|
|
482
|
+
}
|
|
483
|
+
}
|
|
484
|
+
return { additions, deletions };
|
|
485
|
+
}
|
|
486
|
+
parseChunks(lines) {
|
|
487
|
+
const chunks = [];
|
|
488
|
+
let currentChunk = null;
|
|
489
|
+
let oldLineNum = 0;
|
|
490
|
+
let newLineNum = 0;
|
|
491
|
+
for (const line of lines) {
|
|
492
|
+
if (line.startsWith('@@')) {
|
|
493
|
+
if (currentChunk) {
|
|
494
|
+
chunks.push(currentChunk);
|
|
495
|
+
}
|
|
496
|
+
const match = line.match(/@@ -(\d+)(?:,(\d+))? \+(\d+)(?:,(\d+))? @@(.*)/);
|
|
497
|
+
if (match) {
|
|
498
|
+
const oldStart = parseInt(match[1]);
|
|
499
|
+
const oldLines = parseInt(match[2] || '1');
|
|
500
|
+
const newStart = parseInt(match[3]);
|
|
501
|
+
const newLines = parseInt(match[4] || '1');
|
|
502
|
+
oldLineNum = oldStart;
|
|
503
|
+
newLineNum = newStart;
|
|
504
|
+
currentChunk = {
|
|
505
|
+
header: line,
|
|
506
|
+
oldStart,
|
|
507
|
+
oldLines,
|
|
508
|
+
newStart,
|
|
509
|
+
newLines,
|
|
510
|
+
lines: [],
|
|
511
|
+
};
|
|
512
|
+
}
|
|
513
|
+
}
|
|
514
|
+
else if (currentChunk &&
|
|
515
|
+
(line.startsWith('+') || line.startsWith('-') || line.startsWith(' '))) {
|
|
516
|
+
const type = line.startsWith('+') ? 'add' : line.startsWith('-') ? 'delete' : 'normal';
|
|
517
|
+
const diffLine = {
|
|
518
|
+
type,
|
|
519
|
+
content: line.slice(1),
|
|
520
|
+
oldLineNumber: type !== 'add' ? oldLineNum : undefined,
|
|
521
|
+
newLineNumber: type !== 'delete' ? newLineNum : undefined,
|
|
522
|
+
};
|
|
523
|
+
currentChunk.lines.push(diffLine);
|
|
524
|
+
if (type !== 'add')
|
|
525
|
+
oldLineNum++;
|
|
526
|
+
if (type !== 'delete')
|
|
527
|
+
newLineNum++;
|
|
528
|
+
}
|
|
529
|
+
}
|
|
530
|
+
if (currentChunk) {
|
|
531
|
+
chunks.push(currentChunk);
|
|
532
|
+
}
|
|
533
|
+
return chunks;
|
|
534
|
+
}
|
|
535
|
+
async validateCommit(commitish) {
|
|
536
|
+
try {
|
|
537
|
+
if (commitish === '.' || commitish === 'working' || commitish === 'staged') {
|
|
538
|
+
// For working directory or staging area, just check if we're in a git repo
|
|
539
|
+
await this.git.status();
|
|
540
|
+
return true;
|
|
541
|
+
}
|
|
542
|
+
await this.git.show([commitish, '--name-only']);
|
|
543
|
+
return true;
|
|
544
|
+
}
|
|
545
|
+
catch {
|
|
546
|
+
return false;
|
|
547
|
+
}
|
|
548
|
+
}
|
|
549
|
+
parseStdinDiff(diffContent) {
|
|
550
|
+
const files = this.parseUnifiedDiff(diffContent);
|
|
551
|
+
return {
|
|
552
|
+
commit: 'stdin diff',
|
|
553
|
+
files,
|
|
554
|
+
isEmpty: files.length === 0,
|
|
555
|
+
};
|
|
556
|
+
}
|
|
557
|
+
async getBlobContent(filepath, ref) {
|
|
558
|
+
try {
|
|
559
|
+
// For working directory, read directly from filesystem
|
|
560
|
+
if (ref === 'working' || ref === '.') {
|
|
561
|
+
const fs = await import('fs');
|
|
562
|
+
if (filepath.length === 0) {
|
|
563
|
+
throw new Error('Invalid file path');
|
|
564
|
+
}
|
|
565
|
+
const repositoryPath = fs.realpathSync(resolve(this.repoPath));
|
|
566
|
+
const repositoryRoot = `${repositoryPath}${sep}`;
|
|
567
|
+
const absolutePath = fs.realpathSync(resolve(repositoryRoot, filepath.replace(/\\/g, '/')));
|
|
568
|
+
if (!absolutePath.startsWith(repositoryRoot)) {
|
|
569
|
+
throw new Error('File path outside repository');
|
|
570
|
+
}
|
|
571
|
+
return fs.readFileSync(absolutePath);
|
|
572
|
+
}
|
|
573
|
+
const normalizedFilepath = this.normalizeRepositoryRelativePath(filepath);
|
|
574
|
+
// For git refs, we need to use child_process to execute git cat-file
|
|
575
|
+
// to properly handle binary data
|
|
576
|
+
const { execFileSync } = await import('child_process');
|
|
577
|
+
// Handle staged files
|
|
578
|
+
if (ref === 'staged') {
|
|
579
|
+
// For staged files, use git show :filepath
|
|
580
|
+
// Using execFileSync to prevent command injection
|
|
581
|
+
const buffer = execFileSync('git', ['show', `:${normalizedFilepath}`], {
|
|
582
|
+
cwd: this.repoPath,
|
|
583
|
+
maxBuffer: 10 * 1024 * 1024, // 10MB limit
|
|
584
|
+
});
|
|
585
|
+
return buffer;
|
|
586
|
+
}
|
|
587
|
+
// First, get the blob hash for the file at the given ref
|
|
588
|
+
// Using execFileSync to prevent command injection
|
|
589
|
+
const blobHash = execFileSync('git', ['rev-parse', `${ref}:${normalizedFilepath}`], {
|
|
590
|
+
cwd: this.repoPath,
|
|
591
|
+
encoding: 'utf8',
|
|
592
|
+
maxBuffer: 10 * 1024 * 1024,
|
|
593
|
+
}).trim();
|
|
594
|
+
// Then use git cat-file to get the raw binary content
|
|
595
|
+
// Increase maxBuffer to handle large files (default is 1024*1024 = 1MB)
|
|
596
|
+
const buffer = execFileSync('git', ['cat-file', 'blob', blobHash], {
|
|
597
|
+
cwd: this.repoPath,
|
|
598
|
+
maxBuffer: 10 * 1024 * 1024, // 10MB limit
|
|
599
|
+
});
|
|
600
|
+
return buffer;
|
|
601
|
+
}
|
|
602
|
+
catch (error) {
|
|
603
|
+
// Check if it's a buffer size error
|
|
604
|
+
if (error instanceof Error &&
|
|
605
|
+
(error.message.includes('ENOBUFS') || error.message.includes('maxBuffer'))) {
|
|
606
|
+
throw new Error(`Image file ${filepath} is too large to display (over 10MB limit)`);
|
|
607
|
+
}
|
|
608
|
+
if (error instanceof Error &&
|
|
609
|
+
(error.message === 'Invalid file path' || error.message === 'File path outside repository')) {
|
|
610
|
+
throw error;
|
|
611
|
+
}
|
|
612
|
+
throw new Error(`Failed to get blob content for ${filepath} at ${ref}: ${error instanceof Error ? error.message : 'Unknown error'}`);
|
|
613
|
+
}
|
|
614
|
+
}
|
|
615
|
+
async getLineCount(filepath, ref) {
|
|
616
|
+
const buffer = await this.getBlobContent(filepath, ref);
|
|
617
|
+
let count = 0;
|
|
618
|
+
for (let i = 0; i < buffer.length; i++) {
|
|
619
|
+
if (buffer[i] === 0x0a)
|
|
620
|
+
count++; // newline byte
|
|
621
|
+
}
|
|
622
|
+
// If file doesn't end with newline, the last line still counts
|
|
623
|
+
if (buffer.length > 0 && buffer[buffer.length - 1] !== 0x0a) {
|
|
624
|
+
count++;
|
|
625
|
+
}
|
|
626
|
+
return count;
|
|
627
|
+
}
|
|
628
|
+
extractHeaderLines(buffer, maxLines = 20) {
|
|
629
|
+
const headerSlice = buffer.subarray(0, GitDiffParser.GENERATED_HEADER_SCAN_BYTES);
|
|
630
|
+
return headerSlice.toString('utf8').split('\n').slice(0, maxLines);
|
|
631
|
+
}
|
|
632
|
+
async getGeneratedStatus(filepath, ref) {
|
|
633
|
+
const pathResult = isGeneratedFile(filepath);
|
|
634
|
+
if (pathResult.isGenerated) {
|
|
635
|
+
return { isGenerated: true, source: 'path' };
|
|
636
|
+
}
|
|
637
|
+
const gitattributesResult = await this.getGitattributesGeneratedPaths([filepath], ref);
|
|
638
|
+
if (gitattributesResult.has(filepath)) {
|
|
639
|
+
return { isGenerated: true, source: 'path' };
|
|
640
|
+
}
|
|
641
|
+
try {
|
|
642
|
+
const buffer = await this.getBlobContent(filepath, ref);
|
|
643
|
+
const lines = this.extractHeaderLines(buffer);
|
|
644
|
+
const result = isGeneratedFile(filepath, () => lines);
|
|
645
|
+
return { isGenerated: result.isGenerated, source: 'content' };
|
|
646
|
+
}
|
|
647
|
+
catch {
|
|
648
|
+
return { isGenerated: false, source: 'path' };
|
|
649
|
+
}
|
|
650
|
+
}
|
|
651
|
+
async resolveCommitish(commitish) {
|
|
652
|
+
const now = Date.now();
|
|
653
|
+
const cached = this.resolvedCommitCache.get(commitish);
|
|
654
|
+
if (cached && cached.expiresAt > now) {
|
|
655
|
+
return cached.value;
|
|
656
|
+
}
|
|
657
|
+
const hash = await this.git.revparse([commitish]);
|
|
658
|
+
const value = hash.substring(0, 7);
|
|
659
|
+
this.resolvedCommitCache.set(commitish, {
|
|
660
|
+
value,
|
|
661
|
+
expiresAt: now + GitDiffParser.RESOLVED_COMMIT_CACHE_TTL_MS,
|
|
662
|
+
});
|
|
663
|
+
return value;
|
|
664
|
+
}
|
|
665
|
+
clearResolvedCommitCache() {
|
|
666
|
+
this.resolvedCommitCache.clear();
|
|
667
|
+
}
|
|
668
|
+
async getDefaultBranch() {
|
|
669
|
+
try {
|
|
670
|
+
// Try to get the default branch from origin/HEAD
|
|
671
|
+
const result = await this.git.raw(['symbolic-ref', 'refs/remotes/origin/HEAD']);
|
|
672
|
+
// Result will be like "refs/remotes/origin/main\n"
|
|
673
|
+
const match = result.trim().match(/refs\/remotes\/origin\/(.+)/);
|
|
674
|
+
if (match) {
|
|
675
|
+
return match[1];
|
|
676
|
+
}
|
|
677
|
+
}
|
|
678
|
+
catch {
|
|
679
|
+
// If origin/HEAD is not set, fall back to common default branches
|
|
680
|
+
const commonDefaults = ['main', 'master'];
|
|
681
|
+
const branchResult = await this.git.branchLocal();
|
|
682
|
+
const branchNames = Object.keys(branchResult.branches);
|
|
683
|
+
for (const defaultName of commonDefaults) {
|
|
684
|
+
if (branchNames.includes(defaultName)) {
|
|
685
|
+
return defaultName;
|
|
686
|
+
}
|
|
687
|
+
}
|
|
688
|
+
}
|
|
689
|
+
return null;
|
|
690
|
+
}
|
|
691
|
+
async getOriginDefaultBranch() {
|
|
692
|
+
try {
|
|
693
|
+
const result = await this.git.raw(['symbolic-ref', 'refs/remotes/origin/HEAD']);
|
|
694
|
+
const match = result.trim().match(/refs\/remotes\/origin\/(.+)/);
|
|
695
|
+
if (match) {
|
|
696
|
+
return `origin/${match[1]}`;
|
|
697
|
+
}
|
|
698
|
+
}
|
|
699
|
+
catch {
|
|
700
|
+
const commonDefaults = ['main', 'master'];
|
|
701
|
+
for (const defaultName of commonDefaults) {
|
|
702
|
+
try {
|
|
703
|
+
await this.git.raw([
|
|
704
|
+
'show-ref',
|
|
705
|
+
'--verify',
|
|
706
|
+
'--quiet',
|
|
707
|
+
`refs/remotes/origin/${defaultName}`,
|
|
708
|
+
]);
|
|
709
|
+
return `origin/${defaultName}`;
|
|
710
|
+
}
|
|
711
|
+
catch {
|
|
712
|
+
// Ignore missing refs and continue checking common defaults.
|
|
713
|
+
}
|
|
714
|
+
}
|
|
715
|
+
}
|
|
716
|
+
return null;
|
|
717
|
+
}
|
|
718
|
+
async getCurrentBranch() {
|
|
719
|
+
try {
|
|
720
|
+
const name = (await this.git.raw(['rev-parse', '--abbrev-ref', 'HEAD'])).trim();
|
|
721
|
+
return name && name !== 'HEAD' ? name : null;
|
|
722
|
+
}
|
|
723
|
+
catch {
|
|
724
|
+
return null;
|
|
725
|
+
}
|
|
726
|
+
}
|
|
727
|
+
async getPgitParent() {
|
|
728
|
+
const branch = await this.getCurrentBranch();
|
|
729
|
+
if (!branch)
|
|
730
|
+
return null;
|
|
731
|
+
let value;
|
|
732
|
+
try {
|
|
733
|
+
value = (await this.git.raw(['config', `branch.${branch}.pgit-parent`])).trim();
|
|
734
|
+
}
|
|
735
|
+
catch {
|
|
736
|
+
// git config exits non-zero when the key is absent
|
|
737
|
+
return null;
|
|
738
|
+
}
|
|
739
|
+
if (!value)
|
|
740
|
+
return null;
|
|
741
|
+
// Only use the recorded parent if it actually resolves locally; a stale or
|
|
742
|
+
// missing ref would make the current-branch merge-base diff fail, so fall
|
|
743
|
+
// back to null (→ client lands on Uncommitted + a "pick a base" banner).
|
|
744
|
+
try {
|
|
745
|
+
await this.git.raw(['rev-parse', '--verify', '--quiet', `${value}^{commit}`]);
|
|
746
|
+
return value;
|
|
747
|
+
}
|
|
748
|
+
catch {
|
|
749
|
+
return null;
|
|
750
|
+
}
|
|
751
|
+
}
|
|
752
|
+
/**
|
|
753
|
+
* Best-effort parent for a stacked/task branch when no pgit parent is recorded:
|
|
754
|
+
* the local branch whose tip is the *nearest* ancestor of HEAD (smallest
|
|
755
|
+
* `<branch>..HEAD` commit distance, excluding the current branch). For a branch
|
|
756
|
+
* cut from another local branch (e.g. `task_X_child` off `task_X`) this recovers
|
|
757
|
+
* the real parent; git stores no such link otherwise. Heuristic — the caller
|
|
758
|
+
* marks it inferred and keeps it overridable.
|
|
759
|
+
*/
|
|
760
|
+
async getNearestAncestorBranch(currentBranch) {
|
|
761
|
+
let candidates;
|
|
762
|
+
try {
|
|
763
|
+
const out = await this.git.raw(['branch', '--merged', 'HEAD', '--format=%(refname:short)']);
|
|
764
|
+
candidates = out
|
|
765
|
+
.split('\n')
|
|
766
|
+
.map((s) => s.trim())
|
|
767
|
+
.filter((b) => b && b !== currentBranch && !b.startsWith('('));
|
|
768
|
+
}
|
|
769
|
+
catch {
|
|
770
|
+
return null;
|
|
771
|
+
}
|
|
772
|
+
const measured = await Promise.all(candidates.map(async (branch) => {
|
|
773
|
+
try {
|
|
774
|
+
const dist = Number.parseInt((await this.git.raw(['rev-list', '--count', `${branch}..HEAD`])).trim(), 10);
|
|
775
|
+
return Number.isFinite(dist) && dist > 0 ? { branch, dist } : null;
|
|
776
|
+
}
|
|
777
|
+
catch {
|
|
778
|
+
return null;
|
|
779
|
+
}
|
|
780
|
+
}));
|
|
781
|
+
let best = null;
|
|
782
|
+
for (const m of measured) {
|
|
783
|
+
if (!m)
|
|
784
|
+
continue;
|
|
785
|
+
if (!best || m.dist < best.dist || (m.dist === best.dist && m.branch < best.branch))
|
|
786
|
+
best = m;
|
|
787
|
+
}
|
|
788
|
+
return best?.branch ?? null;
|
|
789
|
+
}
|
|
790
|
+
async getCommitsInRange(base, target) {
|
|
791
|
+
const normalizeRef = (ref) => ref === '.' || ref === 'staged' || ref === 'working' ? 'HEAD' : ref;
|
|
792
|
+
const normTarget = normalizeRef(target);
|
|
793
|
+
const normBase = base ? normalizeRef(base) : undefined;
|
|
794
|
+
const result = normBase
|
|
795
|
+
? await this.git.log([`${normBase}..${normTarget}`, '--max-count=200'])
|
|
796
|
+
: await this.git.log([normTarget, '--max-count=50']);
|
|
797
|
+
return result.all.map((commit) => ({
|
|
798
|
+
hash: commit.hash,
|
|
799
|
+
shortHash: commit.hash.substring(0, 7),
|
|
800
|
+
message: commit.message,
|
|
801
|
+
body: commit.body || undefined,
|
|
802
|
+
author: commit.author_name || undefined,
|
|
803
|
+
date: commit.date || undefined,
|
|
804
|
+
}));
|
|
805
|
+
}
|
|
806
|
+
async getRevisionOptions(currentBase, currentTarget) {
|
|
807
|
+
const [branchResult, logResult, defaultBranch, originDefaultBranch, currentBranch, pgitParent] = await Promise.all([
|
|
808
|
+
this.git.branchLocal(),
|
|
809
|
+
this.git.log({ maxCount: 20 }),
|
|
810
|
+
this.getDefaultBranch(),
|
|
811
|
+
this.getOriginDefaultBranch(),
|
|
812
|
+
this.getCurrentBranch(),
|
|
813
|
+
this.getPgitParent(),
|
|
814
|
+
]);
|
|
815
|
+
const branches = Object.entries(branchResult.branches).map(([name, data]) => ({
|
|
816
|
+
name,
|
|
817
|
+
current: data.current,
|
|
818
|
+
}));
|
|
819
|
+
// Sort branches: default branch first, then current branch, then alphabetically
|
|
820
|
+
branches.sort((a, b) => {
|
|
821
|
+
if (defaultBranch) {
|
|
822
|
+
if (a.name === defaultBranch)
|
|
823
|
+
return -1;
|
|
824
|
+
if (b.name === defaultBranch)
|
|
825
|
+
return 1;
|
|
826
|
+
}
|
|
827
|
+
if (a.current && !b.current)
|
|
828
|
+
return -1;
|
|
829
|
+
if (!a.current && b.current)
|
|
830
|
+
return 1;
|
|
831
|
+
return a.name.localeCompare(b.name);
|
|
832
|
+
});
|
|
833
|
+
const commits = logResult.all.map((commit) => ({
|
|
834
|
+
hash: commit.hash,
|
|
835
|
+
shortHash: commit.hash.substring(0, 7),
|
|
836
|
+
message: commit.message,
|
|
837
|
+
body: commit.body || undefined,
|
|
838
|
+
author: commit.author_name || undefined,
|
|
839
|
+
date: commit.date || undefined,
|
|
840
|
+
}));
|
|
841
|
+
// Resolve HEAD and HEAD^ to actual commit hashes if they're being used
|
|
842
|
+
let resolvedBase;
|
|
843
|
+
let resolvedTarget;
|
|
844
|
+
if (currentBase && !['working', 'staged', '.'].includes(currentBase)) {
|
|
845
|
+
try {
|
|
846
|
+
resolvedBase = await this.resolveCommitish(currentBase);
|
|
847
|
+
}
|
|
848
|
+
catch {
|
|
849
|
+
// If resolution fails, leave undefined
|
|
850
|
+
}
|
|
851
|
+
}
|
|
852
|
+
if (currentTarget && !['working', 'staged', '.'].includes(currentTarget)) {
|
|
853
|
+
try {
|
|
854
|
+
resolvedTarget = await this.resolveCommitish(currentTarget);
|
|
855
|
+
}
|
|
856
|
+
catch {
|
|
857
|
+
// If resolution fails, leave undefined
|
|
858
|
+
}
|
|
859
|
+
}
|
|
860
|
+
// Effective base for the "Current branch" view. Prefer the explicit pgit
|
|
861
|
+
// parent; otherwise fall back to the origin default branch, but only when the
|
|
862
|
+
// current branch actually diverges from it (a merge-base exists and isn't
|
|
863
|
+
// HEAD itself) — so a branch sitting on/at the default branch doesn't produce
|
|
864
|
+
// an empty "branch diff". When neither yields a base, the client prompts.
|
|
865
|
+
let branchBase;
|
|
866
|
+
let branchBaseSource;
|
|
867
|
+
if (pgitParent) {
|
|
868
|
+
branchBase = pgitParent;
|
|
869
|
+
branchBaseSource = 'pgit';
|
|
870
|
+
}
|
|
871
|
+
else {
|
|
872
|
+
// Prefer the nearest local ancestor branch (recovers a stacked/task
|
|
873
|
+
// branch's real parent); fall back to the origin default branch only when
|
|
874
|
+
// no local parent exists.
|
|
875
|
+
const nearest = await this.getNearestAncestorBranch(currentBranch);
|
|
876
|
+
if (nearest) {
|
|
877
|
+
branchBase = nearest;
|
|
878
|
+
branchBaseSource = 'branch';
|
|
879
|
+
}
|
|
880
|
+
else if (originDefaultBranch) {
|
|
881
|
+
try {
|
|
882
|
+
const [mergeBase, head] = await Promise.all([
|
|
883
|
+
this.git.raw(['merge-base', originDefaultBranch, 'HEAD']).then((s) => s.trim()),
|
|
884
|
+
this.git.raw(['rev-parse', 'HEAD']).then((s) => s.trim()),
|
|
885
|
+
]);
|
|
886
|
+
if (mergeBase && mergeBase !== head) {
|
|
887
|
+
branchBase = originDefaultBranch;
|
|
888
|
+
branchBaseSource = 'origin-default';
|
|
889
|
+
}
|
|
890
|
+
}
|
|
891
|
+
catch {
|
|
892
|
+
// No merge-base (unrelated histories) or the default ref is missing —
|
|
893
|
+
// leave branchBase undefined so the client falls back to a prompt.
|
|
894
|
+
}
|
|
895
|
+
}
|
|
896
|
+
}
|
|
897
|
+
return {
|
|
898
|
+
branches,
|
|
899
|
+
commits,
|
|
900
|
+
originDefaultBranch: originDefaultBranch ?? undefined,
|
|
901
|
+
resolvedBase,
|
|
902
|
+
resolvedTarget,
|
|
903
|
+
currentBranch: currentBranch ?? undefined,
|
|
904
|
+
pgitParent: pgitParent ?? undefined,
|
|
905
|
+
branchBase,
|
|
906
|
+
branchBaseSource,
|
|
907
|
+
};
|
|
908
|
+
}
|
|
909
|
+
}
|