@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.
@@ -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(path.join(GITBASE_DIR, 'config'), 'utf-8');
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
- console.log(chalk.blue(`Merging files from '${sourceBranch}' into '${currentBranch}'...`));
41
- // 1. Load source tree
42
- const commit = await readCommit(sourceHead);
43
- const tree = await readTree(commit.tree);
44
- // 2. Clear current supabase dir and copy files
45
- // In a real merge, we would do a 3-way merge.
46
- // For MVP, we just overwrite local files with the source branch's files.
47
- try {
48
- await fs.rm('supabase', { recursive: true, force: true });
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
- catch (e) { }
51
- for (const [relPath, hash] of Object.entries(tree)) {
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(chalk.green(`\nSuccessfully merged branch '${sourceBranch}' into your local files.`));
58
- console.log(chalk.yellow(`Run 'gitb push' to apply these changes to the live '${config.currentBranch}' database.`));
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
+ }