Skip to content

Commit ccd6627

Browse files
feat: store mark state in git notes and git config
- Always write git note (txo URI) on marked commits - Store latest txo in gitmark.txo git config instead of .git/blocktrails.json - Add gitmark.dirty flag (default true) to control blocktrails.json updates - When dirty=false, verify/info/mark reconstruct trail from git notes - Backwards compatible: loadPrivateState falls back to legacy .git/blocktrails.json Closes #26
1 parent a488c86 commit ccd6627

2 files changed

Lines changed: 86 additions & 17 deletions

File tree

bin/git-mark.js

Lines changed: 65 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -205,11 +205,55 @@ function saveTrail(trail) {
205205
writeFileSync(TRAIL_FILE, JSON.stringify(trail, null, 2) + '\n');
206206
}
207207
function loadPrivateState() {
208-
if (!existsSync(PRIVATE_FILE)) return null;
209-
return JSON.parse(readFileSync(PRIVATE_FILE, 'utf8'));
208+
// Try git config first, fall back to legacy file
209+
try {
210+
const txoUri = gitExec('git config --local gitmark.txo');
211+
const parsed = parseTxoUri(txoUri);
212+
return { txid: parsed.txid, vout: parsed.vout, amount: parsed.amount };
213+
} catch {
214+
if (!existsSync(PRIVATE_FILE)) return null;
215+
return JSON.parse(readFileSync(PRIVATE_FILE, 'utf8'));
216+
}
217+
}
218+
function savePrivateState(state, chain, head) {
219+
const txoUri = `txo:${chain}:${state.txid}:${state.vout}?amount=${state.amount}${head ? '&commit=' + head : ''}`;
220+
gitExec(`git config --local gitmark.txo ${txoUri}`);
221+
}
222+
function isDirty() {
223+
try { return gitExec('git config --local gitmark.dirty') !== 'false'; } catch { return true; }
224+
}
225+
function addGitNote(commitHash, note) {
226+
try { gitExec(`git notes add -f -m ${note} ${commitHash}`); } catch { /* ignore if no commits */ }
227+
}
228+
function loadTrailFromNotes() {
229+
const trail = loadTrail();
230+
if (!trail) return null;
231+
try {
232+
const notesList = gitExec('git notes list');
233+
if (!notesList) return trail;
234+
const notedCommits = new Set(notesList.split('\n').filter(Boolean).map(l => l.split(' ')[1]));
235+
const allCommits = gitExec('git log --reverse --format=%H').split('\n').filter(Boolean);
236+
const states = [];
237+
const txos = [];
238+
for (const commit of allCommits) {
239+
if (!notedCommits.has(commit)) continue;
240+
try {
241+
const note = gitExec(`git notes show ${commit}`);
242+
if (note.startsWith('txo:')) {
243+
states.push(commit);
244+
txos.push(note);
245+
}
246+
} catch { continue; }
247+
}
248+
trail.states = states;
249+
trail.txo = txos;
250+
return trail;
251+
} catch {
252+
return trail;
253+
}
210254
}
211-
function savePrivateState(state) {
212-
writeFileSync(PRIVATE_FILE, JSON.stringify(state, null, 2) + '\n');
255+
function loadFullTrail() {
256+
return isDirty() ? loadTrail() : loadTrailFromNotes();
213257
}
214258

215259
// --- Parse TXO URI ---
@@ -289,7 +333,7 @@ async function cmdInit(args) {
289333
);
290334
const newTxid = await broadcastTx(rawTx, explorer);
291335

292-
savePrivateState({ txid: newTxid, vout: 0, amount: outputAmount });
336+
savePrivateState({ txid: newTxid, vout: 0, amount: outputAmount }, chain);
293337
console.log(`Funded: ${outputAmount} sats (txid: ${newTxid})`);
294338
}
295339

