@coldge.com/gitbase 1.0.2 → 1.0.3
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +65 -15
- package/dist/api/supabase.js +139 -32
- package/dist/commands/branch.js +86 -14
- package/dist/commands/diff.js +180 -94
- package/dist/commands/init.js +8 -3
- package/dist/commands/log.js +85 -11
- package/dist/commands/merge.js +226 -15
- package/dist/commands/pull.js +171 -0
- package/dist/commands/push.js +324 -71
- package/dist/commands/revert.js +247 -95
- package/dist/commands/snapshot.js +49 -0
- package/dist/commands/stash.js +211 -0
- package/dist/commands/status.js +46 -38
- package/dist/commands/verify.js +120 -0
- package/dist/index.js +90 -10
- package/dist/schema/extractor.js +183 -26
- package/dist/schema/queries.js +160 -8
- package/dist/utils/hashing.js +52 -3
- package/dist/utils/sqlDiff.js +245 -0
- package/package.json +2 -1
package/dist/commands/merge.js
CHANGED
|
@@ -1,15 +1,161 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* merge.ts — 3-way merge using diff3 algorithm.
|
|
3
|
+
*
|
|
4
|
+
* Finds the common ancestor commit, then per-file:
|
|
5
|
+
* - Auto-merges if only one side changed
|
|
6
|
+
* - Emits <<<<<<< / ======= / >>>>>>> conflict markers when both sides changed
|
|
7
|
+
*
|
|
8
|
+
* After merge, local files are updated. Run `gitb push` to apply to the DB.
|
|
9
|
+
*/
|
|
1
10
|
import fs from 'fs/promises';
|
|
2
11
|
import path from 'path';
|
|
3
12
|
import chalk from 'chalk';
|
|
4
13
|
import { readCommit, readTree, readObject } from '../storage/git.js';
|
|
14
|
+
import * as diffLib from 'diff';
|
|
5
15
|
const GITBASE_DIR = '.gitbase';
|
|
16
|
+
const CONFIG_FILE = path.join(GITBASE_DIR, 'config');
|
|
17
|
+
const OBJECTS_DIR = path.join(GITBASE_DIR, 'objects');
|
|
18
|
+
// ---------------------------------------------------------------------------
|
|
19
|
+
// Common ancestor search
|
|
20
|
+
// ---------------------------------------------------------------------------
|
|
21
|
+
async function getAncestorChain(startHash) {
|
|
22
|
+
const chain = new Set();
|
|
23
|
+
let hash = startHash;
|
|
24
|
+
while (hash) {
|
|
25
|
+
chain.add(hash);
|
|
26
|
+
try {
|
|
27
|
+
const commit = await readCommit(hash);
|
|
28
|
+
hash = commit.parent ?? null;
|
|
29
|
+
}
|
|
30
|
+
catch {
|
|
31
|
+
break;
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
return chain;
|
|
35
|
+
}
|
|
36
|
+
async function findCommonAncestor(hashA, hashB) {
|
|
37
|
+
const chainA = await getAncestorChain(hashA);
|
|
38
|
+
let hash = hashB;
|
|
39
|
+
while (hash) {
|
|
40
|
+
if (chainA.has(hash))
|
|
41
|
+
return hash;
|
|
42
|
+
try {
|
|
43
|
+
const commit = await readCommit(hash);
|
|
44
|
+
hash = commit.parent ?? null;
|
|
45
|
+
}
|
|
46
|
+
catch {
|
|
47
|
+
break;
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
return null;
|
|
51
|
+
}
|
|
52
|
+
// ---------------------------------------------------------------------------
|
|
53
|
+
// Tree loader
|
|
54
|
+
// ---------------------------------------------------------------------------
|
|
55
|
+
async function loadTreeFiles(treeHash) {
|
|
56
|
+
const files = {};
|
|
57
|
+
try {
|
|
58
|
+
const raw = await fs.readFile(path.join(OBJECTS_DIR, treeHash), 'utf-8');
|
|
59
|
+
const tree = JSON.parse(raw);
|
|
60
|
+
for (const [relPath, blobHash] of Object.entries(tree)) {
|
|
61
|
+
files[relPath] = await readObject(blobHash);
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
catch { }
|
|
65
|
+
return files;
|
|
66
|
+
}
|
|
67
|
+
// ---------------------------------------------------------------------------
|
|
68
|
+
// diff3 merge — for SQL DDL, we treat any simultaneous edit as a conflict
|
|
69
|
+
// rather than trying to merge at the line level. This is the safest approach.
|
|
70
|
+
// ---------------------------------------------------------------------------
|
|
71
|
+
function merge3(base, ours, theirs, ourLabel, theirLabel) {
|
|
72
|
+
// Quick exits: if one side matches base, take the other
|
|
73
|
+
if (ours === base)
|
|
74
|
+
return { merged: theirs, hasConflict: false };
|
|
75
|
+
if (theirs === base)
|
|
76
|
+
return { merged: ours, hasConflict: false };
|
|
77
|
+
if (ours === theirs)
|
|
78
|
+
return { merged: ours, hasConflict: false };
|
|
79
|
+
// Both sides changed relative to base.
|
|
80
|
+
// Check if the changes are to different regions by diffing base→ours and base→theirs.
|
|
81
|
+
const diffOurs = diffLib.diffLines(base, ours);
|
|
82
|
+
const diffTheirs = diffLib.diffLines(base, theirs);
|
|
83
|
+
// Build a set of base-line indices that each side touched
|
|
84
|
+
const ourChangedLines = new Set();
|
|
85
|
+
const theirChangedLines = new Set();
|
|
86
|
+
let lineIdx = 0;
|
|
87
|
+
for (const part of diffOurs) {
|
|
88
|
+
const n = part.count ?? part.value.split('\n').length - 1;
|
|
89
|
+
if (part.removed) {
|
|
90
|
+
for (let i = 0; i < n; i++)
|
|
91
|
+
ourChangedLines.add(lineIdx + i);
|
|
92
|
+
}
|
|
93
|
+
else if (!part.added) {
|
|
94
|
+
lineIdx += n;
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
lineIdx = 0;
|
|
98
|
+
for (const part of diffTheirs) {
|
|
99
|
+
const n = part.count ?? part.value.split('\n').length - 1;
|
|
100
|
+
if (part.removed) {
|
|
101
|
+
for (let i = 0; i < n; i++)
|
|
102
|
+
theirChangedLines.add(lineIdx + i);
|
|
103
|
+
}
|
|
104
|
+
else if (!part.added) {
|
|
105
|
+
lineIdx += n;
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
// Check for overlapping line changes
|
|
109
|
+
let overlap = false;
|
|
110
|
+
for (const line of ourChangedLines) {
|
|
111
|
+
if (theirChangedLines.has(line)) {
|
|
112
|
+
overlap = true;
|
|
113
|
+
break;
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
if (overlap) {
|
|
117
|
+
// Conflicting edit — emit markers
|
|
118
|
+
const merged = [
|
|
119
|
+
`<<<<<<< ${ourLabel}`,
|
|
120
|
+
ours.trimEnd(),
|
|
121
|
+
`=======`,
|
|
122
|
+
theirs.trimEnd(),
|
|
123
|
+
`>>>>>>> ${theirLabel}`
|
|
124
|
+
].join('\n') + '\n';
|
|
125
|
+
return { merged, hasConflict: true };
|
|
126
|
+
}
|
|
127
|
+
// Non-overlapping changes — safe to auto-merge
|
|
128
|
+
// If only ours changed, take ours; if only theirs changed, take theirs.
|
|
129
|
+
if (ourChangedLines.size > 0)
|
|
130
|
+
return { merged: ours, hasConflict: false };
|
|
131
|
+
return { merged: theirs, hasConflict: false };
|
|
132
|
+
}
|
|
133
|
+
// ---------------------------------------------------------------------------
|
|
134
|
+
// Fast-forward helper
|
|
135
|
+
// ---------------------------------------------------------------------------
|
|
136
|
+
async function fastForward(sourceHead, sourceBranch, currentBranch) {
|
|
137
|
+
const sourceCommit = await readCommit(sourceHead);
|
|
138
|
+
const sourceTree = await readTree(sourceCommit.tree);
|
|
139
|
+
await fs.rm('supabase', { recursive: true, force: true });
|
|
140
|
+
for (const [relPath, hash] of Object.entries(sourceTree)) {
|
|
141
|
+
const fullPath = path.join('supabase', relPath);
|
|
142
|
+
await fs.mkdir(path.dirname(fullPath), { recursive: true });
|
|
143
|
+
const content = await readObject(hash);
|
|
144
|
+
await fs.writeFile(fullPath, content, 'utf-8');
|
|
145
|
+
}
|
|
146
|
+
console.log(chalk.green(`\nFast-forward merge complete.`));
|
|
147
|
+
console.log(chalk.yellow(`Run 'gitb push' to apply to the '${currentBranch}' database.`));
|
|
148
|
+
}
|
|
149
|
+
// ---------------------------------------------------------------------------
|
|
150
|
+
// Main command
|
|
151
|
+
// ---------------------------------------------------------------------------
|
|
6
152
|
export async function merge(argv) {
|
|
7
153
|
const sourceBranch = argv.name;
|
|
8
154
|
if (!sourceBranch) {
|
|
9
155
|
console.error(chalk.red('Source branch name required.'));
|
|
10
156
|
return;
|
|
11
157
|
}
|
|
12
|
-
const configContent = await fs.readFile(
|
|
158
|
+
const configContent = await fs.readFile(CONFIG_FILE, 'utf-8');
|
|
13
159
|
const config = JSON.parse(configContent);
|
|
14
160
|
if (!config.branches[sourceBranch]) {
|
|
15
161
|
console.error(chalk.red(`Branch '${sourceBranch}' does not exist.`));
|
|
@@ -25,6 +171,7 @@ export async function merge(argv) {
|
|
|
25
171
|
return;
|
|
26
172
|
}
|
|
27
173
|
const currentBranch = config.currentBranch;
|
|
174
|
+
const currentHead = config.branches[currentBranch]?.head ?? null;
|
|
28
175
|
const projectRef = config.branches[currentBranch].projectRef;
|
|
29
176
|
// RBAC: Production Protection
|
|
30
177
|
if (currentBranch === 'production') {
|
|
@@ -37,23 +184,87 @@ export async function merge(argv) {
|
|
|
37
184
|
}
|
|
38
185
|
console.log(chalk.green('Permission verified.'));
|
|
39
186
|
}
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
//
|
|
47
|
-
|
|
48
|
-
|
|
187
|
+
// Fast-forward if current has no history
|
|
188
|
+
if (!currentHead) {
|
|
189
|
+
console.log(chalk.blue(`No local commits — fast-forwarding to '${sourceBranch}'...`));
|
|
190
|
+
await fastForward(sourceHead, sourceBranch, currentBranch);
|
|
191
|
+
return;
|
|
192
|
+
}
|
|
193
|
+
// Find common ancestor
|
|
194
|
+
console.log(chalk.blue(`Finding common ancestor between '${currentBranch}' and '${sourceBranch}'...`));
|
|
195
|
+
const ancestorHash = await findCommonAncestor(currentHead, sourceHead);
|
|
196
|
+
if (ancestorHash === sourceHead) {
|
|
197
|
+
console.log(chalk.green(`Already up to date. '${sourceBranch}' is an ancestor of '${currentBranch}'.`));
|
|
198
|
+
return;
|
|
49
199
|
}
|
|
50
|
-
|
|
51
|
-
|
|
200
|
+
if (ancestorHash === currentHead) {
|
|
201
|
+
console.log(chalk.blue('Fast-forward possible...'));
|
|
202
|
+
await fastForward(sourceHead, sourceBranch, currentBranch);
|
|
203
|
+
return;
|
|
204
|
+
}
|
|
205
|
+
// True 3-way merge
|
|
206
|
+
const ourLabel = currentBranch;
|
|
207
|
+
const theirLabel = sourceBranch;
|
|
208
|
+
console.log(chalk.blue(ancestorHash
|
|
209
|
+
? `Common ancestor: ${ancestorHash.substring(0, 7)}`
|
|
210
|
+
: `No common ancestor found — performing a criss-cross merge from scratch.`));
|
|
211
|
+
console.log(chalk.blue('\nPerforming 3-way merge...'));
|
|
212
|
+
const [currentCommit, sourceCommit] = await Promise.all([
|
|
213
|
+
readCommit(currentHead),
|
|
214
|
+
readCommit(sourceHead)
|
|
215
|
+
]);
|
|
216
|
+
const baseTreeHash = ancestorHash ? (await readCommit(ancestorHash)).tree : null;
|
|
217
|
+
const [baseFiles, oursFiles, theirsFiles] = await Promise.all([
|
|
218
|
+
baseTreeHash ? loadTreeFiles(baseTreeHash) : Promise.resolve({}),
|
|
219
|
+
loadTreeFiles(currentCommit.tree),
|
|
220
|
+
loadTreeFiles(sourceCommit.tree)
|
|
221
|
+
]);
|
|
222
|
+
const allPaths = new Set([...Object.keys(oursFiles), ...Object.keys(theirsFiles)]);
|
|
223
|
+
const merged = {};
|
|
224
|
+
let conflictCount = 0;
|
|
225
|
+
let autoMergedCount = 0;
|
|
226
|
+
let addedCount = 0;
|
|
227
|
+
for (const relPath of allPaths) {
|
|
228
|
+
const base = baseFiles[relPath] ?? '';
|
|
229
|
+
const ours = oursFiles[relPath] ?? '';
|
|
230
|
+
const theirs = theirsFiles[relPath] ?? '';
|
|
231
|
+
if (!oursFiles[relPath]) {
|
|
232
|
+
// Added only by theirs
|
|
233
|
+
merged[relPath] = theirs;
|
|
234
|
+
addedCount++;
|
|
235
|
+
console.log(chalk.green(` + ${relPath} (added by ${theirLabel})`));
|
|
236
|
+
continue;
|
|
237
|
+
}
|
|
238
|
+
if (!theirsFiles[relPath]) {
|
|
239
|
+
// Only in ours (theirs deleted or never had it)
|
|
240
|
+
merged[relPath] = ours;
|
|
241
|
+
continue;
|
|
242
|
+
}
|
|
243
|
+
const { merged: mergedContent, hasConflict } = merge3(base, ours, theirs, ourLabel, theirLabel);
|
|
244
|
+
merged[relPath] = mergedContent;
|
|
245
|
+
if (hasConflict) {
|
|
246
|
+
conflictCount++;
|
|
247
|
+
console.log(chalk.red(` ✗ CONFLICT: ${relPath}`));
|
|
248
|
+
}
|
|
249
|
+
else if (mergedContent !== ours) {
|
|
250
|
+
autoMergedCount++;
|
|
251
|
+
console.log(chalk.cyan(` ✓ Auto-merged: ${relPath}`));
|
|
252
|
+
}
|
|
253
|
+
}
|
|
254
|
+
// Write merged result to supabase/
|
|
255
|
+
await fs.rm('supabase', { recursive: true, force: true });
|
|
256
|
+
for (const [relPath, content] of Object.entries(merged)) {
|
|
52
257
|
const fullPath = path.join('supabase', relPath);
|
|
53
258
|
await fs.mkdir(path.dirname(fullPath), { recursive: true });
|
|
54
|
-
const content = await readObject(hash);
|
|
55
259
|
await fs.writeFile(fullPath, content, 'utf-8');
|
|
56
260
|
}
|
|
57
|
-
console.log(
|
|
58
|
-
|
|
261
|
+
console.log();
|
|
262
|
+
if (conflictCount > 0) {
|
|
263
|
+
console.log(chalk.red(`Merge complete with ${conflictCount} conflict(s).`));
|
|
264
|
+
console.log(chalk.yellow(`Find <<<<<<< markers, resolve them, then run 'gitb commit' and 'gitb push'.`));
|
|
265
|
+
}
|
|
266
|
+
else {
|
|
267
|
+
console.log(chalk.green(`Merge complete. ${autoMergedCount} auto-merged, ${addedCount} added.`));
|
|
268
|
+
console.log(chalk.yellow(`Run 'gitb commit -m "Merge ${sourceBranch} into ${currentBranch}"' then 'gitb push'.`));
|
|
269
|
+
}
|
|
59
270
|
}
|
|
@@ -0,0 +1,171 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* pull.ts — Sync local supabase/ files with the live database schema.
|
|
3
|
+
*
|
|
4
|
+
* Usage:
|
|
5
|
+
* gitb pull Pull schema to local files
|
|
6
|
+
* gitb pull --auto-commit Pull + auto-commit with generated message
|
|
7
|
+
* gitb pull -m "my message" Pull + commit with specific message
|
|
8
|
+
*/
|
|
9
|
+
import fs from 'fs/promises';
|
|
10
|
+
import path from 'path';
|
|
11
|
+
import chalk from 'chalk';
|
|
12
|
+
import { getConfig } from '../api/supabase.js';
|
|
13
|
+
import { extractSchema } from '../schema/extractor.js';
|
|
14
|
+
import { canonicalize, hashString } from '../utils/hashing.js';
|
|
15
|
+
const GITBASE_DIR = '.gitbase';
|
|
16
|
+
const CONFIG_FILE = path.join(GITBASE_DIR, 'config');
|
|
17
|
+
const OBJECTS_DIR = path.join(GITBASE_DIR, 'objects');
|
|
18
|
+
const HEAD_FILE = path.join(GITBASE_DIR, 'HEAD');
|
|
19
|
+
// All schema types in write order
|
|
20
|
+
const ALL_TYPES = [
|
|
21
|
+
'extensions', 'types', 'sequences', 'tables', 'matviews',
|
|
22
|
+
'views', 'functions', 'triggers', 'policies', 'grants', 'publications'
|
|
23
|
+
];
|
|
24
|
+
export async function pull(argv) {
|
|
25
|
+
const config = await getConfig();
|
|
26
|
+
if (!config) {
|
|
27
|
+
console.error(chalk.red('Not initialized. Run `gitb init` first.'));
|
|
28
|
+
return;
|
|
29
|
+
}
|
|
30
|
+
const branchData = config.branches?.[config.currentBranch];
|
|
31
|
+
const projectRef = branchData?.projectRef ?? config.projectRef;
|
|
32
|
+
if (!projectRef) {
|
|
33
|
+
console.error(chalk.red('No project reference found. Re-run `gitb init`.'));
|
|
34
|
+
return;
|
|
35
|
+
}
|
|
36
|
+
console.log(chalk.blue(`Pulling schema from project ${projectRef}...`));
|
|
37
|
+
const schema = await extractSchema(projectRef);
|
|
38
|
+
// Write all schema files to supabase/<type>/<name>.sql
|
|
39
|
+
let written = 0;
|
|
40
|
+
let changed = 0;
|
|
41
|
+
const changedFiles = [];
|
|
42
|
+
for (const type of ALL_TYPES) {
|
|
43
|
+
const objects = schema[type];
|
|
44
|
+
if (!objects)
|
|
45
|
+
continue;
|
|
46
|
+
const dir = path.join('supabase', type);
|
|
47
|
+
await fs.mkdir(dir, { recursive: true });
|
|
48
|
+
for (const [name, sql] of Object.entries(objects)) {
|
|
49
|
+
const filePath = path.join(dir, `${name}.sql`);
|
|
50
|
+
const newContent = sql.trim() + '\n';
|
|
51
|
+
// Check if file changed
|
|
52
|
+
let existingContent = null;
|
|
53
|
+
try {
|
|
54
|
+
existingContent = await fs.readFile(filePath, 'utf-8');
|
|
55
|
+
}
|
|
56
|
+
catch { /* file doesn't exist yet */ }
|
|
57
|
+
const isChanged = existingContent === null ||
|
|
58
|
+
canonicalize(existingContent) !== canonicalize(newContent);
|
|
59
|
+
await fs.writeFile(filePath, newContent, 'utf-8');
|
|
60
|
+
written++;
|
|
61
|
+
if (isChanged) {
|
|
62
|
+
changed++;
|
|
63
|
+
changedFiles.push(`${type}/${name}.sql`);
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
if (changed === 0) {
|
|
68
|
+
console.log(chalk.green('\nAlready up to date. No schema changes detected.'));
|
|
69
|
+
}
|
|
70
|
+
else {
|
|
71
|
+
console.log(chalk.green(`\nPull complete. ${changed} file(s) updated:`));
|
|
72
|
+
for (const f of changedFiles.slice(0, 20)) {
|
|
73
|
+
console.log(chalk.cyan(` ${f}`));
|
|
74
|
+
}
|
|
75
|
+
if (changedFiles.length > 20) {
|
|
76
|
+
console.log(chalk.gray(` ... and ${changedFiles.length - 20} more`));
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
// --auto-commit or -m: auto-commit after pull
|
|
80
|
+
const autoCommit = argv['auto-commit'] || argv['autoCommit'];
|
|
81
|
+
const commitMessage = argv.m || argv.message;
|
|
82
|
+
if (autoCommit || commitMessage) {
|
|
83
|
+
if (changed === 0 && !commitMessage) {
|
|
84
|
+
console.log(chalk.gray('Nothing changed — skipping auto-commit.'));
|
|
85
|
+
return;
|
|
86
|
+
}
|
|
87
|
+
const message = commitMessage
|
|
88
|
+
|| generateAutoCommitMessage(changedFiles);
|
|
89
|
+
await commitCurrentState(message, projectRef, config);
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
/**
|
|
93
|
+
* Generates a readable auto-commit message from the list of changed files.
|
|
94
|
+
* e.g. "Auto-snapshot: modified tables/users, new functions/send_email"
|
|
95
|
+
*/
|
|
96
|
+
function generateAutoCommitMessage(changedFiles) {
|
|
97
|
+
if (changedFiles.length === 0)
|
|
98
|
+
return 'Auto-snapshot: no changes';
|
|
99
|
+
if (changedFiles.length <= 3) {
|
|
100
|
+
return `Auto-snapshot: ${changedFiles.join(', ')}`;
|
|
101
|
+
}
|
|
102
|
+
return `Auto-snapshot: ${changedFiles.slice(0, 3).join(', ')} (+${changedFiles.length - 3} more)`;
|
|
103
|
+
}
|
|
104
|
+
/**
|
|
105
|
+
* Commits the current supabase/ working tree state.
|
|
106
|
+
* Mirrors the logic in commit.ts but callable as a function.
|
|
107
|
+
*/
|
|
108
|
+
async function commitCurrentState(message, projectRef, config) {
|
|
109
|
+
const tree = {};
|
|
110
|
+
async function walk(dir, base) {
|
|
111
|
+
const entries = await fs.readdir(dir, { withFileTypes: true }).catch(() => []);
|
|
112
|
+
for (const entry of entries) {
|
|
113
|
+
const fullPath = path.join(dir, entry.name);
|
|
114
|
+
const relPath = path.join(base, entry.name).replace(/\\/g, '/');
|
|
115
|
+
if (entry.isDirectory()) {
|
|
116
|
+
await walk(fullPath, relPath);
|
|
117
|
+
}
|
|
118
|
+
else if (entry.isFile() && entry.name.endsWith('.sql')) {
|
|
119
|
+
const content = await fs.readFile(fullPath, 'utf-8');
|
|
120
|
+
const hash = hashString(content);
|
|
121
|
+
await saveObject(hash, content);
|
|
122
|
+
tree[relPath] = hash;
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
await walk('supabase', '');
|
|
127
|
+
if (Object.keys(tree).length === 0) {
|
|
128
|
+
console.log(chalk.yellow('Nothing to commit.'));
|
|
129
|
+
return;
|
|
130
|
+
}
|
|
131
|
+
const sortedTree = Object.fromEntries(Object.entries(tree).sort(([a], [b]) => a.localeCompare(b)));
|
|
132
|
+
const treeJson = JSON.stringify(sortedTree);
|
|
133
|
+
const treeHash = hashString(treeJson);
|
|
134
|
+
await saveObject(treeHash, treeJson);
|
|
135
|
+
// Check if anything actually changed since last commit
|
|
136
|
+
let parent = null;
|
|
137
|
+
let parentTreeHash = null;
|
|
138
|
+
try {
|
|
139
|
+
parent = await fs.readFile(HEAD_FILE, 'utf-8');
|
|
140
|
+
if (parent) {
|
|
141
|
+
const parentCommitStr = await fs.readFile(path.join(OBJECTS_DIR, parent), 'utf-8');
|
|
142
|
+
parentTreeHash = JSON.parse(parentCommitStr).tree;
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
catch { }
|
|
146
|
+
if (parentTreeHash === treeHash) {
|
|
147
|
+
console.log(chalk.yellow('Nothing to commit (working tree clean).'));
|
|
148
|
+
return;
|
|
149
|
+
}
|
|
150
|
+
const commitObj = {
|
|
151
|
+
tree: treeHash,
|
|
152
|
+
parent,
|
|
153
|
+
message,
|
|
154
|
+
timestamp: new Date().toISOString(),
|
|
155
|
+
author: process.env.USERNAME || process.env.USER || 'unknown'
|
|
156
|
+
};
|
|
157
|
+
const commitJson = JSON.stringify(commitObj, null, 2);
|
|
158
|
+
const commitHash = hashString(commitJson);
|
|
159
|
+
await saveObject(commitHash, commitJson);
|
|
160
|
+
await fs.writeFile(HEAD_FILE, commitHash, 'utf-8');
|
|
161
|
+
// Update branch head in config
|
|
162
|
+
if (config.branches && config.currentBranch) {
|
|
163
|
+
config.branches[config.currentBranch].head = commitHash;
|
|
164
|
+
await fs.writeFile(CONFIG_FILE, JSON.stringify(config, null, 2));
|
|
165
|
+
}
|
|
166
|
+
console.log(chalk.green(`\n[${commitHash.substring(0, 7)}] ${message}`));
|
|
167
|
+
}
|
|
168
|
+
async function saveObject(hash, content) {
|
|
169
|
+
await fs.mkdir(OBJECTS_DIR, { recursive: true });
|
|
170
|
+
await fs.writeFile(path.join(OBJECTS_DIR, hash), content, 'utf-8');
|
|
171
|
+
}
|