@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.
@@ -7,8 +7,125 @@ import { extractSchema } from '../schema/extractor.js';
7
7
  import { canonicalize } from '../utils/hashing.js';
8
8
  const GITBASE_DIR = '.gitbase';
9
9
  const CONFIG_FILE = path.join(GITBASE_DIR, 'config');
10
+ const HEAD_FILE = path.join(GITBASE_DIR, 'HEAD');
11
+ const OBJECTS_DIR = path.join(GITBASE_DIR, 'objects');
12
+ const ALL_TYPES = [
13
+ 'extensions', 'types', 'sequences', 'tables', 'matviews',
14
+ 'views', 'functions', 'triggers', 'policies', 'grants', 'publications'
15
+ ];
16
+ // ---------------------------------------------------------------------------
17
+ // HEAD~N resolution
18
+ // ---------------------------------------------------------------------------
19
+ async function resolveRef(ref) {
20
+ // HEAD~N syntax
21
+ const tildeMatch = ref.match(/^HEAD~(\d+)$/i);
22
+ if (tildeMatch || ref.toUpperCase() === 'HEAD') {
23
+ const steps = tildeMatch ? parseInt(tildeMatch[1], 10) : 0;
24
+ let hash = await fs.readFile(HEAD_FILE, 'utf-8').catch(() => null);
25
+ if (!hash)
26
+ throw new Error('No HEAD commit found.');
27
+ for (let i = 0; i < steps; i++) {
28
+ const commit = await readCommit(hash);
29
+ if (!commit.parent)
30
+ throw new Error(`HEAD~${steps} does not exist (only ${i} ancestors).`);
31
+ hash = commit.parent;
32
+ }
33
+ return hash;
34
+ }
35
+ // Otherwise treat as a raw commit hash (prefix search)
36
+ if (ref.length < 40) {
37
+ const objects = await fs.readdir(OBJECTS_DIR).catch(() => []);
38
+ const match = objects.find(h => h.startsWith(ref));
39
+ if (!match)
40
+ throw new Error(`Commit '${ref}' not found.`);
41
+ return match;
42
+ }
43
+ return ref;
44
+ }
45
+ // ---------------------------------------------------------------------------
46
+ // Tree loader — commit hash → { type: { name: sql } }
47
+ // ---------------------------------------------------------------------------
48
+ async function loadCommitSchema(commitHash) {
49
+ const schema = {};
50
+ for (const t of ALL_TYPES)
51
+ schema[t] = {};
52
+ const commit = await readCommit(commitHash);
53
+ const tree = await readTree(commit.tree);
54
+ for (const [relPath, hash] of Object.entries(tree)) {
55
+ const parts = relPath.split('/');
56
+ if (parts.length < 2)
57
+ continue;
58
+ const type = parts[0];
59
+ const name = path.basename(relPath, '.sql');
60
+ if (schema[type] !== undefined) {
61
+ schema[type][name] = await readObject(hash);
62
+ }
63
+ }
64
+ return schema;
65
+ }
66
+ // ---------------------------------------------------------------------------
67
+ // Local files → schema map
68
+ // ---------------------------------------------------------------------------
69
+ async function loadLocalSchema() {
70
+ const schema = {};
71
+ for (const type of ALL_TYPES) {
72
+ schema[type] = {};
73
+ const dir = path.join('supabase', type);
74
+ try {
75
+ const files = await fs.readdir(dir);
76
+ for (const file of files) {
77
+ if (!file.endsWith('.sql'))
78
+ continue;
79
+ schema[type][path.basename(file, '.sql')] = await fs.readFile(path.join(dir, file), 'utf-8');
80
+ }
81
+ }
82
+ catch { }
83
+ }
84
+ return schema;
85
+ }
86
+ // ---------------------------------------------------------------------------
87
+ // Diff renderer
88
+ // ---------------------------------------------------------------------------
89
+ function renderDiff(aSchema, bSchema, aLabel, bLabel, filterFiles) {
90
+ let hasDiff = false;
91
+ for (const type of ALL_TYPES) {
92
+ const keys = Array.from(new Set([
93
+ ...Object.keys(aSchema[type] ?? {}),
94
+ ...Object.keys(bSchema[type] ?? {})
95
+ ])).sort();
96
+ for (const key of keys) {
97
+ const filePath = `${type}/${key}.sql`;
98
+ if (filterFiles.length > 0 && !filterFiles.includes(filePath))
99
+ continue;
100
+ const aSql = aSchema[type]?.[key] ?? '';
101
+ const bSql = bSchema[type]?.[key] ?? '';
102
+ if (canonicalize(aSql) === canonicalize(bSql))
103
+ continue;
104
+ hasDiff = true;
105
+ console.log(chalk.bold(`\ndiff --git a/${filePath} b/${filePath}`));
106
+ const patch = diffLib.createTwoFilesPatch(`a/${filePath}`, `b/${filePath}`, aSql, bSql, aLabel, bLabel);
107
+ for (const line of patch.split('\n')) {
108
+ if (line.startsWith('+') && !line.startsWith('+++')) {
109
+ process.stdout.write(chalk.green(line) + '\n');
110
+ }
111
+ else if (line.startsWith('-') && !line.startsWith('---')) {
112
+ process.stdout.write(chalk.red(line) + '\n');
113
+ }
114
+ else if (line.startsWith('@@')) {
115
+ process.stdout.write(chalk.cyan(line) + '\n');
116
+ }
117
+ else {
118
+ process.stdout.write(line + '\n');
119
+ }
120
+ }
121
+ }
122
+ }
123
+ return hasDiff;
124
+ }
125
+ // ---------------------------------------------------------------------------
126
+ // Main command
127
+ // ---------------------------------------------------------------------------
10
128
  export async function diff(argv) {
11
- // 1. Check Config
12
129
  try {
13
130
  await fs.access(CONFIG_FILE);
14
131
  }
@@ -17,109 +134,78 @@ export async function diff(argv) {
17
134
  return;
18
135
  }
19
136
  const config = JSON.parse(await fs.readFile(CONFIG_FILE, 'utf-8'));
20
- // Support new config format (branches)
21
- const currentBranch = config.branches ? config.branches[config.currentBranch] : null;
22
- const projectRef = currentBranch ? currentBranch.projectRef : config.projectRef;
137
+ const branchData = config.branches?.[config.currentBranch];
138
+ const projectRef = branchData?.projectRef ?? config.projectRef;
23
139
  if (!projectRef) {
24
- console.error(chalk.red('Project Ref not found in config.'));
140
+ console.error(chalk.red('Project ref not found in config.'));
25
141
  return;
26
142
  }
27
- console.log(chalk.blue(`Fetching current database state...`));
28
- const liveSchema = await extractSchema(projectRef);
29
- let targetSchema = { tables: {}, functions: {}, views: {}, triggers: {}, policies: {}, types: {} };
30
- let targetLabel = '';
31
- // Filter files if provided
32
143
  const filterFiles = argv.files || [];
33
- // 2. Load Target Schema (Commit or Local Files)
34
- const commitHash = argv.commit;
35
- if (commitHash) {
36
- // Compare with specific commit
37
- targetLabel = `Commit ${commitHash.substring(0, 7)}`;
144
+ // Positional args: could be one commit (vs live/local) or two commits
145
+ const args = (argv._ ?? []).filter((a) => typeof a === 'string' && a !== 'diff');
146
+ const commitArg1 = argv.commit ?? args[0];
147
+ const commitArg2 = args[1];
148
+ // ── Case 1: two commit hashes ──────────────────────────────────────────
149
+ if (commitArg1 && commitArg2) {
150
+ let hash1, hash2;
38
151
  try {
39
- const commit = await readCommit(commitHash);
40
- const tree = await readTree(commit.tree);
41
- for (const [relPath, hash] of Object.entries(tree)) {
42
- const parts = relPath.split('/');
43
- const type = parts[0];
44
- const name = path.basename(relPath, '.sql');
45
- const content = await readObject(hash);
46
- if (targetSchema[type])
47
- targetSchema[type][name] = content;
48
- }
152
+ [hash1, hash2] = await Promise.all([resolveRef(commitArg1), resolveRef(commitArg2)]);
49
153
  }
50
- catch {
51
- console.error(chalk.red(`Commit ${commitHash} not found.`));
154
+ catch (e) {
155
+ console.error(chalk.red(e.message));
52
156
  return;
53
157
  }
158
+ const [schemaA, schemaB] = await Promise.all([
159
+ loadCommitSchema(hash1),
160
+ loadCommitSchema(hash2)
161
+ ]);
162
+ const label1 = `${commitArg1} (${hash1.substring(0, 7)})`;
163
+ const label2 = `${commitArg2} (${hash2.substring(0, 7)})`;
164
+ console.log(chalk.blue(`Comparing ${label1} → ${label2}`));
165
+ const hasDiff = renderDiff(schemaA, schemaB, label1, label2, filterFiles);
166
+ if (!hasDiff)
167
+ console.log(chalk.green('\nNo differences between the two commits.'));
168
+ return;
54
169
  }
55
- else {
56
- // Compare with Local Files (equivalent to git diff)
57
- targetLabel = 'Local Files';
58
- const types = ['tables', 'functions', 'views', 'triggers', 'policies', 'types'];
59
- for (const type of types) {
60
- const dir = path.join('supabase', type);
61
- try {
62
- const files = await fs.readdir(dir);
63
- for (const file of files) {
64
- if (!file.endsWith('.sql'))
65
- continue;
66
- const name = path.basename(file, '.sql');
67
- // Un-sanitize name if needed?
68
- // In our extractor, we replace '/' with '_' for local files.
69
- // But for functions and tables they don't usually have '/'.
70
- const content = await fs.readFile(path.join(dir, file), 'utf-8');
71
- targetSchema[type][name] = content;
72
- }
73
- }
74
- catch (e) {
75
- // Directory doesn't exist, ignore
76
- }
170
+ // ── Case 2: one commit (or HEAD~N) vs live DB / local files ───────────
171
+ if (commitArg1) {
172
+ let resolvedHash;
173
+ try {
174
+ resolvedHash = await resolveRef(commitArg1);
77
175
  }
78
- }
79
- // 4. Compare and Print Diff
80
- let hasDifferences = false;
81
- const types = ['tables', 'functions', 'views', 'triggers', 'policies', 'types'];
82
- for (const type of types) {
83
- const keys = Array.from(new Set([
84
- ...Object.keys(liveSchema[type]),
85
- ...Object.keys(targetSchema[type])
86
- ])).sort();
87
- for (const key of keys) {
88
- const filePath = `${type}/${key}.sql`;
89
- if (filterFiles.length > 0 && !filterFiles.includes(filePath))
90
- continue;
91
- const liveSql = liveSchema[type][key] || '';
92
- const targetSql = targetSchema[type][key] || '';
93
- if (canonicalize(liveSql) !== canonicalize(targetSql)) {
94
- hasDifferences = true;
95
- console.log(chalk.bold(`\ndiff --git a/${type}/${key}.sql b/${type}/${key}.sql`));
96
- const patch = diffLib.createTwoFilesPatch(`a/${type}/${key}.sql`, `b/${type}/${key}.sql`, targetSql, liveSql, targetLabel, 'Live Database');
97
- // Colorize patch output, skipping the first 4 header lines usually
98
- const lines = patch.split('\n');
99
- for (let i = 0; i < lines.length; i++) {
100
- const line = lines[i];
101
- if (i < 4) {
102
- // Print headers in default or bold
103
- console.log(chalk.white(line));
104
- continue;
105
- }
106
- if (line.startsWith('+') && !line.startsWith('+++')) {
107
- console.log(chalk.green(line));
108
- }
109
- else if (line.startsWith('-') && !line.startsWith('---')) {
110
- console.log(chalk.red(line));
111
- }
112
- else if (line.startsWith('@@')) {
113
- console.log(chalk.cyan(line));
114
- }
115
- else {
116
- console.log(line);
117
- }
118
- }
119
- }
176
+ catch (e) {
177
+ console.error(chalk.red(e.message));
178
+ return;
120
179
  }
180
+ const label = `${commitArg1} (${resolvedHash.substring(0, 7)})`;
181
+ if (argv.live) {
182
+ // vs live DB
183
+ console.log(chalk.blue(`Fetching live database state...`));
184
+ const liveSchema = await extractSchema(projectRef);
185
+ const commitSchema = await loadCommitSchema(resolvedHash);
186
+ const hasDiff = renderDiff(commitSchema, liveSchema, label, 'Live Database', filterFiles);
187
+ if (!hasDiff)
188
+ console.log(chalk.green(`\nNo differences. Live DB matches ${label}.`));
189
+ }
190
+ else {
191
+ // vs local files (default for one-commit comparison)
192
+ const [commitSchema, localSchema] = await Promise.all([
193
+ loadCommitSchema(resolvedHash),
194
+ loadLocalSchema()
195
+ ]);
196
+ const hasDiff = renderDiff(commitSchema, localSchema, label, 'Local Files', filterFiles);
197
+ if (!hasDiff)
198
+ console.log(chalk.green(`\nNo differences. Local files match ${label}.`));
199
+ }
200
+ return;
121
201
  }
122
- if (!hasDifferences) {
123
- console.log(chalk.green(`\nNo differences found. Live database matches ${targetLabel} exactly.`));
124
- }
202
+ // ── Case 3: no commits — compare live DB vs local files (original behavior) ──
203
+ console.log(chalk.blue('Fetching current database state...'));
204
+ const [liveSchema, localSchema] = await Promise.all([
205
+ extractSchema(projectRef),
206
+ loadLocalSchema()
207
+ ]);
208
+ const hasDiff = renderDiff(localSchema, liveSchema, 'Local Files', 'Live Database', filterFiles);
209
+ if (!hasDiff)
210
+ console.log(chalk.green('\nNo differences found. Live database matches local files exactly.'));
125
211
  }
@@ -64,13 +64,18 @@ export async function init(argv = {}) {
64
64
  };
65
65
  await fs.writeFile(CONFIG_FILE, JSON.stringify(config, null, 2));
66
66
  console.log(chalk.green(`\nLinked to project: ${ref.trim()} (Branch: production)`));
67
- // Create supabase directory structure
67
+ // Create supabase directory structure (all tracked schema types)
68
+ await fs.mkdir('supabase/extensions', { recursive: true });
69
+ await fs.mkdir('supabase/types', { recursive: true });
70
+ await fs.mkdir('supabase/sequences', { recursive: true });
68
71
  await fs.mkdir('supabase/tables', { recursive: true });
69
- await fs.mkdir('supabase/functions', { recursive: true });
72
+ await fs.mkdir('supabase/matviews', { recursive: true });
70
73
  await fs.mkdir('supabase/views', { recursive: true });
71
- await fs.mkdir('supabase/types', { recursive: true });
74
+ await fs.mkdir('supabase/functions', { recursive: true });
72
75
  await fs.mkdir('supabase/triggers', { recursive: true });
73
76
  await fs.mkdir('supabase/policies', { recursive: true });
77
+ await fs.mkdir('supabase/grants', { recursive: true });
78
+ await fs.mkdir('supabase/publications', { recursive: true });
74
79
  // Auto-ignore .gitbase
75
80
  try {
76
81
  const gitignorePath = '.gitignore';
@@ -1,30 +1,104 @@
1
1
  import fs from 'fs/promises';
2
2
  import path from 'path';
3
3
  import chalk from 'chalk';
4
- import { readCommit } from '../storage/git.js';
4
+ import { readCommit, readTree } from '../storage/git.js';
5
5
  const GITBASE_DIR = '.gitbase';
6
6
  const HEAD_FILE = path.join(GITBASE_DIR, 'HEAD');
7
- export async function log() {
8
- let currentHash;
7
+ export async function log(argv = {}) {
8
+ let startHash = null;
9
9
  try {
10
- currentHash = await fs.readFile(HEAD_FILE, 'utf-8');
10
+ startHash = await fs.readFile(HEAD_FILE, 'utf-8');
11
11
  }
12
12
  catch {
13
13
  console.log(chalk.red('No commits yet.'));
14
14
  return;
15
15
  }
16
- console.log(chalk.cyan('Commit History:'));
16
+ if (!startHash) {
17
+ console.log(chalk.red('No commits yet.'));
18
+ return;
19
+ }
20
+ // Parse flags
21
+ const oneline = argv.oneline ?? argv['one-line'] ?? false;
22
+ const sinceStr = argv.since;
23
+ const filterFile = argv.file ?? argv._?.[0]; // e.g. tables/users.sql
24
+ const maxCount = parseInt(argv.n ?? argv['max-count'] ?? '0', 10) || 0;
25
+ // Parse --since into a Date threshold
26
+ let sinceDate = null;
27
+ if (sinceStr) {
28
+ sinceDate = new Date(sinceStr);
29
+ if (isNaN(sinceDate.getTime())) {
30
+ console.error(chalk.red(`Invalid --since value: "${sinceStr}". Use ISO date format, e.g. 2026-01-01`));
31
+ return;
32
+ }
33
+ }
34
+ if (!oneline) {
35
+ console.log(chalk.cyan('Commit History:'));
36
+ }
37
+ let currentHash = startHash;
38
+ let count = 0;
17
39
  while (currentHash) {
40
+ let commit;
18
41
  try {
19
- const commit = await readCommit(currentHash);
20
- console.log(chalk.yellow(`commit ${currentHash}`));
21
- console.log(`Author: ${commit.author}`);
22
- console.log(`Date: ${commit.timestamp}`);
23
- console.log(`\n ${commit.message}\n`);
42
+ commit = await readCommit(currentHash);
43
+ }
44
+ catch {
45
+ break;
46
+ }
47
+ const commitDate = new Date(commit.timestamp);
48
+ // --since filter
49
+ if (sinceDate && commitDate < sinceDate) {
24
50
  currentHash = commit.parent;
51
+ continue;
52
+ }
53
+ // File filter: only include commits that touched the given file
54
+ if (filterFile) {
55
+ let touched = false;
56
+ try {
57
+ const tree = await readTree(commit.tree);
58
+ touched = Object.keys(tree).some(p => p === filterFile || p.endsWith('/' + filterFile));
59
+ // Also check parent tree to detect if file actually changed
60
+ if (touched && commit.parent) {
61
+ const parentCommit = await readCommit(commit.parent).catch(() => null);
62
+ if (parentCommit) {
63
+ const parentTree = (await readTree(parentCommit.tree).catch(() => ({})));
64
+ const fileHash = tree[filterFile] ?? Object.entries(tree).find(([p]) => p.endsWith('/' + filterFile))?.[1];
65
+ const parentHash = parentTree[filterFile] ?? Object.entries(parentTree).find(([p]) => p.endsWith('/' + filterFile))?.[1];
66
+ if (fileHash === parentHash)
67
+ touched = false; // file didn't actually change
68
+ }
69
+ }
70
+ }
71
+ catch { }
72
+ if (!touched) {
73
+ currentHash = commit.parent;
74
+ continue;
75
+ }
76
+ }
77
+ // Render
78
+ const shortHash = chalk.yellow(currentHash.substring(0, 7));
79
+ if (oneline) {
80
+ console.log(`${shortHash} ${commit.message}`);
81
+ }
82
+ else {
83
+ console.log(chalk.yellow(`\ncommit ${currentHash}`));
84
+ console.log(chalk.gray(`Author: ${commit.author}`));
85
+ console.log(chalk.gray(`Date: ${commitDate.toLocaleString()}`));
86
+ console.log(`\n ${commit.message}`);
25
87
  }
26
- catch (e) {
88
+ count++;
89
+ if (maxCount > 0 && count >= maxCount)
27
90
  break;
91
+ currentHash = commit.parent;
92
+ }
93
+ if (count === 0) {
94
+ if (filterFile) {
95
+ console.log(chalk.yellow(`No commits found that touched '${filterFile}'.`));
96
+ }
97
+ else if (sinceDate) {
98
+ console.log(chalk.yellow(`No commits found since ${sinceStr}.`));
99
+ }
100
+ else {
101
+ console.log(chalk.yellow('No commits found.'));
28
102
  }
29
103
  }
30
104
  }