hologit 0.50.2 → 0.50.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.
@@ -0,0 +1,456 @@
1
+ //! holo-tree-napi: Node.js native binding for [holo-tree](../holo-tree).
2
+ //!
3
+ //! Exposes the narrow slice of holo-tree's `MutableTree` + `repo` helpers that
4
+ //! gitsheets needs for its upsert→commit path:
5
+ //!
6
+ //! ```text
7
+ //! const repo = Repo.open(gitDir)
8
+ //! const tree = repo.createTreeFromRef('HEAD') // or repo.createTree()
9
+ //! tree.writeChild(path, content) // hash blob + deep insert
10
+ //! const treeHash = tree.write() // flush dirty subtrees → ODB
11
+ //! const commitHash = repo.commitTree(treeHash, [parentHash], message)
12
+ //! repo.updateRef(refname, commitHash)
13
+ //! ```
14
+ //!
15
+ //! Conventions across the FFI boundary:
16
+ //! - **Object ids** cross as lowercase hex `String` (matches gitsheets' existing
17
+ //! hash handling — it stores/compares hashes as hex strings everywhere).
18
+ //! - **Blob content** crosses as `Buffer` (binary-safe; records are TOML/text
19
+ //! but attachments are arbitrary bytes).
20
+ //!
21
+ //! This binding is a deliberately thin pass-through. Per the spike's governing
22
+ //! principle (see `plans/holo-tree-napi-spike.md` in gitsheets), rough edges in
23
+ //! holo-tree's API are recorded as `Phase-C finding` notes and fixed upstream —
24
+ //! not papered over with cleverness here.
25
+
26
+ use napi::bindgen_prelude::*;
27
+ use napi_derive::napi;
28
+
29
+ use holo_tree::repo as ht_repo;
30
+ use holo_tree::tree::empty_tree_id;
31
+ use holo_tree::{MutableTree, ObjectId};
32
+
33
+ // ── helpers ─────────────────────────────────────────────────────────────────
34
+
35
+ /// Map a holo-tree error into a JS exception.
36
+ ///
37
+ /// Phase-C finding: `holo_tree::Error` collapses to a flat string here. gitsheets
38
+ /// needs to translate substrate failures into its typed error classes, which a
39
+ /// stringified `Display` makes lossy — candidate upstream improvement is a stable
40
+ /// error code / structured variant that survives FFI.
41
+ fn ht_err(e: holo_tree::Error) -> napi::Error {
42
+ napi::Error::from_reason(e.to_string())
43
+ }
44
+
45
+ fn parse_oid(hex: &str) -> napi::Result<ObjectId> {
46
+ ObjectId::from_hex(hex.as_bytes())
47
+ .map_err(|e| napi::Error::from_reason(format!("invalid object id '{hex}': {e}")))
48
+ }
49
+
50
+ fn oid_hex(oid: ObjectId) -> String {
51
+ oid.to_hex().to_string()
52
+ }
53
+
54
+ /// Git's well-known empty-tree hash (`4b825dc6…`).
55
+ #[napi]
56
+ pub fn empty_tree_hash() -> String {
57
+ oid_hex(empty_tree_id())
58
+ }
59
+
60
+ /// A commit identity (author or committer). `timeSeconds`/`offsetMinutes` are
61
+ /// optional; when omitted the current wall-clock time at UTC is used. Pass them
62
+ /// explicitly to reproduce a specific commit (e.g. match `git commit-tree`
63
+ /// under pinned `GIT_AUTHOR_DATE`/`GIT_COMMITTER_DATE`).
64
+ #[napi(object)]
65
+ pub struct Signature {
66
+ pub name: String,
67
+ pub email: String,
68
+ pub time_seconds: Option<i64>,
69
+ pub offset_minutes: Option<i32>,
70
+ }
71
+
72
+ fn now_secs() -> i64 {
73
+ std::time::SystemTime::now()
74
+ .duration_since(std::time::UNIX_EPOCH)
75
+ .map(|d| d.as_secs() as i64)
76
+ .unwrap_or(0)
77
+ }
78
+
79
+ /// Render git's `"<seconds> ±HHMM"` signature-time format.
80
+ fn format_git_time(seconds: i64, offset_minutes: i32) -> String {
81
+ let sign = if offset_minutes < 0 { '-' } else { '+' };
82
+ let abs = offset_minutes.unsigned_abs();
83
+ format!("{seconds} {sign}{:02}{:02}", abs / 60, abs % 60)
84
+ }
85
+
86
+ fn to_gix_signature(sig: Signature) -> napi::Result<gix::actor::Signature> {
87
+ let seconds = sig.time_seconds.unwrap_or_else(now_secs);
88
+ let time = format_git_time(seconds, sig.offset_minutes.unwrap_or(0));
89
+ gix::actor::SignatureRef {
90
+ name: sig.name.as_str().into(),
91
+ email: sig.email.as_str().into(),
92
+ time: &time,
93
+ }
94
+ .to_owned()
95
+ .map_err(|e| napi::Error::from_reason(format!("invalid signature time: {e}")))
96
+ }
97
+
98
+ // ── Repo ────────────────────────────────────────────────────────────────────
99
+
100
+ /// A handle to a git repository, backed by gix.
101
+ ///
102
+ /// Stored as a `ThreadSafeRepository` so the handle is `Send + Sync` and can be
103
+ /// cheaply cloned into each `Tree`; every call derives a thread-local
104
+ /// `gix::Repository` via `to_thread_local()`.
105
+ #[napi]
106
+ pub struct Repo {
107
+ inner: gix::ThreadSafeRepository,
108
+ }
109
+
110
+ #[napi]
111
+ impl Repo {
112
+ /// Open a repository at `gitDir` (a `.git` directory, or any path gix can
113
+ /// discover a repo from).
114
+ #[napi(factory)]
115
+ pub fn open(git_dir: String) -> napi::Result<Repo> {
116
+ let inner = gix::open(&git_dir)
117
+ .map_err(|e| {
118
+ napi::Error::from_reason(format!("failed to open repo at '{git_dir}': {e}"))
119
+ })?
120
+ .into_sync();
121
+ Ok(Repo { inner })
122
+ }
123
+
124
+ /// Resolve a ref (branch, tag, or commit hash) to its tree and return a
125
+ /// mutable, in-memory view of it.
126
+ #[napi]
127
+ pub fn create_tree_from_ref(&self, git_ref: String) -> napi::Result<Tree> {
128
+ let local = self.inner.to_thread_local();
129
+ let inner = ht_repo::create_tree_from_ref(&local, &git_ref).map_err(ht_err)?;
130
+ Ok(Tree {
131
+ repo: self.inner.clone(),
132
+ inner,
133
+ })
134
+ }
135
+
136
+ /// Create a fresh empty, mutable in-memory tree rooted at this repo.
137
+ #[napi]
138
+ pub fn create_tree(&self) -> Tree {
139
+ Tree {
140
+ repo: self.inner.clone(),
141
+ inner: MutableTree::empty(),
142
+ }
143
+ }
144
+
145
+ /// Write a commit object pointing at `treeHash` with `parents`. `author`
146
+ /// and `committer` are optional; each falls back to the repo's configured
147
+ /// identity, then a "holo-tree" default. Returns the new commit hash.
148
+ #[napi]
149
+ pub fn commit_tree(
150
+ &self,
151
+ tree_hash: String,
152
+ parents: Vec<String>,
153
+ message: String,
154
+ author: Option<Signature>,
155
+ committer: Option<Signature>,
156
+ ) -> napi::Result<String> {
157
+ let local = self.inner.to_thread_local();
158
+ let tree = parse_oid(&tree_hash)?;
159
+ let parent_oids = parents
160
+ .iter()
161
+ .map(|p| parse_oid(p))
162
+ .collect::<napi::Result<Vec<_>>>()?;
163
+ let author = author.map(to_gix_signature).transpose()?;
164
+ let committer = committer.map(to_gix_signature).transpose()?;
165
+ let commit = ht_repo::commit_tree(&local, tree, &parent_oids, &message, author, committer)
166
+ .map_err(ht_err)?;
167
+ Ok(oid_hex(commit))
168
+ }
169
+
170
+ /// Point a ref at an object hash.
171
+ ///
172
+ /// When `expectedOldHash` is provided this is a **compare-and-swap**: the
173
+ /// update only succeeds if the ref currently resolves to exactly that hash,
174
+ /// so a concurrent writer who moved the ref makes the swap fail rather than
175
+ /// silently clobbering their commit. Omit it to force the ref (the prior
176
+ /// unconditional behavior).
177
+ #[napi]
178
+ pub fn update_ref(
179
+ &self,
180
+ refname: String,
181
+ hash: String,
182
+ expected_old_hash: Option<String>,
183
+ ) -> napi::Result<()> {
184
+ let local = self.inner.to_thread_local();
185
+ let oid = parse_oid(&hash)?;
186
+ let expected = expected_old_hash.as_deref().map(parse_oid).transpose()?;
187
+ ht_repo::update_ref(&local, &refname, oid, expected).map_err(ht_err)
188
+ }
189
+
190
+ /// Resolve a ref / rev-spec (branch, tag, `HEAD`, hash, …) to its commit
191
+ /// hash, peeling annotated tags. Returns `null` when the ref does not
192
+ /// resolve — the natural "does this ref exist?" probe before a CAS
193
+ /// `updateRef`.
194
+ #[napi]
195
+ pub fn resolve_ref(&self, git_ref: String) -> napi::Result<Option<String>> {
196
+ let local = self.inner.to_thread_local();
197
+ let oid = ht_repo::resolve_ref(&local, &git_ref).map_err(ht_err)?;
198
+ Ok(oid.map(oid_hex))
199
+ }
200
+
201
+ /// Hash raw bytes as a loose blob in the ODB and return its hash, without
202
+ /// inserting it into any tree. Binary-safe.
203
+ #[napi]
204
+ pub fn write_blob(&self, content: Buffer) -> napi::Result<String> {
205
+ let local = self.inner.to_thread_local();
206
+ let oid = local
207
+ .write_blob(content.as_ref())
208
+ .map_err(|e| napi::Error::from_reason(e.to_string()))?
209
+ .detach();
210
+ Ok(oid_hex(oid))
211
+ }
212
+ }
213
+
214
+ // ── Tree read-models ─────────────────────────────────────────────────────────
215
+
216
+ /// A child entry returned by read-only navigation. `type` is `"tree"`,
217
+ /// `"blob"`, or `"commit"`; `mode` is the git filemode as a number
218
+ /// (e.g. `33188` = `0o100644`, `16384` = `0o040000` for a tree).
219
+ #[napi(object)]
220
+ pub struct ChildInfo {
221
+ pub r#type: String,
222
+ pub hash: String,
223
+ pub mode: u32,
224
+ }
225
+
226
+ /// A named child entry, returned by `getChildren`.
227
+ #[napi(object)]
228
+ pub struct NamedChildInfo {
229
+ pub name: String,
230
+ pub r#type: String,
231
+ pub hash: String,
232
+ pub mode: u32,
233
+ }
234
+
235
+ /// A blob entry in a flattened blob map, returned by `getBlobMap`. `path` is
236
+ /// relative to the navigated subtree.
237
+ #[napi(object)]
238
+ pub struct BlobEntry {
239
+ pub path: String,
240
+ pub hash: String,
241
+ pub mode: u32,
242
+ }
243
+
244
+ /// Options for `Tree.merge`. `mode` is `"overlay"`, `"replace"`, or
245
+ /// `"underlay"`; `files` is an optional list of glob patterns restricting which
246
+ /// paths merge (omit to merge everything).
247
+ #[napi(object)]
248
+ pub struct MergeOpts {
249
+ pub files: Option<Vec<String>>,
250
+ pub mode: String,
251
+ }
252
+
253
+ /// Classify a holo-tree `Child` into `(type, hash, mode)` for the read-models.
254
+ ///
255
+ /// Note: for a `Tree` child the reported `hash` is the child's *stored* tree
256
+ /// hash, which is stale if that subtree has been mutated but not yet
257
+ /// `write()`-flushed. Read-only navigation on a freshly loaded/written tree
258
+ /// reports accurate hashes.
259
+ fn classify(child: &holo_tree::Child) -> (&'static str, String, u32) {
260
+ match child {
261
+ holo_tree::Child::Tree(t) => ("tree", oid_hex(t.hash), 0o040000),
262
+ holo_tree::Child::Blob { mode, hash } => ("blob", oid_hex(*hash), u32::from(*mode)),
263
+ holo_tree::Child::Commit { hash } => ("commit", oid_hex(*hash), 0o160000),
264
+ }
265
+ }
266
+
267
+ // ── Tree ────────────────────────────────────────────────────────────────────
268
+
269
+ /// A mutable, in-memory git tree.
270
+ ///
271
+ /// Holds its own clone of the repo handle so JS callers don't thread a repo
272
+ /// argument through every call.
273
+ ///
274
+ /// Phase-C finding #1: holo-tree's `MutableTree` takes `&gix::Repository` on
275
+ /// nearly every method and keeps a *thread-local* tree cache. We smooth the
276
+ /// first half here (the handle lives on the `Tree`) but NOT the second: each
277
+ /// call does `to_thread_local()`, and whether holo-tree's thread-local cache
278
+ /// stays warm across libuv-dispatched calls is the open ergonomics question to
279
+ /// resolve upstream (e.g. a repo-bound tree handle, or an explicit session/
280
+ /// cache object the consumer owns).
281
+ #[napi]
282
+ pub struct Tree {
283
+ repo: gix::ThreadSafeRepository,
284
+ inner: MutableTree,
285
+ }
286
+
287
+ #[napi]
288
+ impl Tree {
289
+ /// Hash `content` (UTF-8 text) as a blob and insert it at `path`, creating
290
+ /// intermediate trees as needed. Returns the blob hash.
291
+ #[napi]
292
+ pub fn write_child(&mut self, path: String, content: String) -> napi::Result<String> {
293
+ let local = self.repo.to_thread_local();
294
+ let oid = self
295
+ .inner
296
+ .write_child(&local, &path, &content)
297
+ .map_err(ht_err)?;
298
+ Ok(oid_hex(oid))
299
+ }
300
+
301
+ /// Hash raw bytes as a blob and insert at `path`. Binary-safe.
302
+ #[napi]
303
+ pub fn write_child_bytes(&mut self, path: String, content: Buffer) -> napi::Result<String> {
304
+ let local = self.repo.to_thread_local();
305
+ let oid = self
306
+ .inner
307
+ .write_child_bytes(&local, &path, content.as_ref())
308
+ .map_err(ht_err)?;
309
+ Ok(oid_hex(oid))
310
+ }
311
+
312
+ /// Place an already-written blob at `path` by its `hash`, without reading its
313
+ /// bytes. Unlike `writeChildBytes` (which re-hashes content), this grafts a
314
+ /// blob already in the ODB — validated to exist and be a blob via a header
315
+ /// lookup, so a large attachment isn't read back and re-hashed. `mode` is the
316
+ /// git filemode: `0o100644` regular, `0o100755` executable, `0o120000`
317
+ /// symlink. Returns the placed hash.
318
+ #[napi]
319
+ pub fn write_child_hash(
320
+ &mut self,
321
+ path: String,
322
+ hash: String,
323
+ mode: u32,
324
+ ) -> napi::Result<String> {
325
+ let local = self.repo.to_thread_local();
326
+ let oid = parse_oid(&hash)?;
327
+ let mode = u16::try_from(mode)
328
+ .map_err(|_| napi::Error::from_reason(format!("invalid blob mode {mode:o}")))?;
329
+ self.inner
330
+ .write_child_hash(&local, &path, oid, mode)
331
+ .map_err(ht_err)?;
332
+ Ok(oid_hex(oid))
333
+ }
334
+
335
+ /// Read a blob's bytes at `path`, or `null` if no blob exists there.
336
+ #[napi]
337
+ pub fn read_blob(&mut self, path: String) -> napi::Result<Option<Buffer>> {
338
+ let local = self.repo.to_thread_local();
339
+ let bytes = self.inner.read_blob(&local, &path).map_err(ht_err)?;
340
+ Ok(bytes.map(Buffer::from))
341
+ }
342
+
343
+ /// Read-only: look up the child at a deep `path` and report its type,
344
+ /// hash, and mode, or `null` if nothing exists there.
345
+ #[napi]
346
+ pub fn get_child(&mut self, path: String) -> napi::Result<Option<ChildInfo>> {
347
+ let local = self.repo.to_thread_local();
348
+ let info = self
349
+ .inner
350
+ .get_child(&local, &path)
351
+ .map_err(ht_err)?
352
+ .map(|child| {
353
+ let (ty, hash, mode) = classify(child);
354
+ ChildInfo {
355
+ r#type: ty.to_string(),
356
+ hash,
357
+ mode,
358
+ }
359
+ });
360
+ Ok(info)
361
+ }
362
+
363
+ /// Read-only: list the direct children of the subtree at `path` (use `"."`
364
+ /// for the root). Returns an empty array if `path` is missing or not a tree.
365
+ #[napi]
366
+ pub fn get_children(&mut self, path: String) -> napi::Result<Vec<NamedChildInfo>> {
367
+ let local = self.repo.to_thread_local();
368
+ let subtree = match self.inner.get_subtree(&local, &path).map_err(ht_err)? {
369
+ Some(t) => t,
370
+ None => return Ok(vec![]),
371
+ };
372
+ subtree.ensure_children(&local).map_err(ht_err)?;
373
+ let mut out = Vec::new();
374
+ for (name, child) in subtree.children.as_ref().unwrap().iter() {
375
+ let (ty, hash, mode) = classify(child);
376
+ out.push(NamedChildInfo {
377
+ name: name.clone(),
378
+ r#type: ty.to_string(),
379
+ hash,
380
+ mode,
381
+ });
382
+ }
383
+ Ok(out)
384
+ }
385
+
386
+ /// Read-only: recursively collect every blob under the subtree at `path`
387
+ /// (defaults to the whole tree) into a flat list. Each `path` is relative
388
+ /// to the navigated subtree. Returns an empty array if `path` is missing.
389
+ #[napi]
390
+ pub fn get_blob_map(&mut self, path: Option<String>) -> napi::Result<Vec<BlobEntry>> {
391
+ let local = self.repo.to_thread_local();
392
+ let path = path.unwrap_or_else(|| ".".to_string());
393
+ let map = match self.inner.get_subtree(&local, &path).map_err(ht_err)? {
394
+ Some(subtree) => subtree.get_blob_map(&local).map_err(ht_err)?,
395
+ None => return Ok(vec![]),
396
+ };
397
+ let out = map
398
+ .into_iter()
399
+ .map(|(p, info)| BlobEntry {
400
+ path: p,
401
+ hash: oid_hex(info.hash),
402
+ mode: u32::from(info.mode),
403
+ })
404
+ .collect();
405
+ Ok(out)
406
+ }
407
+
408
+ /// Delete a child at a deep `path`. Returns whether it existed.
409
+ #[napi]
410
+ pub fn delete_child_deep(&mut self, path: String) -> napi::Result<bool> {
411
+ let local = self.repo.to_thread_local();
412
+ self.inner
413
+ .delete_child_deep(&local, &path)
414
+ .map_err(ht_err)
415
+ }
416
+
417
+ /// Clear all children under a deep `path` in O(1) — replace the subtree
418
+ /// there with the empty tree (and dirty its ancestors) without loading the
419
+ /// cleared subtree's contents. `path == "."` clears the whole tree. Used to
420
+ /// wipe a directory before a full rewrite.
421
+ #[napi]
422
+ pub fn clear_children(&mut self, path: String) -> napi::Result<()> {
423
+ let local = self.repo.to_thread_local();
424
+ self.inner.clear_children(&local, &path).map_err(ht_err)
425
+ }
426
+
427
+ /// Merge another tree into this one in place, per `options.mode`
428
+ /// (`overlay`/`replace`/`underlay`) and optional `options.files` globs.
429
+ /// `other` must be a *different* `Tree` instance.
430
+ #[napi]
431
+ pub fn merge(&mut self, other: &mut Tree, options: MergeOpts) -> napi::Result<()> {
432
+ let local = self.repo.to_thread_local();
433
+ let mode = match options.mode.as_str() {
434
+ "overlay" => holo_tree::MergeMode::Overlay,
435
+ "replace" => holo_tree::MergeMode::Replace,
436
+ "underlay" => holo_tree::MergeMode::Underlay,
437
+ other => {
438
+ return Err(napi::Error::from_reason(format!(
439
+ "invalid merge mode '{other}', expected 'overlay', 'replace', or 'underlay'"
440
+ )))
441
+ }
442
+ };
443
+ let opts = holo_tree::MergeOptions::new(options.files.as_deref(), mode).map_err(ht_err)?;
444
+ self.inner
445
+ .merge(&local, &mut other.inner, &opts, ".")
446
+ .map_err(ht_err)
447
+ }
448
+
449
+ /// Flush dirty subtrees to the ODB and return the resulting tree hash.
450
+ #[napi]
451
+ pub fn write(&mut self) -> napi::Result<String> {
452
+ let local = self.repo.to_thread_local();
453
+ let oid = self.inner.write(&local).map_err(ht_err)?;
454
+ Ok(oid_hex(oid))
455
+ }
456
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "hologit",
3
- "version": "0.50.2",
3
+ "version": "0.50.3",
4
4
  "description": "Hologit automates the projection of layered composite file trees based on flat, declarative plans",
5
5
  "repository": "https://github.com/JarvusInnovations/hologit",
6
6
  "main": "lib/index.js",