gent-cli 2.0.0 → 5.0.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/README.md +28 -42
- package/package.json +1 -1
- package/src/commands/add.js +102 -23
- package/src/commands/clone.js +137 -86
- package/src/commands/commit.js +89 -81
- package/src/commands/diff.js +257 -0
- package/src/commands/init.js +5 -36
- package/src/commands/log.js +57 -13
- package/src/commands/merge.js +245 -0
- package/src/commands/pull.js +176 -86
- package/src/commands/push.js +172 -79
- package/src/commands/remote.js +97 -113
- package/src/commands/reset.js +149 -0
- package/src/commands/rm.js +85 -0
- package/src/commands/show.js +167 -0
- package/src/commands/stash.js +255 -0
- package/src/commands/status.js +80 -46
- package/src/commands/tag.js +146 -0
- package/src/index.js +108 -57
- package/src/utils/constants.js +10 -26
- package/src/utils/diff-engine.js +236 -0
- package/src/utils/fileSystem.js +8 -60
- package/src/utils/hash-engine.js +337 -0
- package/src/utils/merge-engine.js +379 -0
- package/src/utils/object-store.js +54 -0
- package/src/commands/create.js +0 -121
- package/src/commands/list.js +0 -67
- package/src/services/repo-service.js +0 -284
- package/src/utils/cloud-sync.js +0 -323
- package/src/utils/diff.js +0 -121
|
@@ -0,0 +1,236 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* ============================================================================
|
|
3
|
+
* Diff Engine - Line-level LCS diff with hunk generation and unified format
|
|
4
|
+
* ============================================================================
|
|
5
|
+
*
|
|
6
|
+
* PURPOSE:
|
|
7
|
+
* Compute minimal edit scripts between two text files, classify each line
|
|
8
|
+
* as insert / delete / equal, generate unified diff output.
|
|
9
|
+
*
|
|
10
|
+
* ALGORITHM: Longest Common Subsequence (LCS)
|
|
11
|
+
* - Build M×N dynamic programming matrix where M,N = line counts
|
|
12
|
+
* - dp[i][j] = length of LCS of first i lines of A and first j lines of B
|
|
13
|
+
* - Recurrence:
|
|
14
|
+
* if A[i] == B[j]: dp[i][j] = dp[i-1][j-1] + 1
|
|
15
|
+
* else: dp[i][j] = max(dp[i-1][j], dp[i][j-1])
|
|
16
|
+
* - Backtrack from dp[M][N] to produce edit operations
|
|
17
|
+
* - Time: O(M*N), Space: O(M*N) — uses Uint32Array for memory efficiency
|
|
18
|
+
*
|
|
19
|
+
* INSERTION/DELETION CLASSIFICATION:
|
|
20
|
+
* During backtrack:
|
|
21
|
+
* - A[i]==B[j] → EQUAL (line unchanged)
|
|
22
|
+
* - Move up (i-1) → DELETE (line only in old version)
|
|
23
|
+
* - Move left (j-1) → INSERT (line only in new version)
|
|
24
|
+
*
|
|
25
|
+
* HUNK GENERATION:
|
|
26
|
+
* Groups adjacent changes with N context lines (default 3) into hunks.
|
|
27
|
+
* Changes within 2*N+1 lines of each other merge into one hunk.
|
|
28
|
+
* Output format matches unified diff:
|
|
29
|
+
* @@ -oldStart,oldCount +newStart,newCount @@
|
|
30
|
+
*
|
|
31
|
+
* BACKEND EXPECTATIONS:
|
|
32
|
+
* Diffs are computed locally. Backend does NOT need diff support.
|
|
33
|
+
* Backend stores blob objects; clients compute diffs on demand.
|
|
34
|
+
*
|
|
35
|
+
* ============================================================================
|
|
36
|
+
*/
|
|
37
|
+
|
|
38
|
+
const { splitLines } = require('./hash-engine');
|
|
39
|
+
|
|
40
|
+
// ─── Core LCS / Diff ────────────────────────────────────
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* Build LCS length matrix.
|
|
44
|
+
* @param {String[]} a
|
|
45
|
+
* @param {String[]} b
|
|
46
|
+
* @returns {Array<Uint32Array>}
|
|
47
|
+
*/
|
|
48
|
+
function buildLcsMatrix(a, b) {
|
|
49
|
+
const rows = a.length + 1;
|
|
50
|
+
const cols = b.length + 1;
|
|
51
|
+
const matrix = Array.from({ length: rows }, () => new Uint32Array(cols));
|
|
52
|
+
|
|
53
|
+
for (let i = 1; i < rows; i++) {
|
|
54
|
+
for (let j = 1; j < cols; j++) {
|
|
55
|
+
if (a[i - 1] === b[j - 1]) {
|
|
56
|
+
matrix[i][j] = matrix[i - 1][j - 1] + 1;
|
|
57
|
+
} else {
|
|
58
|
+
matrix[i][j] = Math.max(matrix[i - 1][j], matrix[i][j - 1]);
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
return matrix;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/**
|
|
66
|
+
* Backtrack LCS matrix → line operations.
|
|
67
|
+
* @param {String[]} oldLines
|
|
68
|
+
* @param {String[]} newLines
|
|
69
|
+
* @returns {Array<{type: 'equal'|'insert'|'delete', oldLine: number, newLine: number, content: String}>}
|
|
70
|
+
*/
|
|
71
|
+
function buildLineOperations(oldLines, newLines) {
|
|
72
|
+
const matrix = buildLcsMatrix(oldLines, newLines);
|
|
73
|
+
const ops = [];
|
|
74
|
+
let i = oldLines.length;
|
|
75
|
+
let j = newLines.length;
|
|
76
|
+
|
|
77
|
+
while (i > 0 || j > 0) {
|
|
78
|
+
if (i > 0 && j > 0 && oldLines[i - 1] === newLines[j - 1]) {
|
|
79
|
+
ops.push({ type: 'equal', oldLine: i, newLine: j, content: oldLines[i - 1] });
|
|
80
|
+
i--; j--;
|
|
81
|
+
} else if (j > 0 && (i === 0 || matrix[i][j - 1] >= matrix[i - 1][j])) {
|
|
82
|
+
ops.push({ type: 'insert', oldLine: i, newLine: j, content: newLines[j - 1] });
|
|
83
|
+
j--;
|
|
84
|
+
} else {
|
|
85
|
+
ops.push({ type: 'delete', oldLine: i, newLine: j, content: oldLines[i - 1] });
|
|
86
|
+
i--;
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
return ops.reverse();
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/**
|
|
94
|
+
* Diff two texts → operations + stats.
|
|
95
|
+
* @param {String} oldText
|
|
96
|
+
* @param {String} newText
|
|
97
|
+
* @returns {{ algorithm: string, operations: Array, stats: Object }}
|
|
98
|
+
*/
|
|
99
|
+
function diffText(oldText, newText) {
|
|
100
|
+
const oldLines = splitLines(oldText);
|
|
101
|
+
const newLines = splitLines(newText);
|
|
102
|
+
const operations = buildLineOperations(oldLines, newLines);
|
|
103
|
+
const stats = summarizeOperations(operations);
|
|
104
|
+
return { algorithm: 'lcs-line-v1', operations, stats };
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
/**
|
|
108
|
+
* Count insert/delete/equal ops.
|
|
109
|
+
* @param {Array} operations
|
|
110
|
+
* @returns {{insertions, deletions, unchanged, changes}}
|
|
111
|
+
*/
|
|
112
|
+
function summarizeOperations(operations) {
|
|
113
|
+
let insertions = 0, deletions = 0, unchanged = 0;
|
|
114
|
+
for (const op of operations) {
|
|
115
|
+
if (op.type === 'insert') insertions++;
|
|
116
|
+
else if (op.type === 'delete') deletions++;
|
|
117
|
+
else unchanged++;
|
|
118
|
+
}
|
|
119
|
+
return { insertions, deletions, unchanged, changes: insertions + deletions };
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
// ─── Hunk Generation ────────────────────────────────────
|
|
123
|
+
|
|
124
|
+
/**
|
|
125
|
+
* Group diff ops into hunks with context lines (like git diff).
|
|
126
|
+
* @param {Array} ops - From buildLineOperations
|
|
127
|
+
* @param {Number} contextLines - Context around changes (default 3)
|
|
128
|
+
* @returns {Array<{oldStart, oldCount, newStart, newCount, lines: String[]}>}
|
|
129
|
+
*/
|
|
130
|
+
function generateHunks(ops, contextLines = 3) {
|
|
131
|
+
const changeIndices = [];
|
|
132
|
+
for (let i = 0; i < ops.length; i++) {
|
|
133
|
+
if (ops[i].type !== 'equal') changeIndices.push(i);
|
|
134
|
+
}
|
|
135
|
+
if (changeIndices.length === 0) return [];
|
|
136
|
+
|
|
137
|
+
// Group changes within contextLines*2 of each other
|
|
138
|
+
const groups = [];
|
|
139
|
+
let group = [changeIndices[0]];
|
|
140
|
+
for (let i = 1; i < changeIndices.length; i++) {
|
|
141
|
+
if (changeIndices[i] - changeIndices[i - 1] <= contextLines * 2 + 1) {
|
|
142
|
+
group.push(changeIndices[i]);
|
|
143
|
+
} else {
|
|
144
|
+
groups.push(group);
|
|
145
|
+
group = [changeIndices[i]];
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
groups.push(group);
|
|
149
|
+
|
|
150
|
+
const hunks = [];
|
|
151
|
+
for (const g of groups) {
|
|
152
|
+
const first = g[0];
|
|
153
|
+
const last = g[g.length - 1];
|
|
154
|
+
const start = Math.max(0, first - contextLines);
|
|
155
|
+
const end = Math.min(ops.length - 1, last + contextLines);
|
|
156
|
+
|
|
157
|
+
let oldLine = 0, newLine = 0;
|
|
158
|
+
for (let i = 0; i < start; i++) {
|
|
159
|
+
if (ops[i].type === 'equal' || ops[i].type === 'delete') oldLine++;
|
|
160
|
+
if (ops[i].type === 'equal' || ops[i].type === 'insert') newLine++;
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
const hunkOldStart = oldLine + 1;
|
|
164
|
+
const hunkNewStart = newLine + 1;
|
|
165
|
+
let hunkOldCount = 0, hunkNewCount = 0;
|
|
166
|
+
const lines = [];
|
|
167
|
+
|
|
168
|
+
for (let i = start; i <= end; i++) {
|
|
169
|
+
const op = ops[i];
|
|
170
|
+
if (op.type === 'equal') {
|
|
171
|
+
lines.push(` ${op.content}`);
|
|
172
|
+
hunkOldCount++; hunkNewCount++;
|
|
173
|
+
} else if (op.type === 'delete') {
|
|
174
|
+
lines.push(`-${op.content}`);
|
|
175
|
+
hunkOldCount++;
|
|
176
|
+
} else {
|
|
177
|
+
lines.push(`+${op.content}`);
|
|
178
|
+
hunkNewCount++;
|
|
179
|
+
}
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
hunks.push({ oldStart: hunkOldStart, oldCount: hunkOldCount, newStart: hunkNewStart, newCount: hunkNewCount, lines });
|
|
183
|
+
}
|
|
184
|
+
return hunks;
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
// ─── Unified Diff Format ────────────────────────────────
|
|
188
|
+
|
|
189
|
+
/**
|
|
190
|
+
* Format as unified diff string (like `git diff`).
|
|
191
|
+
* @param {String} filePath
|
|
192
|
+
* @param {String} oldText
|
|
193
|
+
* @param {String} newText
|
|
194
|
+
* @returns {String}
|
|
195
|
+
*/
|
|
196
|
+
function formatUnifiedDiff(filePath, oldText, newText) {
|
|
197
|
+
const oldLines = splitLines(oldText);
|
|
198
|
+
const newLines = splitLines(newText);
|
|
199
|
+
const ops = buildLineOperations(oldLines, newLines);
|
|
200
|
+
const hunks = generateHunks(ops);
|
|
201
|
+
if (hunks.length === 0) return '';
|
|
202
|
+
|
|
203
|
+
const out = [`--- a/${filePath}`, `+++ b/${filePath}`];
|
|
204
|
+
for (const h of hunks) {
|
|
205
|
+
out.push(`@@ -${h.oldStart},${h.oldCount} +${h.newStart},${h.newCount} @@`);
|
|
206
|
+
out.push(...h.lines);
|
|
207
|
+
}
|
|
208
|
+
return out.join('\n');
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
// ─── Patch Application ──────────────────────────────────
|
|
212
|
+
|
|
213
|
+
/**
|
|
214
|
+
* Apply operations to reconstruct target from source.
|
|
215
|
+
* @param {Array} ops
|
|
216
|
+
* @returns {String[]} Reconstructed lines
|
|
217
|
+
*/
|
|
218
|
+
function applyOperations(ops) {
|
|
219
|
+
const result = [];
|
|
220
|
+
for (const op of ops) {
|
|
221
|
+
if (op.type === 'equal' || op.type === 'insert') {
|
|
222
|
+
result.push(op.content);
|
|
223
|
+
}
|
|
224
|
+
}
|
|
225
|
+
return result;
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
module.exports = {
|
|
229
|
+
diffText,
|
|
230
|
+
summarizeOperations,
|
|
231
|
+
buildLcsMatrix,
|
|
232
|
+
buildLineOperations,
|
|
233
|
+
generateHunks,
|
|
234
|
+
formatUnifiedDiff,
|
|
235
|
+
applyOperations
|
|
236
|
+
};
|
package/src/utils/fileSystem.js
CHANGED
|
@@ -5,7 +5,7 @@
|
|
|
5
5
|
|
|
6
6
|
const fs = require('fs').promises;
|
|
7
7
|
const path = require('path');
|
|
8
|
-
const { GENT_DIR, DEFAULT_IGNORE_PATTERNS, IGNORE_FILE
|
|
8
|
+
const { GENT_DIR, DEFAULT_IGNORE_PATTERNS, IGNORE_FILE } = require('./constants');
|
|
9
9
|
|
|
10
10
|
/**
|
|
11
11
|
* Check if a path exists
|
|
@@ -113,31 +113,23 @@ async function getAllFiles(dir, ignorePatterns = []) {
|
|
|
113
113
|
*/
|
|
114
114
|
function shouldIgnore(filePath, patterns) {
|
|
115
115
|
const normalizedPath = filePath.replace(/\\/g, '/');
|
|
116
|
-
const segments = normalizedPath.split('/');
|
|
117
116
|
|
|
118
117
|
for (const pattern of patterns) {
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
// Check if any segment matches the pattern (for directory-level ignores like node_modules)
|
|
122
|
-
if (segments.some(segment => segment === normalizedPattern)) {
|
|
123
|
-
return true;
|
|
124
|
-
}
|
|
125
|
-
|
|
126
|
-
// Exact match or starts with pattern
|
|
127
|
-
if (normalizedPath === normalizedPattern || normalizedPath.startsWith(normalizedPattern + '/')) {
|
|
118
|
+
// Exact match
|
|
119
|
+
if (normalizedPath === pattern || normalizedPath.startsWith(pattern + '/')) {
|
|
128
120
|
return true;
|
|
129
121
|
}
|
|
130
122
|
|
|
131
123
|
// Wildcard match
|
|
132
|
-
if (
|
|
133
|
-
const regex = new RegExp('^' +
|
|
124
|
+
if (pattern.includes('*')) {
|
|
125
|
+
const regex = new RegExp('^' + pattern.replace(/\*/g, '.*') + '$');
|
|
134
126
|
if (regex.test(normalizedPath)) {
|
|
135
127
|
return true;
|
|
136
128
|
}
|
|
137
129
|
}
|
|
138
130
|
|
|
139
131
|
// Extension match
|
|
140
|
-
if (
|
|
132
|
+
if (pattern.startsWith('*.') && normalizedPath.endsWith(pattern.substring(1))) {
|
|
141
133
|
return true;
|
|
142
134
|
}
|
|
143
135
|
}
|
|
@@ -178,53 +170,11 @@ async function getTrackedFiles(gentPath, commitHash) {
|
|
|
178
170
|
}
|
|
179
171
|
|
|
180
172
|
const repository = await readJSON(path.join(gentPath, 'commits.json'));
|
|
181
|
-
const commit = repository.commits.find(c => c.
|
|
173
|
+
const commit = repository.commits.find(c => c.hash === commitHash);
|
|
182
174
|
|
|
183
175
|
return commit ? commit.files : [];
|
|
184
176
|
}
|
|
185
177
|
|
|
186
|
-
/**
|
|
187
|
-
* Get path to the objects directory
|
|
188
|
-
* @returns {Promise<String>}
|
|
189
|
-
*/
|
|
190
|
-
async function getObjectsPath() {
|
|
191
|
-
const gentPath = await getGentPath();
|
|
192
|
-
const objectsPath = path.join(gentPath, OBJECTS_DIR);
|
|
193
|
-
await ensureDir(objectsPath);
|
|
194
|
-
return objectsPath;
|
|
195
|
-
}
|
|
196
|
-
|
|
197
|
-
/**
|
|
198
|
-
* Save file content as a blob
|
|
199
|
-
* @param {String} content - File content
|
|
200
|
-
* @param {String} hash - Content hash
|
|
201
|
-
*/
|
|
202
|
-
async function saveBlob(content, hash) {
|
|
203
|
-
const objectsPath = await getObjectsPath();
|
|
204
|
-
const blobPath = path.join(objectsPath, hash);
|
|
205
|
-
|
|
206
|
-
// Only write if it doesn't exist (content-addressable, immutable)
|
|
207
|
-
if (!await pathExists(blobPath)) {
|
|
208
|
-
await fs.writeFile(blobPath, content, 'utf-8');
|
|
209
|
-
}
|
|
210
|
-
}
|
|
211
|
-
|
|
212
|
-
/**
|
|
213
|
-
* Get file content from blob
|
|
214
|
-
* @param {String} hash - Content hash
|
|
215
|
-
* @returns {Promise<String>}
|
|
216
|
-
*/
|
|
217
|
-
async function getBlob(hash) {
|
|
218
|
-
const objectsPath = await getObjectsPath();
|
|
219
|
-
const blobPath = path.join(objectsPath, hash);
|
|
220
|
-
|
|
221
|
-
if (!await pathExists(blobPath)) {
|
|
222
|
-
return ''; // Return empty string if blob missing (shouldn't happen in healthy repo)
|
|
223
|
-
}
|
|
224
|
-
|
|
225
|
-
return fs.readFile(blobPath, 'utf-8');
|
|
226
|
-
}
|
|
227
|
-
|
|
228
178
|
module.exports = {
|
|
229
179
|
pathExists,
|
|
230
180
|
ensureDir,
|
|
@@ -234,7 +184,5 @@ module.exports = {
|
|
|
234
184
|
getAllFiles,
|
|
235
185
|
shouldIgnore,
|
|
236
186
|
getIgnorePatterns,
|
|
237
|
-
getTrackedFiles
|
|
238
|
-
saveBlob,
|
|
239
|
-
getBlob
|
|
187
|
+
getTrackedFiles
|
|
240
188
|
};
|
|
@@ -0,0 +1,337 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* ============================================================================
|
|
3
|
+
* Hash Engine - Content-Addressable Object Store
|
|
4
|
+
* ============================================================================
|
|
5
|
+
*
|
|
6
|
+
* PURPOSE:
|
|
7
|
+
* Git-like blob/tree storage using SHA-256 content addressing.
|
|
8
|
+
* Files are stored once (deduplicated) and retrieved by hash.
|
|
9
|
+
*
|
|
10
|
+
* STORAGE LAYOUT:
|
|
11
|
+
* .gent/objects/<2-char-prefix>/<remaining-hash>
|
|
12
|
+
* Example: .gent/objects/ab/cdef1234567890...
|
|
13
|
+
*
|
|
14
|
+
* OBJECT FORMAT (on disk):
|
|
15
|
+
* zlib-compressed( "<type> <size>\0<content>" )
|
|
16
|
+
* Where type = "blob" | "tree"
|
|
17
|
+
*
|
|
18
|
+
* HASHING ALGORITHMS:
|
|
19
|
+
* 1. SHA-256 (crypto.createHash) — for content-addressable storage
|
|
20
|
+
* Same approach as git but uses SHA-256 instead of SHA-1.
|
|
21
|
+
* Input: type header + null byte + raw content
|
|
22
|
+
* Output: 64-char hex string
|
|
23
|
+
*
|
|
24
|
+
* 2. FNV-1a 32-bit — for fast line-level fingerprinting
|
|
25
|
+
* Used by diff engine to quickly compare lines.
|
|
26
|
+
* Non-cryptographic, optimized for speed over collision resistance.
|
|
27
|
+
*
|
|
28
|
+
* DEDUPLICATION:
|
|
29
|
+
* Before writing, check if object file exists → skip if so.
|
|
30
|
+
* Identical content always produces same hash → automatic dedup.
|
|
31
|
+
*
|
|
32
|
+
* COMPRESSION:
|
|
33
|
+
* zlib.deflate before write, zlib.inflate on read.
|
|
34
|
+
* Typically 60-80% size reduction for text files.
|
|
35
|
+
*
|
|
36
|
+
* BACKEND EXPECTATIONS:
|
|
37
|
+
* Backend should implement equivalent object store:
|
|
38
|
+
* - POST /api/repos/:id/push/ receives base64-encoded blobs
|
|
39
|
+
* - Backend computes same SHA-256 hash to verify integrity
|
|
40
|
+
* - Store in DB or filesystem with same addressing scheme
|
|
41
|
+
* - GET /api/repos/:id/pull/ returns base64 blob data
|
|
42
|
+
*
|
|
43
|
+
* ============================================================================
|
|
44
|
+
*/
|
|
45
|
+
|
|
46
|
+
const crypto = require('crypto');
|
|
47
|
+
const fs = require('fs').promises;
|
|
48
|
+
const path = require('path');
|
|
49
|
+
const zlib = require('zlib');
|
|
50
|
+
const { promisify } = require('util');
|
|
51
|
+
|
|
52
|
+
const deflate = promisify(zlib.deflate);
|
|
53
|
+
const inflate = promisify(zlib.inflate);
|
|
54
|
+
|
|
55
|
+
// ─── Primitive Hashing ───────────────────────────────────
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* SHA-256 hash of raw input.
|
|
59
|
+
* @param {Buffer|String} input
|
|
60
|
+
* @returns {String}
|
|
61
|
+
*/
|
|
62
|
+
function sha256(input) {
|
|
63
|
+
return crypto.createHash('sha256').update(input).digest('hex');
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/**
|
|
67
|
+
* FNV-1a 32-bit fast non-crypto hash for line tracking.
|
|
68
|
+
* @param {String} value
|
|
69
|
+
* @returns {String}
|
|
70
|
+
*/
|
|
71
|
+
function fnv1a32(value) {
|
|
72
|
+
let hash = 0x811c9dc5;
|
|
73
|
+
for (let i = 0; i < value.length; i++) {
|
|
74
|
+
hash ^= value.charCodeAt(i);
|
|
75
|
+
hash = (hash >>> 0) * 0x01000193;
|
|
76
|
+
}
|
|
77
|
+
return (hash >>> 0).toString(16).padStart(8, '0');
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/**
|
|
81
|
+
* Hash each line of text.
|
|
82
|
+
* @param {String} text
|
|
83
|
+
* @returns {Array<{ lineNumber: number, hash: string }>}
|
|
84
|
+
*/
|
|
85
|
+
function hashLines(text) {
|
|
86
|
+
const lines = splitLines(text);
|
|
87
|
+
return lines.map((line, index) => ({
|
|
88
|
+
lineNumber: index + 1,
|
|
89
|
+
hash: fnv1a32(line)
|
|
90
|
+
}));
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/**
|
|
94
|
+
* Split text into lines (normalizes CRLF).
|
|
95
|
+
* @param {String} text
|
|
96
|
+
* @returns {Array<String>}
|
|
97
|
+
*/
|
|
98
|
+
function splitLines(text) {
|
|
99
|
+
if (!text) return [];
|
|
100
|
+
return text.replace(/\r\n/g, '\n').split('\n');
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
/**
|
|
104
|
+
* Check if buffer looks binary (contains null bytes).
|
|
105
|
+
* @param {Buffer} buffer
|
|
106
|
+
* @returns {Boolean}
|
|
107
|
+
*/
|
|
108
|
+
function isBinaryBuffer(buffer) {
|
|
109
|
+
const probeLength = Math.min(buffer.length, 8000);
|
|
110
|
+
for (let i = 0; i < probeLength; i++) {
|
|
111
|
+
if (buffer[i] === 0) return true;
|
|
112
|
+
}
|
|
113
|
+
return false;
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
// ─── Content-Addressable Object Hashing ──────────────────
|
|
117
|
+
|
|
118
|
+
/**
|
|
119
|
+
* Hash content with type prefix: "<type> <size>\0<content>"
|
|
120
|
+
* @param {String} type - 'blob' | 'tree'
|
|
121
|
+
* @param {Buffer|String} content
|
|
122
|
+
* @returns {String} SHA-256 hex
|
|
123
|
+
*/
|
|
124
|
+
function hashObject(type, content) {
|
|
125
|
+
const buf = Buffer.isBuffer(content) ? content : Buffer.from(content, 'utf-8');
|
|
126
|
+
const header = `${type} ${buf.length}\0`;
|
|
127
|
+
const store = Buffer.concat([Buffer.from(header), buf]);
|
|
128
|
+
return crypto.createHash('sha256').update(store).digest('hex');
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
/**
|
|
132
|
+
* Hash content as blob.
|
|
133
|
+
* @param {Buffer|String} content
|
|
134
|
+
* @returns {String}
|
|
135
|
+
*/
|
|
136
|
+
function hashBlob(content) {
|
|
137
|
+
return hashObject('blob', content);
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
/**
|
|
141
|
+
* Hash a tree structure.
|
|
142
|
+
* @param {Array<{mode: String, name: String, hash: String, type: String}>} entries
|
|
143
|
+
* @returns {String}
|
|
144
|
+
*/
|
|
145
|
+
function hashTree(entries) {
|
|
146
|
+
return hashObject('tree', serializeTree(entries));
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
// ─── Object Store (disk read/write) ─────────────────────
|
|
150
|
+
|
|
151
|
+
/**
|
|
152
|
+
* Filesystem path for object: objects/ab/cdef1234...
|
|
153
|
+
*/
|
|
154
|
+
function objectPath(gentPath, hash) {
|
|
155
|
+
return path.join(gentPath, 'objects', hash.substring(0, 2), hash.substring(2));
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
/**
|
|
159
|
+
* Check if object exists in store.
|
|
160
|
+
* @param {String} gentPath
|
|
161
|
+
* @param {String} hash
|
|
162
|
+
* @returns {Promise<Boolean>}
|
|
163
|
+
*/
|
|
164
|
+
async function objectExists(gentPath, hash) {
|
|
165
|
+
try {
|
|
166
|
+
await fs.access(objectPath(gentPath, hash));
|
|
167
|
+
return true;
|
|
168
|
+
} catch {
|
|
169
|
+
return false;
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
/**
|
|
174
|
+
* Store blob (file content). Compressed with zlib. Deduplicates.
|
|
175
|
+
* @param {String} gentPath
|
|
176
|
+
* @param {Buffer|String} content
|
|
177
|
+
* @returns {Promise<String>} hash
|
|
178
|
+
*/
|
|
179
|
+
async function storeBlob(gentPath, content) {
|
|
180
|
+
const buf = Buffer.isBuffer(content) ? content : Buffer.from(content, 'utf-8');
|
|
181
|
+
const hash = hashBlob(buf);
|
|
182
|
+
|
|
183
|
+
if (await objectExists(gentPath, hash)) return hash;
|
|
184
|
+
|
|
185
|
+
const header = `blob ${buf.length}\0`;
|
|
186
|
+
const store = Buffer.concat([Buffer.from(header), buf]);
|
|
187
|
+
const compressed = await deflate(store);
|
|
188
|
+
|
|
189
|
+
const objPath = objectPath(gentPath, hash);
|
|
190
|
+
await fs.mkdir(path.dirname(objPath), { recursive: true });
|
|
191
|
+
await fs.writeFile(objPath, compressed);
|
|
192
|
+
|
|
193
|
+
return hash;
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
/**
|
|
197
|
+
* Read blob raw content from store.
|
|
198
|
+
* @param {String} gentPath
|
|
199
|
+
* @param {String} hash
|
|
200
|
+
* @returns {Promise<Buffer>}
|
|
201
|
+
*/
|
|
202
|
+
async function readBlob(gentPath, hash) {
|
|
203
|
+
const objPath = objectPath(gentPath, hash);
|
|
204
|
+
const compressed = await fs.readFile(objPath);
|
|
205
|
+
const raw = await inflate(compressed);
|
|
206
|
+
const nullIndex = raw.indexOf(0);
|
|
207
|
+
return raw.slice(nullIndex + 1);
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
/**
|
|
211
|
+
* Read blob as UTF-8 string.
|
|
212
|
+
* @param {String} gentPath
|
|
213
|
+
* @param {String} hash
|
|
214
|
+
* @returns {Promise<String>}
|
|
215
|
+
*/
|
|
216
|
+
async function readBlobAsString(gentPath, hash) {
|
|
217
|
+
const buf = await readBlob(gentPath, hash);
|
|
218
|
+
return buf.toString('utf-8');
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
// ─── Tree Objects ────────────────────────────────────────
|
|
222
|
+
|
|
223
|
+
/**
|
|
224
|
+
* Serialize tree entries to deterministic JSON (sorted by name).
|
|
225
|
+
*/
|
|
226
|
+
function serializeTree(entries) {
|
|
227
|
+
const sorted = [...entries].sort((a, b) => a.name.localeCompare(b.name));
|
|
228
|
+
return JSON.stringify(sorted);
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
/**
|
|
232
|
+
* Deserialize tree JSON.
|
|
233
|
+
*/
|
|
234
|
+
function deserializeTree(data) {
|
|
235
|
+
return JSON.parse(data);
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
/**
|
|
239
|
+
* Store tree object. Entry: { mode, name, hash, type }
|
|
240
|
+
* @param {String} gentPath
|
|
241
|
+
* @param {Array} entries
|
|
242
|
+
* @returns {Promise<String>} tree hash
|
|
243
|
+
*/
|
|
244
|
+
async function storeTree(gentPath, entries) {
|
|
245
|
+
const serialized = serializeTree(entries);
|
|
246
|
+
const hash = hashObject('tree', serialized);
|
|
247
|
+
|
|
248
|
+
if (await objectExists(gentPath, hash)) return hash;
|
|
249
|
+
|
|
250
|
+
const header = `tree ${Buffer.byteLength(serialized)}\0`;
|
|
251
|
+
const store = Buffer.concat([Buffer.from(header), Buffer.from(serialized)]);
|
|
252
|
+
const compressed = await deflate(store);
|
|
253
|
+
|
|
254
|
+
const objPath = objectPath(gentPath, hash);
|
|
255
|
+
await fs.mkdir(path.dirname(objPath), { recursive: true });
|
|
256
|
+
await fs.writeFile(objPath, compressed);
|
|
257
|
+
|
|
258
|
+
return hash;
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
/**
|
|
262
|
+
* Read tree entries from store.
|
|
263
|
+
* @param {String} gentPath
|
|
264
|
+
* @param {String} hash
|
|
265
|
+
* @returns {Promise<Array>}
|
|
266
|
+
*/
|
|
267
|
+
async function readTree(gentPath, hash) {
|
|
268
|
+
const buf = await readBlob(gentPath, hash);
|
|
269
|
+
return deserializeTree(buf.toString('utf-8'));
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
// ─── Snapshot Helpers ────────────────────────────────────
|
|
273
|
+
|
|
274
|
+
/**
|
|
275
|
+
* Snapshot single file → store blob, return tree entry.
|
|
276
|
+
* @param {String} gentPath
|
|
277
|
+
* @param {String} cwd
|
|
278
|
+
* @param {String} relativePath
|
|
279
|
+
* @returns {Promise<{mode, name, hash, type}>}
|
|
280
|
+
*/
|
|
281
|
+
async function snapshotFile(gentPath, cwd, relativePath) {
|
|
282
|
+
const fullPath = path.join(cwd, relativePath);
|
|
283
|
+
const content = await fs.readFile(fullPath);
|
|
284
|
+
const hash = await storeBlob(gentPath, content);
|
|
285
|
+
return { mode: '100644', name: relativePath, hash, type: 'blob' };
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
/**
|
|
289
|
+
* Snapshot multiple files → store blobs + tree.
|
|
290
|
+
* @param {String} gentPath
|
|
291
|
+
* @param {String} cwd
|
|
292
|
+
* @param {Array<String>} files
|
|
293
|
+
* @returns {Promise<{treeHash: String, entries: Array}>}
|
|
294
|
+
*/
|
|
295
|
+
async function snapshotFiles(gentPath, cwd, files) {
|
|
296
|
+
const entries = [];
|
|
297
|
+
for (const file of files) {
|
|
298
|
+
const entry = await snapshotFile(gentPath, cwd, file);
|
|
299
|
+
entries.push(entry);
|
|
300
|
+
}
|
|
301
|
+
const treeHash = await storeTree(gentPath, entries);
|
|
302
|
+
return { treeHash, entries };
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
/**
|
|
306
|
+
* Build lookup map: filePath → blobHash from tree entries.
|
|
307
|
+
* @param {Array} entries
|
|
308
|
+
* @returns {Map<String, String>}
|
|
309
|
+
*/
|
|
310
|
+
function treeToMap(entries) {
|
|
311
|
+
const map = new Map();
|
|
312
|
+
for (const e of entries) map.set(e.name, e.hash);
|
|
313
|
+
return map;
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
module.exports = {
|
|
317
|
+
// Primitives
|
|
318
|
+
sha256,
|
|
319
|
+
fnv1a32,
|
|
320
|
+
hashLines,
|
|
321
|
+
splitLines,
|
|
322
|
+
isBinaryBuffer,
|
|
323
|
+
// Content-addressable
|
|
324
|
+
hashObject,
|
|
325
|
+
hashBlob,
|
|
326
|
+
hashTree,
|
|
327
|
+
objectExists,
|
|
328
|
+
storeBlob,
|
|
329
|
+
readBlob,
|
|
330
|
+
readBlobAsString,
|
|
331
|
+
storeTree,
|
|
332
|
+
readTree,
|
|
333
|
+
// Snapshots
|
|
334
|
+
snapshotFile,
|
|
335
|
+
snapshotFiles,
|
|
336
|
+
treeToMap
|
|
337
|
+
};
|