vault-git-sync.mjs
225 lines 7.9 KB
Raw
sha256:65ccb454656ea5acdea0a10e559b78bcde1eb6ff753ecc2911bc99d1c3d7cadd feat(calendar): enforce agent context tiers in retrieval AP… Human minor ⚠ breaking 1 day ago
1 /**
2 * Optional vault Git sync: when vault.git.auto_commit (and optionally auto_push) is enabled,
3 * run git add + commit (and push) after vault writes. Used by Hub and optionally CLI.
4 * Failures are logged but do not throw — so write/approve never fail because of git.
5 */
6
7 import fs from 'fs';
8 import path from 'path';
9 import { spawnSync } from 'child_process';
10 import { readConnection } from './github-connection.mjs';
11
12 /** Git exits 1 with this text when there is nothing new to commit after `git add`. */
13 function isBenignNoCommitOutput(text) {
14 return /nothing to commit|no changes added to commit|working tree clean/i.test(text || '');
15 }
16
17 /** Compare two remote URLs (https vs .git, trailing slash). */
18 function normalizeRemoteUrl(u) {
19 if (!u || typeof u !== 'string') return '';
20 let s = u.trim().replace(/\/+$/, '');
21 if (s.toLowerCase().endsWith('.git')) s = s.slice(0, -4);
22 return s.toLowerCase();
23 }
24
25 /**
26 * Make `origin` match Hub-configured remote. If origin already pointed elsewhere (e.g. old
27 * backup repo), pushes were going to the wrong GitHub repository while the UI showed a new URL.
28 */
29 function ensureOriginMatchesConfig(vaultPath, configuredRemote) {
30 const configured = (configuredRemote || '').trim();
31 if (!configured) throw new Error('vault.git.remote is empty.');
32 const maxBuffer = 10 * 1024 * 1024;
33 const cur = spawnSync('git', ['config', '--get', 'remote.origin.url'], {
34 cwd: vaultPath,
35 encoding: 'utf8',
36 maxBuffer,
37 });
38 if (cur.status !== 0 || !cur.stdout.trim()) {
39 spawnGit(['remote', 'add', 'origin', configured], vaultPath);
40 return configured;
41 }
42 const existing = cur.stdout.trim();
43 if (normalizeRemoteUrl(existing) !== normalizeRemoteUrl(configured)) {
44 spawnGit(['remote', 'set-url', 'origin', configured], vaultPath);
45 return configured;
46 }
47 return existing;
48 }
49
50 function spawnGit(args, cwd) {
51 const r = spawnSync('git', args, {
52 cwd,
53 encoding: 'utf8',
54 maxBuffer: 10 * 1024 * 1024,
55 });
56 if (r.error) throw r.error;
57 if (r.status !== 0) {
58 const errText = `${r.stderr || ''}\n${r.stdout || ''}`.trim();
59 throw new Error(errText || `git ${args[0]} failed (exit ${r.status})`);
60 }
61 return r;
62 }
63
64 function assertGitRepo(vaultPath) {
65 const gitDir = path.join(vaultPath, '.git');
66 if (!fs.existsSync(gitDir)) {
67 throw new Error(
68 'Vault folder is not a Git repository (no .git). From a terminal: cd to your vault path, run `git init`, add/commit once, then use Back up now. See hub/README.md (Git backup).'
69 );
70 }
71 }
72
73 /**
74 * If config has vault_git.enabled and auto_commit, run git add, commit (and push if auto_push).
75 * @param {{ vault_path: string, vault_git?: { enabled?: boolean, remote?: string, auto_commit?: boolean, auto_push?: boolean } }} config - Loaded config (vault_path, vault_git)
76 * @param {{ log?: (msg: string) => void }} options - log defaults to console.error
77 */
78 export function maybeAutoSync(config, options = {}) {
79 const log = options.log || ((msg) => console.error(msg));
80 const vg = config.vault_git;
81 if (!vg?.enabled || !vg?.auto_commit) return;
82
83 const vaultPath = config.vault_path;
84 if (!vaultPath) return;
85
86 try {
87 if (!fs.existsSync(path.join(vaultPath, '.git'))) return;
88 spawnGit(['add', '-A'], vaultPath);
89 const autoMsg = 'vault auto-sync ' + new Date().toISOString().slice(0, 19).replace('T', ' ');
90 const commitR = spawnSync('git', ['commit', '-m', autoMsg], {
91 cwd: vaultPath,
92 encoding: 'utf8',
93 maxBuffer: 10 * 1024 * 1024,
94 });
95 if (commitR.status !== 0) {
96 const out = `${commitR.stderr || ''}\n${commitR.stdout || ''}`;
97 if (!isBenignNoCommitOutput(out)) {
98 log('vault-git-sync: commit failed: ' + (out.trim() || commitR.error?.message || 'git commit'));
99 }
100 return;
101 }
102 if (vg.auto_push && vg.remote) {
103 try {
104 pushWithOptionalToken(vaultPath, vg.remote, config.data_dir);
105 } catch (e) {
106 log('vault-git-sync: push failed: ' + (e.message || e.stderr || e));
107 }
108 }
109 } catch (e) {
110 log('vault-git-sync: ' + (e.message || e));
111 }
112 }
113
114 function pushWithOptionalToken(vaultPath, remoteUrl, dataDir) {
115 const maxBuffer = 10 * 1024 * 1024;
116 const originUrl = ensureOriginMatchesConfig(vaultPath, remoteUrl);
117
118 const conn = dataDir ? readConnection(dataDir) : null;
119 const token = conn?.access_token;
120 const cleanOrigin = originUrl;
121 const useAuth = Boolean(token && originUrl.startsWith('https://'));
122
123 if (useAuth) {
124 const authUrl = originUrl.replace(/^https:\/\//, 'https://x-access-token:' + token + '@');
125 spawnGit(['remote', 'set-url', 'origin', authUrl], vaultPath);
126 }
127
128 try {
129 // `-u origin HEAD` sets upstream on first push (plain `git push` fails with "no upstream").
130 let push = spawnSync('git', ['push', '-u', 'origin', 'HEAD'], {
131 cwd: vaultPath,
132 encoding: 'utf8',
133 maxBuffer,
134 });
135 if (push.status !== 0) {
136 const errA = `${push.stderr || ''}\n${push.stdout || ''}`.trim();
137 push = spawnSync('git', ['push'], { cwd: vaultPath, encoding: 'utf8', maxBuffer });
138 if (push.status !== 0) {
139 const errB = `${push.stderr || ''}\n${push.stdout || ''}`.trim();
140 throw new Error(errB || errA || 'git push failed');
141 }
142 }
143
144 // Detect false success: push exited 0 but nothing reachable on origin (wrong remote, etc.).
145 const verify = spawnSync('git', ['ls-remote', 'origin'], {
146 cwd: vaultPath,
147 encoding: 'utf8',
148 maxBuffer,
149 });
150 if (verify.status !== 0) {
151 throw new Error(
152 `${verify.stderr || ''}\n${verify.stdout || ''}`.trim() || 'git ls-remote origin failed after push',
153 );
154 }
155 if (!(verify.stdout || '').trim()) {
156 throw new Error(
157 'Push reported success but `git ls-remote origin` returned no refs. ' +
158 'Check that Settings → Backup → Git remote URL matches the GitHub repo you opened. ' +
159 'In the vault folder run: git remote -v',
160 );
161 }
162 } finally {
163 if (useAuth) {
164 try {
165 spawnGit(['remote', 'set-url', 'origin', cleanOrigin], vaultPath);
166 } catch (_) {
167 /* avoid masking push error if restore fails */
168 }
169 }
170 }
171 }
172
173 /**
174 * Run full manual vault sync (add, commit, push). Used by CLI and Hub "Back up now".
175 * If data_dir has a stored GitHub token (Connect GitHub), uses it for push.
176 * @param {{ vault_path: string, data_dir?: string, vault_git?: { enabled?: boolean, remote?: string } }} config
177 * @returns {{ ok: true, message: string }}
178 * @throws Error if not configured or git fails
179 */
180 export function runVaultSync(config) {
181 const vg = config.vault_git;
182 if (!vg?.enabled || !vg?.remote) {
183 throw new Error('vault.git.enabled and vault.git.remote must be set in config.');
184 }
185 const vaultPath = config.vault_path;
186 if (!vaultPath) throw new Error('vault_path is required.');
187
188 assertGitRepo(vaultPath);
189 spawnGit(['add', '-A'], vaultPath);
190 const msg = 'vault sync ' + new Date().toISOString().slice(0, 10);
191 const commitR = spawnSync('git', ['commit', '-m', msg], {
192 cwd: vaultPath,
193 encoding: 'utf8',
194 maxBuffer: 10 * 1024 * 1024,
195 });
196 let committed = false;
197 if (commitR.status === 0) {
198 committed = true;
199 } else {
200 const commitOut = `${commitR.stderr || ''}\n${commitR.stdout || ''}`;
201 if (!isBenignNoCommitOutput(commitOut)) {
202 throw new Error(
203 commitOut.trim() ||
204 commitR.error?.message ||
205 `git commit failed (exit ${commitR.status}). Configure user.name and user.email in this repo if Git asks for identity.`,
206 );
207 }
208 }
209 pushWithOptionalToken(vaultPath, vg.remote, config.data_dir);
210 const sha = safeRevParse(vaultPath);
211 const message = committed
212 ? 'Synced'
213 : 'No new changes to commit; push completed (or remote already up to date).';
214 return { ok: true, committed, pushed: true, sha, message };
215 }
216
217 function safeRevParse(vaultPath) {
218 const r = spawnSync('git', ['rev-parse', 'HEAD'], {
219 cwd: vaultPath,
220 encoding: 'utf8',
221 maxBuffer: 1024 * 1024,
222 });
223 if (r.status !== 0) return null;
224 return (r.stdout || '').trim();
225 }
File History 2 commits
sha256:65ccb454656ea5acdea0a10e559b78bcde1eb6ff753ecc2911bc99d1c3d7cadd feat(calendar): enforce agent context tiers in retrieval AP… Human minor 1 day ago
sha256:9103f98c89257ed2b01c237cea895dabb3e85ea337dccb1161c175e4422355b6 docs: accept Calendar Events v0 spec with Phase 0 security … Human 2 days ago