@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.
@@ -3,7 +3,10 @@ import path from 'path';
3
3
  import chalk from 'chalk';
4
4
  import readline from 'readline';
5
5
  import { readCommit, readTree, readObject } from '../storage/git.js';
6
- import { runQuery } from '../api/supabase.js';
6
+ import { runQuery, runTransaction } from '../api/supabase.js';
7
+ import { extractSchema } from '../schema/extractor.js';
8
+ import { canonicalize, hashString } from '../utils/hashing.js';
9
+ import { generateTableAlter, generateEnumAlter } from '../utils/sqlDiff.js';
7
10
  import toposort from 'toposort';
8
11
  const GITBASE_DIR = '.gitbase';
9
12
  const CONFIG_FILE = path.join(GITBASE_DIR, 'config');
@@ -13,7 +16,7 @@ export async function revert(argv) {
13
16
  await fs.access(CONFIG_FILE);
14
17
  }
15
18
  catch {
16
- console.error(chalk.red('Not initialized.'));
19
+ console.error(chalk.red('Not initialized. Run `gitb init` first.'));
17
20
  return;
18
21
  }
19
22
  const config = JSON.parse(await fs.readFile(CONFIG_FILE, 'utf-8'));
@@ -51,169 +54,294 @@ export async function revert(argv) {
51
54
  console.error(chalk.red(`Commit ${commitHash} not found.`));
52
55
  return;
53
56
  }
54
- console.log(chalk.blue(`Reverting to commit ${commitHash} (${commit.message})...`));
55
- // 3. Fetch current live state to make smart decisions
56
- console.log(chalk.blue(`Checking current database state...`));
57
- const { extractSchema } = await import('../schema/extractor.js');
57
+ console.log(chalk.blue(`\nReverting to commit ${commitHash.substring(0, 7)} ("${commit.message}")...`));
58
+ console.log(chalk.blue(`Fetching current database state...`));
58
59
  const liveSchema = await extractSchema(projectRef);
59
- // 4. Load Tree & Parse
60
+ // 3. Load stored schema from the target commit tree
60
61
  const tree = await readTree(commit.tree);
61
- const schema = { tables: {}, functions: {}, views: {}, triggers: {}, policies: {}, types: {} };
62
+ const schema = {
63
+ types: {}, sequences: {}, tables: {}, matviews: {},
64
+ views: {}, functions: {}, triggers: {}, policies: {}
65
+ };
62
66
  const filterFiles = argv.files || [];
