sticky-note-cli 2.5.0

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.
@@ -0,0 +1,337 @@
1
+ /**
2
+ * sticky-git-notes.js — Git Notes utilities for Sticky Note attribution
3
+ *
4
+ * Stores session/thread attribution on commits using native Git Notes
5
+ * under refs/notes/sticky-note. Notes survive rebase/amend when
6
+ * notes.rewriteRef is configured (see configureRewriteRef).
7
+ *
8
+ * SPDX-License-Identifier: Apache-2.0
9
+ */
10
+
11
+ "use strict";
12
+
13
+ const { execFileSync } = require("child_process");
14
+ const path = require("path");
15
+
16
+ const NOTES_REF = "refs/notes/sticky-note";
17
+ const NOTE_TIMEOUT_MS = 3000;
18
+
19
+ // ── Write ────────────────────────────────────────────────
20
+
21
+ /**
22
+ * Write (or append to) a Git Note on a commit.
23
+ * Data is stored as a JSON array — each call appends an entry.
24
+ */
25
+ function writeNote(sha, data) {
26
+ if (!sha || !data) return false;
27
+ try {
28
+ const existing = readNote(sha);
29
+ const entries = Array.isArray(existing) ? existing : existing ? [existing] : [];
30
+ entries.push(data);
31
+ const json = JSON.stringify(entries);
32
+ execFileSync("git", ["notes", "--ref", NOTES_REF, "add", "-f", "-m", json, sha], {
33
+ encoding: "utf-8",
34
+ timeout: NOTE_TIMEOUT_MS,
35
+ stdio: ["pipe", "pipe", "pipe"],
36
+ });
37
+ return true;
38
+ } catch (_) {
39
+ return false;
40
+ }
41
+ }
42
+
43
+ /**
44
+ * Write a single Git Note (overwrite existing).
45
+ */
46
+ function writeNoteFull(sha, data) {
47
+ if (!sha || !data) return false;
48
+ try {
49
+ const json = JSON.stringify(data);
50
+ execFileSync("git", ["notes", "--ref", NOTES_REF, "add", "-f", "-m", json, sha], {
51
+ encoding: "utf-8",
52
+ timeout: NOTE_TIMEOUT_MS,
53
+ stdio: ["pipe", "pipe", "pipe"],
54
+ });
55
+ return true;
56
+ } catch (_) {
57
+ return false;
58
+ }
59
+ }
60
+
61
+ // ── Read ─────────────────────────────────────────────────
62
+
63
+ /**
64
+ * Read the Git Note on a commit. Returns parsed JSON or null.
65
+ */
66
+ function readNote(sha) {
67
+ if (!sha) return null;
68
+ try {
69
+ const raw = execFileSync("git", ["notes", "--ref", NOTES_REF, "show", sha], {
70
+ encoding: "utf-8",
71
+ timeout: NOTE_TIMEOUT_MS,
72
+ stdio: ["pipe", "pipe", "pipe"],
73
+ }).trim();
74
+ if (!raw) return null;
75
+ return JSON.parse(raw);
76
+ } catch (_) {
77
+ return null;
78
+ }
79
+ }
80
+
81
+ /**
82
+ * Aggregate notes from multiple SHAs into one array.
83
+ */
84
+ function aggregateNotes(shas) {
85
+ const results = [];
86
+ for (const sha of shas) {
87
+ const note = readNote(sha);
88
+ if (!note) continue;
89
+ if (Array.isArray(note)) {
90
+ results.push(...note);
91
+ } else {
92
+ results.push(note);
93
+ }
94
+ }
95
+ return results;
96
+ }
97
+
98
+ // ── Git Blame ────────────────────────────────────────────
99
+
100
+ /**
101
+ * Run git blame --line-porcelain on a file and parse the output.
102
+ * Returns: { lines: { lineNum: sha }, shas: Set<sha> }
103
+ */
104
+ function gitBlame(file) {
105
+ try {
106
+ const raw = execFileSync("git", ["blame", "--line-porcelain", file], {
107
+ encoding: "utf-8",
108
+ timeout: 10000,
109
+ stdio: ["pipe", "pipe", "pipe"],
110
+ });
111
+ return parseBlameOutput(raw);
112
+ } catch (_) {
113
+ return null;
114
+ }
115
+ }
116
+
117
+ /**
118
+ * Parse git blame --line-porcelain output.
119
+ * Each block starts with: <sha> <orig_line> <final_line> [num_lines]
120
+ */
121
+ function parseBlameOutput(raw) {
122
+ const lines = {};
123
+ const shas = new Set();
124
+ let currentSha = null;
125
+ let currentLine = null;
126
+
127
+ for (const line of raw.split(/\r?\n/)) {
128
+ // Lines starting with a 40-char hex SHA begin a new block
129
+ const headerMatch = line.match(/^([0-9a-f]{40})\s+\d+\s+(\d+)/);
130
+ if (headerMatch) {
131
+ currentSha = headerMatch[1];
132
+ currentLine = parseInt(headerMatch[2], 10);
133
+ lines[currentLine] = currentSha;
134
+ shas.add(currentSha);
135
+ }
136
+ }
137
+ return { lines, shas: Array.from(shas) };
138
+ }
139
+
140
+ /**
141
+ * Get SHAs for a specific line range from blame data.
142
+ */
143
+ function getShasForLineRange(blameData, startLine, endLine) {
144
+ if (!blameData || !blameData.lines) return [];
145
+ const result = new Set();
146
+ for (let i = startLine; i <= endLine; i++) {
147
+ if (blameData.lines[i]) {
148
+ result.add(blameData.lines[i]);
149
+ }
150
+ }
151
+ return Array.from(result);
152
+ }
153
+
154
+ // ── Configuration ────────────────────────────────────────
155
+
156
+ /**
157
+ * Configure Git to preserve sticky-note notes on rebase/amend.
158
+ * Returns true if configuration succeeded.
159
+ */
160
+ function configureRewriteRef() {
161
+ try {
162
+ execFileSync("git", ["config", "notes.rewriteRef", NOTES_REF], {
163
+ encoding: "utf-8",
164
+ timeout: NOTE_TIMEOUT_MS,
165
+ stdio: ["pipe", "pipe", "pipe"],
166
+ });
167
+ execFileSync("git", ["config", "notes.rewrite.rebase", "true"], {
168
+ encoding: "utf-8",
169
+ timeout: NOTE_TIMEOUT_MS,
170
+ stdio: ["pipe", "pipe", "pipe"],
171
+ });
172
+ execFileSync("git", ["config", "notes.rewrite.amend", "true"], {
173
+ encoding: "utf-8",
174
+ timeout: NOTE_TIMEOUT_MS,
175
+ stdio: ["pipe", "pipe", "pipe"],
176
+ });
177
+ return true;
178
+ } catch (_) {
179
+ return false;
180
+ }
181
+ }
182
+
183
+ /**
184
+ * Check if notes.rewriteRef is already configured.
185
+ */
186
+ function isRewriteConfigured() {
187
+ try {
188
+ const val = execFileSync("git", ["config", "--get", "notes.rewriteRef"], {
189
+ encoding: "utf-8",
190
+ timeout: NOTE_TIMEOUT_MS,
191
+ stdio: ["pipe", "pipe", "pipe"],
192
+ }).trim();
193
+ return val.includes("sticky-note");
194
+ } catch (_) {
195
+ return false;
196
+ }
197
+ }
198
+
199
+ /**
200
+ * Configure git remote to push/fetch sticky-note notes.
201
+ */
202
+ function configureRemoteNotes(remote = "origin") {
203
+ try {
204
+ // Add fetch refspec for notes
205
+ try {
206
+ execFileSync("git", ["config", "--add", `remote.${remote}.fetch`, `+${NOTES_REF}:${NOTES_REF}`], {
207
+ encoding: "utf-8",
208
+ timeout: NOTE_TIMEOUT_MS,
209
+ stdio: ["pipe", "pipe", "pipe"],
210
+ });
211
+ } catch (_) { /* may already exist */ }
212
+
213
+ // Add push refspec for notes
214
+ try {
215
+ execFileSync("git", ["config", "--add", `remote.${remote}.push`, `+${NOTES_REF}:${NOTES_REF}`], {
216
+ encoding: "utf-8",
217
+ timeout: NOTE_TIMEOUT_MS,
218
+ stdio: ["pipe", "pipe", "pipe"],
219
+ });
220
+ } catch (_) { /* may already exist */ }
221
+
222
+ return true;
223
+ } catch (_) {
224
+ return false;
225
+ }
226
+ }
227
+
228
+ // ── Post-Rewrite Support ─────────────────────────────────
229
+
230
+ /**
231
+ * Copy notes from old SHAs to new SHAs.
232
+ * Input: array of { oldSha, newSha } pairs (from post-rewrite hook stdin).
233
+ */
234
+ function copyNotesForRewrite(pairs) {
235
+ let copied = 0;
236
+ for (const { oldSha, newSha } of pairs) {
237
+ const note = readNote(oldSha);
238
+ if (note) {
239
+ if (writeNoteFull(newSha, note)) copied++;
240
+ }
241
+ }
242
+ return copied;
243
+ }
244
+
245
+ /**
246
+ * Parse post-rewrite hook stdin: "old_sha new_sha\n" pairs.
247
+ */
248
+ function parsePostRewriteInput(stdin) {
249
+ const pairs = [];
250
+ for (const line of stdin.split(/\r?\n/)) {
251
+ const parts = line.trim().split(/\s+/);
252
+ if (parts.length >= 2 && /^[0-9a-f]{40}$/.test(parts[0]) && /^[0-9a-f]{40}$/.test(parts[1])) {
253
+ pairs.push({ oldSha: parts[0], newSha: parts[1] });
254
+ }
255
+ }
256
+ return pairs;
257
+ }
258
+
259
+ // ── Checkpoint Support ───────────────────────────────────
260
+
261
+ /**
262
+ * Read the current checkpoint from .sticky-checkpoint file.
263
+ */
264
+ function getCurrentCheckpoint() {
265
+ try {
266
+ const fs = require("fs");
267
+ const stickyDir = path.join(process.cwd(), ".sticky-note");
268
+ const cpFile = path.join(stickyDir, ".sticky-checkpoint");
269
+ if (!fs.existsSync(cpFile)) return null;
270
+ return JSON.parse(fs.readFileSync(cpFile, "utf-8"));
271
+ } catch (_) {
272
+ return null;
273
+ }
274
+ }
275
+
276
+ /**
277
+ * Save a checkpoint.
278
+ */
279
+ function saveCheckpoint(data) {
280
+ try {
281
+ const fs = require("fs");
282
+ const stickyDir = path.join(process.cwd(), ".sticky-note");
283
+ if (!fs.existsSync(stickyDir)) fs.mkdirSync(stickyDir, { recursive: true });
284
+ const cpFile = path.join(stickyDir, ".sticky-checkpoint");
285
+ fs.writeFileSync(cpFile, JSON.stringify(data), "utf-8");
286
+ return true;
287
+ } catch (_) {
288
+ return false;
289
+ }
290
+ }
291
+
292
+ /**
293
+ * Clear the current checkpoint.
294
+ */
295
+ function clearCheckpoint() {
296
+ try {
297
+ const fs = require("fs");
298
+ const cpFile = path.join(process.cwd(), ".sticky-note", ".sticky-checkpoint");
299
+ if (fs.existsSync(cpFile)) fs.unlinkSync(cpFile);
300
+ return true;
301
+ } catch (_) {
302
+ return false;
303
+ }
304
+ }
305
+
306
+ // ── Exports ──────────────────────────────────────────────
307
+
308
+ module.exports = {
309
+ NOTES_REF,
310
+
311
+ // Write
312
+ writeNote,
313
+ writeNoteFull,
314
+
315
+ // Read
316
+ readNote,
317
+ aggregateNotes,
318
+
319
+ // Git Blame
320
+ gitBlame,
321
+ parseBlameOutput,
322
+ getShasForLineRange,
323
+
324
+ // Configuration
325
+ configureRewriteRef,
326
+ isRewriteConfigured,
327
+ configureRemoteNotes,
328
+
329
+ // Post-Rewrite
330
+ copyNotesForRewrite,
331
+ parsePostRewriteInput,
332
+
333
+ // Checkpoint
334
+ getCurrentCheckpoint,
335
+ saveCheckpoint,
336
+ clearCheckpoint,
337
+ };