@@ -300,14 +344,14 @@ async function cmdInit(args) {
300344
console.log(`Base public key: ${pubkey}`);
301345
console.log(`Chain: ${chain}`);
302346
console.log(`Address: ${pubkeyToAddress(pubkey, [], chain)}`);
303-
if (!existsSync(PRIVATE_FILE) && voucherIdx === -1) {
347+
if (!loadPrivateState() && voucherIdx === -1) {
304348
console.log(`\nUnfunded. Use: git mark init --voucher txo:${chain}:txid:vout?amount=X&key=Y`);
305349
console.log(`Or send sats to: ${pubkeyToAddress(pubkey, [], chain)}`);
306350
}
307351
}
308352

309353
async function cmdMark(args) {
310-
const trail = loadTrail();
354+
const trail = loadFullTrail();
311355
if (!trail) { console.error(`No ${TRAIL_FILE} found. Run: git mark init`); process.exit(1); }
312356
const priv = loadPrivateState();
313357
if (!priv) { console.error('No funding. Run: git mark init --voucher txo:...'); process.exit(1); }
@@ -353,13 +397,18 @@ async function cmdMark(args) {
353397
);
354398
const newTxid = await broadcastTx(rawTx, explorer);
355399

356-
// Update trail
357-
trail.states.push(head);
358-
trail.txo.push(`txo:${chain}:${newTxid}:0?commit=${head}`);
359-
saveTrail(trail);
400+
const txoUri = `txo:${chain}:${newTxid}:0?amount=${outputAmount}&commit=${head}`;
401+
402+
// Always: git notes + git config
403+
addGitNote(head, txoUri);
404+
savePrivateState({ txid: newTxid, vout: 0, amount: outputAmount }, chain, head);
360405

361-
// Update private state
362-
savePrivateState({ txid: newTxid, vout: 0, amount: outputAmount });
406+
// Update trail file if dirty mode
407+
trail.states.push(head);
408+
trail.txo.push(txoUri);
409+
if (isDirty()) {
410+
saveTrail(trail);
411+
}
363412

364413
const address = pubkeyToAddress(trail.publicKeyBase, allStates, chain);
365414
console.log(`Marked: ${head.slice(0, 8)}${newTxid.slice(0, 16)}...`);
@@ -369,7 +418,7 @@ async function cmdMark(args) {
369418
}
370419

371420
async function cmdInfo() {
372-
const trail = loadTrail();
421+
const trail = loadFullTrail();
373422
if (!trail) { console.error(`No ${TRAIL_FILE} found.`); process.exit(1); }
374423
const priv = loadPrivateState();
375424

@@ -393,7 +442,7 @@ async function cmdInfo() {
393442
}
394443

395444
async function cmdVerify() {
396-
const trail = loadTrail();
445+
const trail = loadFullTrail();
397446
if (!trail) { console.error(`No ${TRAIL_FILE} found.`); process.exit(1); }
398447
if (trail.states.length === 0) { console.log('No marks to verify.'); return; }
399448

@@ -437,7 +486,7 @@ async function cmdVerify() {
437486
export {
438487
taggedHash, btScalar, deriveChainedPrivkey, deriveChainedPubkey,
439488
pubkeyToAddress, parseTxoUri, p2trScript, buildTransaction,
440-
TRAIL_FILE, PRIVATE_FILE, CHAINS
489+
TRAIL_FILE, PRIVATE_FILE, CHAINS, isDirty, loadTrailFromNotes, loadFullTrail
441490
};
442491

443492
// --- CLI ---

test/git-mark.test.js

Lines changed: 21 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@ import { bytesToHex, hexToBytes } from '@noble/hashes/utils';
55

66
import {
77
taggedHash, btScalar, deriveChainedPrivkey, deriveChainedPubkey,
8-
pubkeyToAddress, parseTxoUri, p2trScript, CHAINS
8+
pubkeyToAddress, parseTxoUri, p2trScript, CHAINS, isDirty, loadFullTrail
99
} from '../bin/git-mark.js';
1010

1111
describe('Key chaining', () => {
@@ -188,4 +188,24 @@ describe('Trail format', () => {
188188
};
189189
assert.strictEqual(trail.states.length, trail.txo.length);
190190
});
191+
192+
it('txo URIs include amount and commit params', () => {
193+
const txoUri = 'txo:tbtc4:abc123:0?amount=9700&commit=deadbeef';
194+
const parsed = parseTxoUri(txoUri);
195+
assert.strictEqual(parsed.chain, 'tbtc4');
196+
assert.strictEqual(parsed.txid, 'abc123');
197+
assert.strictEqual(parsed.amount, 9700);
198+
});
199+
});
200+
201+
describe('Dirty flag', () => {
202+
it('isDirty returns true by default (no config set)', () => {
203+
assert.strictEqual(isDirty(), true);
204+
});
205+
206+
it('loadFullTrail returns null when no trail file exists', () => {
207+
// In test context there's no blocktrails.json, so should return null
208+
const trail = loadFullTrail();
209+
assert.strictEqual(trail, null);
210+
});
191211
});

0 commit comments

Comments
 (0)