63
67
  for (const [relPath, hash] of Object.entries(tree)) {
64
68
  if (filterFiles.length > 0 && !filterFiles.includes(relPath))
65
69
  continue;
66
70
  const parts = relPath.split('/');
67
- // relPath corresponds to file structure, e.g., "tables/users.sql"
68
71
  const type = parts[0];
69
72
  const name = path.basename(relPath, '.sql');
70
73
  const content = await readObject(hash);
71
- if (schema[type]) {
74
+ if (schema[type] !== undefined) {
72
75
  schema[type][name] = content;
73
76
  }
74
77
  }
75
- const rl = readline.createInterface({
76
- input: process.stdin,
77
- output: process.stdout
78
- });
78
+ const rl = readline.createInterface({ input: process.stdin, output: process.stdout });
79
79
  const ask = (q) => new Promise(r => rl.question(q, r));
80
- // 4. Execution Plan
81
- const { canonicalize } = await import('../utils/hashing.js');
82
- let madeDbChanges = false;
83
- // TYPES
80
+ const same = (a, b) => canonicalize(a) === canonicalize(b);
81
+ // =========================================================================
82
+ // PHASE 1: PLAN — determine all SQL changes, ask backup questions up front
83
+ // No DB writes happen in this phase.
84
+ // =========================================================================
85
+ const plan = [];
86
+ const timestamp = Math.floor(Date.now() / 1000);
87
+ // EXTENSIONS — safe to add, never drop
88
+ for (const [name, sql] of Object.entries(schema.extensions)) {
89
+ if (liveSchema.extensions[name] && same(sql, liveSchema.extensions[name]))
90
+ continue;
91
+ plan.push({ label: `Extension: ${name}`, sqls: [sql] });
92
+ }
93
+ // TYPES (enums, composites, domains)
84
94
  for (const [name, sql] of Object.entries(schema.types)) {
85
- if (liveSchema.types[name] && canonicalize(sql) === canonicalize(liveSchema.types[name])) {
95
+ if (liveSchema.types[name] && same(sql, liveSchema.types[name]))
86
96
  continue;
97
+ const isEnum = /AS ENUM/i.test(sql);
98
+ if (isEnum && liveSchema.types[name]) {
99
+ const alterStmts = generateEnumAlter(name, liveSchema.types[name], sql);
100
+ if (alterStmts !== null) {
101
+ if (alterStmts.length > 0) {
102
+ plan.push({ label: `Enum Type (ADD VALUE): ${name}`, sqls: alterStmts });
103
+ }
104
+ continue;
105
+ }
106
+ console.log(chalk.yellow(`\nEnum '${name}' has removed/reordered values — full recreation required.`));
87
107
  }
88
- madeDbChanges = true;
89
- console.log(chalk.cyan(`Restoring Type: ${name}`));
90
- await runQuery(projectRef, `DROP TYPE IF EXISTS public."${name}" CASCADE;`);
91
- await runQuery(projectRef, sql);
108
+ plan.push({
109
+ label: `Type: ${name}`,
110
+ sqls: [`DROP TYPE IF EXISTS public."${name}" CASCADE;`, sql]
111
+ });
92
112
  }
93
- // TABLES (Topological Sort)
113
+ // SEQUENCES IF NOT EXISTS to preserve sequence counter values
114
+ for (const [name, sql] of Object.entries(schema.sequences)) {
115
+ if (liveSchema.sequences[name] && same(sql, liveSchema.sequences[name]))
116
+ continue;
117
+ plan.push({ label: `Sequence: ${name}`, sqls: [sql] });
118
+ }
119
+ // TABLES — topological sort respecting FK dependencies
94
120
  const tableEdges = [];
95
121
  const tables = schema.tables;
96
122
  const tableNames = Object.keys(tables);
97
123
  for (const [name, sql] of Object.entries(tables)) {
98
- const content = sql;
99
124
  const regex = /REFERENCES\s+(?:public\.)?(\w+)/gi;
100
125
  let match;
101
- while ((match = regex.exec(content)) !== null) {
126
+ while ((match = regex.exec(sql)) !== null) {
102
127
  const target = match[1];
103
- if (target !== name && tables[target]) {
104
- tableEdges.push([target, name]); // target -> name
105
- }
128
+ if (target !== name && tables[target])
129
+ tableEdges.push([target, name]);
106
130
  }
107
131
  }
108
132
  let sortedTables = tableNames;
109
133
  try {
110
134
  sortedTables = toposort.array(tableNames, tableEdges);
111
135
  }
112
- catch (e) {
113
- console.warn(chalk.yellow('Circular dependency detected in tables. Falling back to alphabetical order.'));
136
+ catch {
137
+ console.warn(chalk.yellow('Circular dependency detected falling back to alphabetical order.'));
114
138
  sortedTables = tableNames.sort();
115
139
  }
116
140
  for (const name of sortedTables) {
117
141
  const sql = tables[name];
118
142
  if (!sql)
119
143
  continue;
120
- if (liveSchema.tables[name] && canonicalize(sql) === canonicalize(liveSchema.tables[name])) {
144
+ if (liveSchema.tables[name] && same(sql, liveSchema.tables[name]))
121
145
  continue;
122
- }
123
- madeDbChanges = true;
124
- console.log(chalk.yellow(`\nRestoring Table: ${name}`));
125
146
  if (liveSchema.tables[name]) {
126
- const answer = await ask(chalk.white(`? Keep existing '${name}' table as backup? (Y/n) > `));
127
- if (answer.toLowerCase() !== 'n') {
128
- const timestamp = Math.floor(Date.now() / 1000);
147
+ // Table exists in live DB generate ALTER statements
148
+ const alterStmts = generateTableAlter(name, liveSchema.tables[name], sql);
149
+ if (alterStmts.length === 0)
150
+ continue;
151
+ console.log(chalk.yellow(`\nTable schema differs: ${name}`));
152
+ console.log(chalk.gray(` Changes that will be applied (${alterStmts.length} statement${alterStmts.length > 1 ? 's' : ''}):`));
153
+ for (const s of alterStmts)
154
+ console.log(chalk.gray(` → ${s}`));
155
+ console.log();
156
+ const answer = await ask(chalk.white(` Apply smart ALTER in-place (data preserved) or backup + recreate?\n`) +
157
+ chalk.cyan(` [A]lter (default) `) +
158
+ chalk.yellow(`[B]ackup + recreate `) +
159
+ chalk.red(`[D]rop + recreate (data loss)`) +
160
+ chalk.white(`\n Choice [A/b/d] > `));
161
+ const choice = answer.trim().toLowerCase();
162
+ if (choice === 'b') {
129
163
  const backupName = `${name}_backup_${timestamp}`;
130
- console.log(chalk.gray(`Backing up to ${backupName}...`));
131
- try {
132
- await runQuery(projectRef, `ALTER TABLE public."${name}" RENAME TO "${backupName}";`);
133
- console.log(chalk.green('Backup successful.'));
134
- }
135
- catch (e) {
136
- console.log(chalk.red(`Backup failed: ${e.message}`));
137
- }
164
+ console.log(chalk.gray(` Will rename '${name}' → '${backupName}' then restore from snapshot.`));
165
+ plan.push({
166
+ label: `Table (backup+restore): ${name}`,
167
+ sqls: [
168
+ `ALTER TABLE public."${name}" RENAME TO "${backupName}";`,
169
+ sql
170
+ ]
171
+ });
172
+ }
173
+ else if (choice === 'd') {
174
+ console.log(chalk.red(` → Will DROP '${name}' and restore from snapshot (current data lost).`));
175
+ plan.push({
176
+ label: `Table (drop+restore): ${name}`,
177
+ sqls: [`DROP TABLE IF EXISTS public."${name}" CASCADE;`, sql]
178
+ });
138
179
  }
139
180
  else {
140
- console.log(chalk.red(`Dropping table ${name}...`));
141
- await runQuery(projectRef, `DROP TABLE IF EXISTS public."${name}" CASCADE;`);
181
+ // Default: smart ALTER — optionally with pre-transaction data snapshot
182
+ const snapshotAnswer = await ask(chalk.white(` Save a data snapshot of '${name}' before altering? (y/N) > `));
183
+ const wantsSnapshot = snapshotAnswer.trim().toLowerCase() === 'y';
184
+ const snapshotName = `${name}_snapshot_${timestamp}`;
185
+ if (wantsSnapshot) {
186
+ console.log(chalk.gray(` → Will snapshot data to '${snapshotName}', then ALTER in-place.`));
187
+ }
188
+ else {
189
+ console.log(chalk.cyan(` → Will ALTER in-place.`));
190
+ }
191
+ plan.push({
192
+ label: `Table (ALTER): ${name}`,
193
+ sqls: alterStmts,
194
+ preTransactionSqls: wantsSnapshot
195
+ ? [`CREATE TABLE public."${snapshotName}" AS SELECT * FROM public."${name}";`]
196
+ : undefined
197
+ });
142
198
  }
143
199
  }
144
200
  else {
145
- console.log(chalk.gray(`Table '${name}' does not exist in live database. Skipping backup.`));
146
- }
147
- console.log(chalk.cyan(`Creating table ${name}...`));
148
- try {
149
- await runQuery(projectRef, sql);
150
- }
151
- catch (e) {
152
- console.error(chalk.red(`Failed to create table: ${e.message}`));
201
+ // Table missing from live DB restore from snapshot
202
+ console.log(chalk.green(`\nRestoring missing table: ${name}`));
203
+ plan.push({ label: `Table (RESTORE): ${name}`, sqls: [sql] });
153
204
  }
154
205
  }
155
- // VIEWS
206
+ // MATERIALIZED VIEWS
207
+ for (const [name, sql] of Object.entries(schema.matviews)) {
208
+ if (liveSchema.matviews[name] && same(sql, liveSchema.matviews[name]))
209
+ continue;
210
+ plan.push({
211
+ label: `Materialized View: ${name}`,
212
+ sqls: [`DROP MATERIALIZED VIEW IF EXISTS public."${name}" CASCADE;`, sql]
213
+ });
214
+ }
215
+ // VIEWS — CREATE OR REPLACE, no DROP needed
156
216
  for (const [name, sql] of Object.entries(schema.views)) {
157
- if (liveSchema.views[name] && canonicalize(sql) === canonicalize(liveSchema.views[name])) {
217
+ if (liveSchema.views[name] && same(sql, liveSchema.views[name]))
158
218
  continue;
159
- }
160
- madeDbChanges = true;
161
- console.log(chalk.cyan(`Restoring View: ${name}`));
162
- await runQuery(projectRef, `DROP VIEW IF EXISTS public."${name}" CASCADE;`);
163
- await runQuery(projectRef, sql);
219
+ plan.push({ label: `View: ${name}`, sqls: [sql] });
164
220
  }
165
- // FUNCTIONS
221
+ // FUNCTIONS — CREATE OR REPLACE, no drop needed
166
222
  for (const [name, sql] of Object.entries(schema.functions)) {
167
- if (liveSchema.functions[name] && canonicalize(sql) === canonicalize(liveSchema.functions[name])) {
223
+ if (liveSchema.functions[name] && same(sql, liveSchema.functions[name]))
168
224
  continue;
169
- }
170
- madeDbChanges = true;
171
- console.log(chalk.cyan(`Restoring Function: ${name}`));
172
- await runQuery(projectRef, sql);
225
+ plan.push({ label: `Function: ${name}`, sqls: [sql] });
173
226
  }
174
227
  // TRIGGERS
175
228
  for (const [name, sql] of Object.entries(schema.triggers)) {
176
- if (liveSchema.triggers[name] && canonicalize(sql) === canonicalize(liveSchema.triggers[name])) {
229
+ if (liveSchema.triggers[name] && same(sql, liveSchema.triggers[name]))
177
230
  continue;
178
- }
179
- madeDbChanges = true;
180
- console.log(chalk.cyan(`Restoring Trigger: ${name}`));
181
231
  const match = sql.match(/ON\s+(public\.)?("?\w+"?)/i);
232
+ const triggerSqls = [];
182
233
  if (match) {
183
234
  const tableName = match[2].replace(/"/g, '');
184
- await runQuery(projectRef, `DROP TRIGGER IF EXISTS "${name}" ON public."${tableName}";`);
235
+ triggerSqls.push(`DROP TRIGGER IF EXISTS "${name}" ON public."${tableName}";`);
185
236
  }
186
- await runQuery(projectRef, sql);
237
+ triggerSqls.push(sql);
238
+ plan.push({ label: `Trigger: ${name}`, sqls: triggerSqls });
187
239
  }
188
240
  // POLICIES
189
241
  for (const [key, sql] of Object.entries(schema.policies)) {
190
- if (liveSchema.policies[key] && canonicalize(sql) === canonicalize(liveSchema.policies[key])) {
242
+ if (liveSchema.policies[key] && same(sql, liveSchema.policies[key]))
191
243
  continue;
192
- }
193
- madeDbChanges = true;
194
244
  const match = sql.match(/CREATE POLICY "([^"]+)"\s+ON\s+(public\.)?("?\w+"?)/i);
195
245
  if (match) {
196
246
  const policyName = match[1];
197
247
  const tableName = match[3].replace(/"/g, '');
198
- console.log(chalk.cyan(`Restoring Policy: ${policyName} on ${tableName}`));
199
- // Auto-enable RLS just in case the table creation didn't include it (for backwards compatibility with older commits)
248
+ plan.push({
249
+ label: `Policy: ${policyName} on ${tableName}`,
250
+ sqls: [
251
+ `ALTER TABLE public."${tableName}" ENABLE ROW LEVEL SECURITY;`,
252
+ `DROP POLICY IF EXISTS "${policyName}" ON public."${tableName}";`,
253
+ sql
254
+ ]
255
+ });
256
+ }
257
+ }
258
+ // GRANTS — REVOKE ALL then re-GRANT
259
+ for (const [key, sql] of Object.entries(schema.grants)) {
260
+ if (liveSchema.grants[key] && same(sql, liveSchema.grants[key]))
261
+ continue;
262
+ const grantSqls = [];
263
+ const isTable = key.startsWith('table__');
264
+ const isFunc = key.startsWith('function__');
265
+ const objName = key.replace(/^(table|function)__/, '');
266
+ const grantees = [...new Set((sql.match(/TO (\w+)/gi) || []).map(m => m.replace(/^TO /i, '')))];
267
+ if (grantees.length > 0) {
268
+ if (isTable) {
269
+ grantSqls.push(`REVOKE ALL PRIVILEGES ON TABLE public."${objName}" FROM ${grantees.join(', ')};`);
270
+ }
271
+ else if (isFunc) {
272
+ grantSqls.push(`REVOKE ALL PRIVILEGES ON FUNCTION public."${objName}" FROM ${grantees.join(', ')};`);
273
+ }
274
+ }
275
+ for (const grantStmt of sql.split('\n').filter(Boolean)) {
276
+ grantSqls.push(grantStmt);
277
+ }
278
+ plan.push({ label: `Grants on ${key.replace('__', ': ')}`, sqls: grantSqls });
279
+ }
280
+ // PUBLICATIONS
281
+ for (const [name, sql] of Object.entries(schema.publications)) {
282
+ if (liveSchema.publications[name] && same(sql, liveSchema.publications[name]))
283
+ continue;
284
+ plan.push({
285
+ label: `Publication: ${name}`,
286
+ sqls: [`DROP PUBLICATION IF EXISTS "${name}";`, sql]
287
+ });
288
+ }
289
+ rl.close();
290
+ if (plan.length === 0) {
291
+ console.log(chalk.green('\nNo database changes required. Live DB already matches this commit.'));
292
+ }
293
+ else {
294
+ // =====================================================================
295
+ // PHASE 2: EXECUTE — all statements in a single atomic transaction
296
+ // =====================================================================
297
+ console.log(chalk.blue(`\nApplying ${plan.length} change(s)...`));
298
+ for (const step of plan) {
299
+ const prefix = step.preTransactionSqls?.length ? '(+data snapshot) ' : '';
300
+ console.log(chalk.cyan(` → ${prefix}${step.label}`));
301
+ }
302
+ // Pre-transaction data snapshots — run outside the transaction so they
303
+ // survive even if the subsequent ALTER rolls back
304
+ const preSteps = plan.filter(p => p.preTransactionSqls?.length);
305
+ if (preSteps.length > 0) {
306
+ console.log(chalk.blue(`\nCreating ${preSteps.length} data snapshot(s)...`));
200
307
  try {
201
- await runQuery(projectRef, `ALTER TABLE public."${tableName}" ENABLE ROW LEVEL SECURITY;`);
308
+ for (const step of preSteps) {
309
+ for (const sql of step.preTransactionSqls) {
310
+ await runQuery(projectRef, sql);
311
+ console.log(chalk.gray(` ✓ ${sql}`));
312
+ }
313
+ }
314
+ }
315
+ catch (e) {
316
+ console.error(chalk.red(`\n🔥 Data snapshot failed — aborting revert.`));
317
+ console.error(chalk.red(` Reason: ${e.message}`));
318
+ return;
319
+ }
320
+ }
321
+ const allSqls = plan.flatMap(p => p.sqls);
322
+ try {
323
+ await runTransaction(projectRef, allSqls);
324
+ console.log(chalk.green('\n✅ Database reset complete!'));
325
+ if (preSteps.length > 0) {
326
+ console.log(chalk.gray(' Data snapshot table(s) preserved. Drop them manually when no longer needed.'));
202
327
  }
203
- catch (e) { }
204
- await runQuery(projectRef, `DROP POLICY IF EXISTS "${policyName}" ON public."${tableName}";`);
205
- await runQuery(projectRef, sql);
328
+ }
329
+ catch (e) {
330
+ console.error(chalk.red(`\n🔥 Revert failed — database unchanged (transaction rolled back).`));
331
+ if (preSteps.length > 0) {
332
+ console.error(chalk.yellow(` Data snapshot(s) were created before the failure and are still intact.`));
333
+ }
334
+ console.error(chalk.red(` Reason: ${e.message}`));
335
+ return;
206
336
  }
207
337
  }
208
- // 5. Update local working tree
209
- // Clear existing supabase directory safely
338
+ // 5. Update local working tree to match the commit
210
339
  if (filterFiles.length === 0) {
211
340
  try {
212
341
  await fs.rm('supabase', { recursive: true, force: true });
213
342
  }
214
- catch (e) { }
343
+ catch { }
215
344
  }
216
- // Recreate files from tree
217
345
  for (const [relPath, hash] of Object.entries(tree)) {
218
346
  if (filterFiles.length > 0 && !filterFiles.includes(relPath))
219
347
  continue;
@@ -222,12 +350,36 @@ export async function revert(argv) {
222
350
  const content = await readObject(hash);
223
351
  await fs.writeFile(fullPath, content, 'utf-8');
224
352
  }
225
- rl.close();
226
- if (!madeDbChanges) {
227
- console.log(chalk.green('\nNo database changes were required. The live database already matches this commit.'));
228
- }
229
- else {
230
- console.log(chalk.green('\nDatabase reset complete!'));
353
+ console.log(chalk.gray('Local files synchronized with reverted state.'));
354
+ // 3.3 — Create a new commit recording this revert in history
355
+ // (mirrors git revert: creates a new commit rather than moving HEAD back)
356
+ if (filterFiles.length === 0) {
357
+ try {
358
+ const revertedHash = commitHash ?? 'HEAD';
359
+ const config = JSON.parse(await fs.readFile(CONFIG_FILE, 'utf-8'));
360
+ const currentHead = await fs.readFile(path.join(GITBASE_DIR, 'HEAD'), 'utf-8').catch(() => null);
361
+ // Re-use the reverted commit's tree directly
362
+ const revertedCommit = await readCommit(Array.isArray(revertedHash) ? revertedHash : commitHash);
363
+ const revertCommitObj = {
364
+ tree: revertedCommit.tree,
365
+ parent: currentHead,
366
+ message: `Revert to ${(commitHash ?? '').substring(0, 7)}`,
367
+ timestamp: new Date().toISOString(),
368
+ author: process.env.USERNAME || process.env.USER || 'unknown'
369
+ };
370
+ const revertCommitJson = JSON.stringify(revertCommitObj, null, 2);
371
+ const revertCommitHash = hashString(revertCommitJson);
372
+ await fs.mkdir(path.join(GITBASE_DIR, 'objects'), { recursive: true });
373
+ await fs.writeFile(path.join(GITBASE_DIR, 'objects', revertCommitHash), revertCommitJson, 'utf-8');
374
+ await fs.writeFile(path.join(GITBASE_DIR, 'HEAD'), revertCommitHash, 'utf-8');
375
+ if (config.branches && config.currentBranch) {
376
+ config.branches[config.currentBranch].head = revertCommitHash;
377
+ await fs.writeFile(CONFIG_FILE, JSON.stringify(config, null, 2));
378
+ }
379
+ console.log(chalk.green(`[${revertCommitHash.substring(0, 7)}] Revert to ${(commitHash ?? '').substring(0, 7)}`));
380
+ }
381
+ catch (e) {
382
+ console.warn(chalk.yellow(`Warning: could not create revert commit: ${e.message}`));
383
+ }
231
384
  }
232
- console.log(chalk.gray('Local files have been synchronized.'));
233
385
  }
@@ -0,0 +1,49 @@
1
+ import chalk from 'chalk';
2
+ import { pull } from './pull.js';
3
+ import { commit } from './commit.js';
4
+ export async function snapshot(argv) {
5
+ const intervalMinutes = argv.interval;
6
+ const intervalMs = intervalMinutes * 60 * 1000;
7
+ console.log(chalk.blue(`Starting GitBase snapshot daemon...`));
8
+ console.log(chalk.cyan(`Interval: Every ${intervalMinutes} minutes`));
9
+ console.log(chalk.gray(`Press Ctrl+C to exit.\n`));
10
+ // Force pull to require a manual run once if we want, or just auto-run immediately
11
+ await runSnapshotCycle();
12
+ setInterval(async () => {
13
+ await runSnapshotCycle();
14
+ }, intervalMs);
15
+ }
16
+ async function runSnapshotCycle() {
17
+ console.log(chalk.gray(`[${new Date().toISOString()}] Checking for database changes...`));
18
+ try {
19
+ // Run a silent status check (returns true if changes exist)
20
+ const hasChanges = await checkStatusSilent();
21
+ if (hasChanges) {
22
+ console.log(chalk.yellow(` Changes detected in live database.`));
23
+ // 1. Pull changes
24
+ console.log(chalk.blue(` Pulling changes...`));
25
+ await pull({ _: [], $0: '' }); // Simulate argv
26
+ // 2. Commit changes
27
+ const message = `Auto-snapshot: ${new Date().toISOString()}`;
28
+ console.log(chalk.blue(` Committing: "${message}"`));
29
+ await commit({ message, _: [], $0: '' });
30
+ console.log(chalk.green(` ✅ Snapshot complete.`));
31
+ }
32
+ else {
33
+ console.log(chalk.gray(` No changes. Sleeping.`));
34
+ }
35
+ }
36
+ catch (e) {
37
+ console.error(chalk.red(` ❌ Snapshot failed: ${e.message}`));
38
+ }
39
+ }
40
+ /**
41
+ * A modified version of status that returns a boolean instead of printing to stdout,
42
+ * so we can run it quietly.
43
+ */
44
+ async function checkStatusSilent() {
45
+ const { getStatus } = await import('./status.js');
46
+ const changes = await getStatus(false);
47
+ // changes is an array like [{ type: 'modified', ... }]
48
+ return changes ? changes.length > 0 : false;
49
+ }