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.
- package/Cargo.lock +103 -10
- package/Cargo.toml +1 -1
- package/holo-tree/Cargo.toml +4 -1
- package/holo-tree/src/error.rs +44 -0
- package/holo-tree/src/repo.rs +160 -24
- package/holo-tree/src/tree.rs +159 -40
- package/holo-tree/tests/clear_children.rs +95 -0
- package/holo-tree/tests/helpers/mod.rs +25 -0
- package/holo-tree/tests/repo_ops.rs +130 -0
- package/holo-tree/tests/write_child.rs +281 -0
- package/holo-tree-napi/Cargo.toml +22 -0
- package/holo-tree-napi/README.md +144 -0
- package/holo-tree-napi/build.rs +3 -0
- package/holo-tree-napi/index.d.ts +170 -0
- package/holo-tree-napi/index.js +317 -0
- package/holo-tree-napi/npm/darwin-arm64/README.md +3 -0
- package/holo-tree-napi/npm/darwin-arm64/package.json +24 -0
- package/holo-tree-napi/npm/darwin-x64/README.md +3 -0
- package/holo-tree-napi/npm/darwin-x64/package.json +24 -0
- package/holo-tree-napi/npm/linux-arm64-gnu/README.md +3 -0
- package/holo-tree-napi/npm/linux-arm64-gnu/package.json +27 -0
- package/holo-tree-napi/npm/linux-x64-gnu/README.md +3 -0
- package/holo-tree-napi/npm/linux-x64-gnu/package.json +27 -0
- package/holo-tree-napi/npm/linux-x64-musl/README.md +3 -0
- package/holo-tree-napi/npm/linux-x64-musl/package.json +27 -0
- package/holo-tree-napi/npm/win32-x64-msvc/README.md +3 -0
- package/holo-tree-napi/npm/win32-x64-msvc/package.json +24 -0
- package/holo-tree-napi/package-lock.json +89 -0
- package/holo-tree-napi/package.json +53 -0
- package/holo-tree-napi/src/lib.rs +456 -0
- package/package.json +1 -1
|
@@ -0,0 +1,281 @@
|
|
|
1
|
+
//! Regression: writing a child into a subtree that already exists in the
|
|
2
|
+
//! parent commit must load that subtree's existing children (preserving
|
|
3
|
+
//! siblings) rather than panic on `children.as_mut().unwrap()`.
|
|
4
|
+
//!
|
|
5
|
+
//! Surfaced by the gitsheets holo-tree spike (JarvusInnovations/gitsheets#127):
|
|
6
|
+
//! every upsert into an already-populated sheet directory hit this. Before the
|
|
7
|
+
//! fix, `get_or_create_subtree` left the final (lazily-loaded) node's
|
|
8
|
+
//! `children` as `None`, so `write_child_bytes` panicked — aborting the host
|
|
9
|
+
//! process when called across the napi FFI boundary.
|
|
10
|
+
|
|
11
|
+
mod helpers;
|
|
12
|
+
|
|
13
|
+
use helpers::Sandbox;
|
|
14
|
+
use holo_tree::MutableTree;
|
|
15
|
+
|
|
16
|
+
#[test]
|
|
17
|
+
fn write_child_into_existing_subtree_preserves_siblings() {
|
|
18
|
+
let sb = Sandbox::new();
|
|
19
|
+
|
|
20
|
+
// A committed tree that already contains data/a.toml.
|
|
21
|
+
let base = sb.write_tree(&[("data/a.toml", "id = 1\n")]);
|
|
22
|
+
|
|
23
|
+
// Load it lazily and write a sibling into the existing data/ directory.
|
|
24
|
+
let mut tree = MutableTree::new(base);
|
|
25
|
+
tree.write_child(&sb.repo, "data/b.toml", "id = 2\n").unwrap();
|
|
26
|
+
let new_hash = tree.write(&sb.repo).unwrap();
|
|
27
|
+
assert_ne!(new_hash, base, "writing a child must change the tree hash");
|
|
28
|
+
|
|
29
|
+
// Round-trip through the ODB: both records are present.
|
|
30
|
+
let mut reloaded = MutableTree::new(new_hash);
|
|
31
|
+
assert_eq!(
|
|
32
|
+
reloaded.read_blob(&sb.repo, "data/a.toml").unwrap().as_deref(),
|
|
33
|
+
Some(&b"id = 1\n"[..]),
|
|
34
|
+
"existing sibling must be preserved",
|
|
35
|
+
);
|
|
36
|
+
assert_eq!(
|
|
37
|
+
reloaded.read_blob(&sb.repo, "data/b.toml").unwrap().as_deref(),
|
|
38
|
+
Some(&b"id = 2\n"[..]),
|
|
39
|
+
"newly written child must be present",
|
|
40
|
+
);
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
#[test]
|
|
44
|
+
fn write_child_into_fresh_subtree_still_works() {
|
|
45
|
+
let sb = Sandbox::new();
|
|
46
|
+
|
|
47
|
+
// Empty starting point — the subtree is created fresh.
|
|
48
|
+
let mut tree = MutableTree::empty();
|
|
49
|
+
tree.write_child(&sb.repo, "data/only.toml", "id = 1\n").unwrap();
|
|
50
|
+
let hash = tree.write(&sb.repo).unwrap();
|
|
51
|
+
|
|
52
|
+
let mut reloaded = MutableTree::new(hash);
|
|
53
|
+
assert_eq!(
|
|
54
|
+
reloaded.read_blob(&sb.repo, "data/only.toml").unwrap().as_deref(),
|
|
55
|
+
Some(&b"id = 1\n"[..]),
|
|
56
|
+
);
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/// Regression: writing a file at the REPO ROOT (dir = ".") into a lazily-loaded
|
|
60
|
+
/// tree must load the root's children rather than panic on
|
|
61
|
+
/// `children.as_mut().unwrap()`. The earlier fix only covered deep paths; the
|
|
62
|
+
/// `path == "."` early-return in get_or_create_subtree bypassed it. Surfaced by
|
|
63
|
+
/// install-testing the published binding with a root-level `writeChild`.
|
|
64
|
+
#[test]
|
|
65
|
+
fn write_child_at_repo_root_preserves_siblings() {
|
|
66
|
+
let sb = Sandbox::new();
|
|
67
|
+
|
|
68
|
+
// Committed tree with a root-level file; reload it lazily (children: None).
|
|
69
|
+
let base = sb.write_tree(&[("existing.toml", "id = 0\n")]);
|
|
70
|
+
let mut tree = MutableTree::new(base);
|
|
71
|
+
|
|
72
|
+
tree.write_child(&sb.repo, "new.toml", "id = 1\n").unwrap();
|
|
73
|
+
let hash = tree.write(&sb.repo).unwrap();
|
|
74
|
+
assert_ne!(hash, base, "a root-level write must change the tree hash");
|
|
75
|
+
|
|
76
|
+
let mut reloaded = MutableTree::new(hash);
|
|
77
|
+
assert_eq!(
|
|
78
|
+
reloaded.read_blob(&sb.repo, "existing.toml").unwrap().as_deref(),
|
|
79
|
+
Some(&b"id = 0\n"[..]),
|
|
80
|
+
"existing root sibling must be preserved",
|
|
81
|
+
);
|
|
82
|
+
assert_eq!(
|
|
83
|
+
reloaded.read_blob(&sb.repo, "new.toml").unwrap().as_deref(),
|
|
84
|
+
Some(&b"id = 1\n"[..]),
|
|
85
|
+
);
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
#[test]
|
|
89
|
+
fn write_child_at_repo_root_into_empty() {
|
|
90
|
+
let sb = Sandbox::new();
|
|
91
|
+
let mut tree = MutableTree::empty();
|
|
92
|
+
tree.write_child(&sb.repo, "only.toml", "id = 1\n").unwrap();
|
|
93
|
+
let hash = tree.write(&sb.repo).unwrap();
|
|
94
|
+
|
|
95
|
+
let mut reloaded = MutableTree::new(hash);
|
|
96
|
+
assert_eq!(
|
|
97
|
+
reloaded.read_blob(&sb.repo, "only.toml").unwrap().as_deref(),
|
|
98
|
+
Some(&b"id = 1\n"[..]),
|
|
99
|
+
);
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
/// Regression: deleting a child deep inside a lazily-loaded tree must dirty the
|
|
103
|
+
/// ancestor chain, or `write()` short-circuits on a clean root and silently
|
|
104
|
+
/// drops the deletion (returns the original tree hash). Surfaced by the
|
|
105
|
+
/// gitsheets migration (#127): every Sheet delete/rename-cleanup hit this once
|
|
106
|
+
/// the working tree was binding-backed.
|
|
107
|
+
#[test]
|
|
108
|
+
fn delete_child_deep_into_existing_tree_persists() {
|
|
109
|
+
let sb = Sandbox::new();
|
|
110
|
+
|
|
111
|
+
// Committed tree with two records in a directory; reload it lazily.
|
|
112
|
+
let base = sb.write_tree(&[("data/a.toml", "id = 1\n"), ("data/b.toml", "id = 2\n")]);
|
|
113
|
+
let mut tree = MutableTree::new(base);
|
|
114
|
+
|
|
115
|
+
let deleted = tree.delete_child_deep(&sb.repo, "data/a.toml").unwrap();
|
|
116
|
+
assert!(deleted, "the record existed and should report deleted");
|
|
117
|
+
|
|
118
|
+
let new_hash = tree.write(&sb.repo).unwrap();
|
|
119
|
+
assert_ne!(new_hash, base, "deletion must change the tree hash (the bug: it didn't)");
|
|
120
|
+
|
|
121
|
+
let mut reloaded = MutableTree::new(new_hash);
|
|
122
|
+
assert!(
|
|
123
|
+
reloaded.read_blob(&sb.repo, "data/a.toml").unwrap().is_none(),
|
|
124
|
+
"deleted record must be gone after write()",
|
|
125
|
+
);
|
|
126
|
+
assert_eq!(
|
|
127
|
+
reloaded.read_blob(&sb.repo, "data/b.toml").unwrap().as_deref(),
|
|
128
|
+
Some(&b"id = 2\n"[..]),
|
|
129
|
+
"sibling must be preserved",
|
|
130
|
+
);
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
/// Deleting a non-existent deep path is a clean no-op: returns false and leaves
|
|
134
|
+
/// the tree hash unchanged.
|
|
135
|
+
#[test]
|
|
136
|
+
fn delete_child_deep_missing_is_noop() {
|
|
137
|
+
let sb = Sandbox::new();
|
|
138
|
+
let base = sb.write_tree(&[("data/a.toml", "id = 1\n")]);
|
|
139
|
+
let mut tree = MutableTree::new(base);
|
|
140
|
+
|
|
141
|
+
assert!(!tree.delete_child_deep(&sb.repo, "data/missing.toml").unwrap());
|
|
142
|
+
assert_eq!(tree.write(&sb.repo).unwrap(), base, "no-op delete must not change the hash");
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
// ── write_child_hash (#477): place an existing blob by hash ─────────────────
|
|
146
|
+
|
|
147
|
+
/// Placing a blob by hash yields a byte-identical tree to writing the same
|
|
148
|
+
/// content via `write_child_bytes` — the whole point is to skip re-hashing while
|
|
149
|
+
/// producing exactly the same result.
|
|
150
|
+
#[test]
|
|
151
|
+
fn write_child_hash_matches_write_child_bytes() {
|
|
152
|
+
let sb = Sandbox::new();
|
|
153
|
+
let content = b"attachment payload\n";
|
|
154
|
+
let blob = sb.repo.write_blob(content).unwrap().detach();
|
|
155
|
+
|
|
156
|
+
let mut by_bytes = MutableTree::empty();
|
|
157
|
+
by_bytes
|
|
158
|
+
.write_child_bytes(&sb.repo, "data/att.bin", content)
|
|
159
|
+
.unwrap();
|
|
160
|
+
let bytes_hash = by_bytes.write(&sb.repo).unwrap();
|
|
161
|
+
|
|
162
|
+
let mut by_hash = MutableTree::empty();
|
|
163
|
+
by_hash
|
|
164
|
+
.write_child_hash(&sb.repo, "data/att.bin", blob, 0o100644)
|
|
165
|
+
.unwrap();
|
|
166
|
+
let hash_hash = by_hash.write(&sb.repo).unwrap();
|
|
167
|
+
|
|
168
|
+
assert_eq!(
|
|
169
|
+
hash_hash, bytes_hash,
|
|
170
|
+
"place-by-hash must produce the same tree as write-by-content",
|
|
171
|
+
);
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
/// Placing a blob by hash into an already-populated, lazily-loaded subtree
|
|
175
|
+
/// preserves the existing siblings (same subtree-loading path as
|
|
176
|
+
/// `write_child_bytes`).
|
|
177
|
+
#[test]
|
|
178
|
+
fn write_child_hash_preserves_siblings() {
|
|
179
|
+
let sb = Sandbox::new();
|
|
180
|
+
let base = sb.write_tree(&[("data/a.toml", "id = 1\n")]);
|
|
181
|
+
let blob = sb.repo.write_blob(b"id = 2\n").unwrap().detach();
|
|
182
|
+
|
|
183
|
+
let mut tree = MutableTree::new(base);
|
|
184
|
+
tree.write_child_hash(&sb.repo, "data/b.toml", blob, 0o100644)
|
|
185
|
+
.unwrap();
|
|
186
|
+
let new_hash = tree.write(&sb.repo).unwrap();
|
|
187
|
+
assert_ne!(new_hash, base);
|
|
188
|
+
|
|
189
|
+
let mut reloaded = MutableTree::new(new_hash);
|
|
190
|
+
assert_eq!(
|
|
191
|
+
reloaded.read_blob(&sb.repo, "data/a.toml").unwrap().as_deref(),
|
|
192
|
+
Some(&b"id = 1\n"[..]),
|
|
193
|
+
"existing sibling must be preserved",
|
|
194
|
+
);
|
|
195
|
+
assert_eq!(
|
|
196
|
+
reloaded.read_blob(&sb.repo, "data/b.toml").unwrap().as_deref(),
|
|
197
|
+
Some(&b"id = 2\n"[..]),
|
|
198
|
+
"placed-by-hash blob must be present",
|
|
199
|
+
);
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
/// The `mode` argument is honored — placing the same blob as executable records
|
|
203
|
+
/// mode 100755, producing a different tree than the regular-mode placement.
|
|
204
|
+
#[test]
|
|
205
|
+
fn write_child_hash_honors_executable_mode() {
|
|
206
|
+
let sb = Sandbox::new();
|
|
207
|
+
let blob = sb.repo.write_blob(b"#!/bin/sh\n").unwrap().detach();
|
|
208
|
+
|
|
209
|
+
let mut regular = MutableTree::empty();
|
|
210
|
+
regular
|
|
211
|
+
.write_child_hash(&sb.repo, "run", blob, 0o100644)
|
|
212
|
+
.unwrap();
|
|
213
|
+
let regular_hash = regular.write(&sb.repo).unwrap();
|
|
214
|
+
|
|
215
|
+
let mut exec = MutableTree::empty();
|
|
216
|
+
exec.write_child_hash(&sb.repo, "run", blob, 0o100755)
|
|
217
|
+
.unwrap();
|
|
218
|
+
let exec_hash = exec.write(&sb.repo).unwrap();
|
|
219
|
+
|
|
220
|
+
assert_ne!(
|
|
221
|
+
regular_hash, exec_hash,
|
|
222
|
+
"executable mode must be reflected in the tree entry",
|
|
223
|
+
);
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
/// A hash that isn't in the ODB is rejected — no invalid entry is grafted.
|
|
227
|
+
#[test]
|
|
228
|
+
fn write_child_hash_rejects_missing_object() {
|
|
229
|
+
let sb = Sandbox::new();
|
|
230
|
+
// A valid-shaped sha1 that was never written to this repo's ODB.
|
|
231
|
+
let absent: gix::ObjectId = "0123456789abcdef0123456789abcdef01234567"
|
|
232
|
+
.parse()
|
|
233
|
+
.unwrap();
|
|
234
|
+
|
|
235
|
+
let mut tree = MutableTree::empty();
|
|
236
|
+
let err = tree
|
|
237
|
+
.write_child_hash(&sb.repo, "x", absent, 0o100644)
|
|
238
|
+
.unwrap_err();
|
|
239
|
+
assert!(matches!(err, holo_tree::Error::Git(_)), "got {err:?}");
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
/// A hash pointing at a non-blob object (here a tree) is rejected rather than
|
|
243
|
+
/// silently producing a tree entry that claims a blob mode over a tree object.
|
|
244
|
+
#[test]
|
|
245
|
+
fn write_child_hash_rejects_non_blob() {
|
|
246
|
+
let sb = Sandbox::new();
|
|
247
|
+
let tree_oid = sb.write_tree(&[("inner.toml", "id = 1\n")]);
|
|
248
|
+
|
|
249
|
+
let mut tree = MutableTree::empty();
|
|
250
|
+
let err = tree
|
|
251
|
+
.write_child_hash(&sb.repo, "x", tree_oid, 0o100644)
|
|
252
|
+
.unwrap_err();
|
|
253
|
+
match err {
|
|
254
|
+
holo_tree::Error::Git(msg) => assert!(
|
|
255
|
+
msg.contains("not a blob"),
|
|
256
|
+
"expected a not-a-blob error, got: {msg}",
|
|
257
|
+
),
|
|
258
|
+
other => panic!("expected Git error, got {other:?}"),
|
|
259
|
+
}
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
/// An invalid (non-blob) mode is rejected up front, before any tree mutation —
|
|
263
|
+
/// it would otherwise panic later in `write()` when mapped to an `EntryMode`.
|
|
264
|
+
#[test]
|
|
265
|
+
fn write_child_hash_rejects_invalid_mode() {
|
|
266
|
+
let sb = Sandbox::new();
|
|
267
|
+
let blob = sb.repo.write_blob(b"x\n").unwrap().detach();
|
|
268
|
+
|
|
269
|
+
let mut tree = MutableTree::empty();
|
|
270
|
+
// 040000 is a tree mode, not valid for a blob entry.
|
|
271
|
+
let err = tree
|
|
272
|
+
.write_child_hash(&sb.repo, "x", blob, 0o040000)
|
|
273
|
+
.unwrap_err();
|
|
274
|
+
assert!(matches!(err, holo_tree::Error::Git(_)), "got {err:?}");
|
|
275
|
+
// The tree must be untouched by a rejected call.
|
|
276
|
+
assert_eq!(
|
|
277
|
+
tree.write(&sb.repo).unwrap(),
|
|
278
|
+
MutableTree::empty().write(&sb.repo).unwrap(),
|
|
279
|
+
"a rejected write_child_hash must not mutate the tree",
|
|
280
|
+
);
|
|
281
|
+
}
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
[package]
|
|
2
|
+
name = "holo-tree-napi"
|
|
3
|
+
version = "0.1.0"
|
|
4
|
+
edition = "2021"
|
|
5
|
+
license = "MIT"
|
|
6
|
+
description = "Node.js native binding for holo-tree — mutable in-memory git trees via gix"
|
|
7
|
+
publish = false
|
|
8
|
+
|
|
9
|
+
[lib]
|
|
10
|
+
crate-type = ["cdylib"]
|
|
11
|
+
|
|
12
|
+
[dependencies]
|
|
13
|
+
# Node-API floor of 6 covers Node.js >= 20 comfortably.
|
|
14
|
+
napi = { version = "2", default-features = false, features = ["napi6"] }
|
|
15
|
+
napi-derive = "2"
|
|
16
|
+
holo-tree = { path = "../holo-tree" }
|
|
17
|
+
# gix defaults only — the binding does no networking, so we avoid the TLS/
|
|
18
|
+
# OpenSSL pull-in of `blocking-network-client` to keep cross-compilation clean.
|
|
19
|
+
gix = { version = "0.83" }
|
|
20
|
+
|
|
21
|
+
[build-dependencies]
|
|
22
|
+
napi-build = "2"
|
|
@@ -0,0 +1,144 @@
|
|
|
1
|
+
# holo-tree-napi
|
|
2
|
+
|
|
3
|
+
Node.js native binding for [`holo-tree`](../holo-tree) — mutable in-memory git
|
|
4
|
+
trees via [gitoxide](https://github.com/GitoxideLabs/gitoxide), with no `git`
|
|
5
|
+
subprocess.
|
|
6
|
+
|
|
7
|
+
This crate exposes the narrow slice of holo-tree that record-oriented consumers
|
|
8
|
+
(notably [gitsheets](https://github.com/JarvusInnovations/gitsheets)) need for an
|
|
9
|
+
upsert→commit path. It is intentionally a thin pass-through; it is also the first
|
|
10
|
+
integrated consumer of the new Rust libs, so it doubles as a hardening vehicle —
|
|
11
|
+
rough edges in holo-tree's API are recorded as `Phase-C finding` notes in the
|
|
12
|
+
source and fixed upstream rather than worked around here.
|
|
13
|
+
|
|
14
|
+
## API
|
|
15
|
+
|
|
16
|
+
```js
|
|
17
|
+
const { Repo, emptyTreeHash } = require('@hologit/holo-tree');
|
|
18
|
+
|
|
19
|
+
const repo = Repo.open('/path/to/repo/.git');
|
|
20
|
+
|
|
21
|
+
const tree = repo.createTreeFromRef('HEAD'); // or repo.createTree() for empty
|
|
22
|
+
tree.writeChild('data/widgets/1.toml', 'id = 1\n'); // hash blob + deep insert
|
|
23
|
+
const treeHash = tree.write(); // flush dirty subtrees → ODB, returns tree hash
|
|
24
|
+
|
|
25
|
+
const commit = repo.commitTree(treeHash, [parentHash], 'add widget 1');
|
|
26
|
+
repo.updateRef('refs/heads/main', commit);
|
|
27
|
+
|
|
28
|
+
const bytes = repo.createTreeFromRef(commit).readBlob('data/widgets/1.toml');
|
|
29
|
+
```
|
|
30
|
+
|
|
31
|
+
Conventions: object ids cross the boundary as lowercase 40-char hex strings;
|
|
32
|
+
blob content crosses as `Buffer` (binary-safe).
|
|
33
|
+
|
|
34
|
+
### Surface
|
|
35
|
+
|
|
36
|
+
| Method | holo-tree call | Notes |
|
|
37
|
+
|---|---|---|
|
|
38
|
+
| `Repo.open(gitDir)` | `gix::open().into_sync()` | factory |
|
|
39
|
+
| `repo.createTreeFromRef(ref)` → `Tree` | `repo::create_tree_from_ref` | resolves ref→commit→tree |
|
|
40
|
+
| `repo.createTree()` → `Tree` | `MutableTree::empty` | |
|
|
41
|
+
| `repo.commitTree(treeHash, parents[], msg)` → hash | `repo::commit_tree` | uses git-config identity |
|
|
42
|
+
| `repo.updateRef(ref, hash, expectedOldHash?)` | `repo::update_ref` | compare-and-swap when `expectedOldHash` given; force otherwise |
|
|
43
|
+
| `repo.resolveRef(ref)` → `hash\|null` | `repo::resolve_ref` | peels tags; `null` if unresolved |
|
|
44
|
+
| `repo.writeBlob(buf)` → hash | `gix write_blob` | hash bytes into the ODB, no tree |
|
|
45
|
+
| `tree.writeChild(path, text)` → hash | `MutableTree::write_child` | UTF-8 text |
|
|
46
|
+
| `tree.writeChildBytes(path, buf)` → hash | `MutableTree::write_child_bytes` | binary |
|
|
47
|
+
| `tree.readBlob(path)` → `Buffer\|null` | `MutableTree::read_blob` | |
|
|
48
|
+
| `tree.getChild(path)` → `{type,hash,mode}\|null` | `MutableTree::get_child` | read-only; deep path |
|
|
49
|
+
| `tree.getChildren(path)` → `[{name,type,hash,mode}]` | `get_subtree`+`ensure_children` | read-only; direct children |
|
|
50
|
+
| `tree.getBlobMap(path?)` → `[{path,hash,mode}]` | `get_subtree`+`get_blob_map` | read-only; paths relative to subtree |
|
|
51
|
+
| `tree.deleteChildDeep(path)` → bool | `MutableTree::delete_child_deep` | |
|
|
52
|
+
| `tree.clearChildren(path)` | `MutableTree::clear_children` | O(1) subtree wipe |
|
|
53
|
+
| `tree.merge(other, {files?, mode})` | `MutableTree::merge` | `mode`: `overlay`/`replace`/`underlay` |
|
|
54
|
+
| `tree.write()` → treeHash | `MutableTree::write` | |
|
|
55
|
+
| `emptyTreeHash()` → hash | `tree::empty_tree_id` | module fn |
|
|
56
|
+
|
|
57
|
+
`mode` values are the git filemode as a number (e.g. `33188` = `0o100644`). Tree
|
|
58
|
+
hashes reported by the read-only navigators reflect the last `write()`/load and
|
|
59
|
+
are stale for a subtree mutated since — flush with `write()` for canonical
|
|
60
|
+
hashes.
|
|
61
|
+
|
|
62
|
+
## Building
|
|
63
|
+
|
|
64
|
+
Requires a Rust toolchain and `@napi-rs/cli` (a devDependency):
|
|
65
|
+
|
|
66
|
+
```sh
|
|
67
|
+
npm install
|
|
68
|
+
npm run build:debug # or: npm run build (release)
|
|
69
|
+
npm test # node --test against a scratch git repo
|
|
70
|
+
```
|
|
71
|
+
|
|
72
|
+
`napi build` emits `holo-tree.<triple>.node`. The generated `index.js` loader
|
|
73
|
+
and `index.d.ts` types **are committed**; only the `.node` binaries are
|
|
74
|
+
git-ignored (built per-platform in CI).
|
|
75
|
+
|
|
76
|
+
## Publishing
|
|
77
|
+
|
|
78
|
+
Published as the scoped package **`@hologit/holo-tree`** with per-platform
|
|
79
|
+
prebuilt binaries shipped as `optionalDependencies`:
|
|
80
|
+
|
|
81
|
+
| Platform package | Triple | Built on | Smoke-tested |
|
|
82
|
+
| --- | --- | --- | --- |
|
|
83
|
+
| `@hologit/holo-tree-linux-x64-gnu` | `x86_64-unknown-linux-gnu` | ubuntu-latest | ✓ native |
|
|
84
|
+
| `@hologit/holo-tree-linux-arm64-gnu` | `aarch64-unknown-linux-gnu` | ubuntu-24.04-arm | ✓ native |
|
|
85
|
+
| `@hologit/holo-tree-linux-x64-musl` | `x86_64-unknown-linux-musl` | ubuntu-latest (musl cross) | build-only |
|
|
86
|
+
| `@hologit/holo-tree-darwin-arm64` | `aarch64-apple-darwin` | macos-latest | ✓ native |
|
|
87
|
+
| `@hologit/holo-tree-darwin-x64` | `x86_64-apple-darwin` | macos-latest (cross) | build-only |
|
|
88
|
+
| `@hologit/holo-tree-win32-x64-msvc` | `x86_64-pc-windows-msvc` | windows-latest | ✓ native |
|
|
89
|
+
|
|
90
|
+
Native targets build + smoke-test on a matching runner; cross targets (musl,
|
|
91
|
+
darwin-x64) build only, since their `.node` can't run on the host arch/libc (the
|
|
92
|
+
logic is covered by the native runs). The `.github/workflows/holo-tree-napi.yml`
|
|
93
|
+
workflow builds all six on every PR touching the binding, and on a
|
|
94
|
+
`holo-tree-v*` tag it builds then publishes.
|
|
95
|
+
|
|
96
|
+
Auth is **npm trusted publishing (OIDC)** — no tokens, matching hologit's
|
|
97
|
+
`publish-npm.yml`. Trusted publishing is configured *per package*, and a package
|
|
98
|
+
can't get a trusted publisher until it exists — so the four packages need a
|
|
99
|
+
**one-time manual bootstrap** before automated releases work.
|
|
100
|
+
|
|
101
|
+
### One-time bootstrap (manual first publish, then configure trusted publishing)
|
|
102
|
+
|
|
103
|
+
The four packages all start at an early version (currently `0.0.1`). They must
|
|
104
|
+
exist on npm before trusted publishing can be turned on.
|
|
105
|
+
|
|
106
|
+
1. **Get the prebuilt binaries.** Run the `holo-tree-napi` workflow (push the
|
|
107
|
+
branch / open a PR, or trigger `workflow_dispatch`) and download its three
|
|
108
|
+
`bindings-*` artifacts — they hold the `.node` for each platform. A single
|
|
109
|
+
machine can't build all three natively, so use the CI artifacts.
|
|
110
|
+
|
|
111
|
+
2. **Publish all four manually**, logged in as an `@hologit` org member
|
|
112
|
+
(`npm login`):
|
|
113
|
+
|
|
114
|
+
```sh
|
|
115
|
+
cd holo-tree-napi
|
|
116
|
+
npm install
|
|
117
|
+
npx napi artifacts --dir <downloaded-artifacts-dir> # → npm/<triple>/*.node
|
|
118
|
+
# platform packages first, then the main package:
|
|
119
|
+
for d in npm/*/ ; do ( cd "$d" && npm publish --access public ); done
|
|
120
|
+
npm publish --access public --ignore-scripts # main; skip the napi
|
|
121
|
+
# prepublish hook
|
|
122
|
+
```
|
|
123
|
+
|
|
124
|
+
3. **Turn on trusted publishing** on npmjs.com for **each** of the four packages
|
|
125
|
+
→ Settings → Trusted Publisher → GitHub Actions, repo
|
|
126
|
+
`JarvusInnovations/hologit`, workflow `holo-tree-napi.yml`.
|
|
127
|
+
|
|
128
|
+
### Releases (after bootstrap — fully automated, tokenless)
|
|
129
|
+
|
|
130
|
+
```sh
|
|
131
|
+
git tag holo-tree-v0.1.1 && git push origin holo-tree-v0.1.1
|
|
132
|
+
```
|
|
133
|
+
|
|
134
|
+
The tag drives the published version; CI builds all three platforms, then
|
|
135
|
+
publishes via OIDC (provenance). No secret needed. The `holo-tree-v*` tag is the
|
|
136
|
+
release marker — napi runs with `--skip-gh-release` so it does **not** create a
|
|
137
|
+
bare `v<version>` GitHub release/tag (which would collide with hologit's own
|
|
138
|
+
`v*` JS-package release namespace).
|
|
139
|
+
|
|
140
|
+
To add or drop a platform later, edit `napi.triples.additional` +
|
|
141
|
+
`optionalDependencies` in `package.json`, run `napi create-npm-dir -t .`, add the
|
|
142
|
+
matching matrix entry in the workflow, and (since it's a new package) bootstrap
|
|
143
|
+
|
|
144
|
+
+ trust that one package too.
|
|
@@ -0,0 +1,170 @@
|
|
|
1
|
+
/* tslint:disable */
|
|
2
|
+
/* eslint-disable */
|
|
3
|
+
|
|
4
|
+
/* auto-generated by NAPI-RS */
|
|
5
|
+
|
|
6
|
+
/** Git's well-known empty-tree hash (`4b825dc6…`). */
|
|
7
|
+
export declare function emptyTreeHash(): string
|
|
8
|
+
/**
|
|
9
|
+
* A commit identity (author or committer). `timeSeconds`/`offsetMinutes` are
|
|
10
|
+
* optional; when omitted the current wall-clock time at UTC is used. Pass them
|
|
11
|
+
* explicitly to reproduce a specific commit (e.g. match `git commit-tree`
|
|
12
|
+
* under pinned `GIT_AUTHOR_DATE`/`GIT_COMMITTER_DATE`).
|
|
13
|
+
*/
|
|
14
|
+
export interface Signature {
|
|
15
|
+
name: string
|
|
16
|
+
email: string
|
|
17
|
+
timeSeconds?: number
|
|
18
|
+
offsetMinutes?: number
|
|
19
|
+
}
|
|
20
|
+
/**
|
|
21
|
+
* A child entry returned by read-only navigation. `type` is `"tree"`,
|
|
22
|
+
* `"blob"`, or `"commit"`; `mode` is the git filemode as a number
|
|
23
|
+
* (e.g. `33188` = `0o100644`, `16384` = `0o040000` for a tree).
|
|
24
|
+
*/
|
|
25
|
+
export interface ChildInfo {
|
|
26
|
+
type: string
|
|
27
|
+
hash: string
|
|
28
|
+
mode: number
|
|
29
|
+
}
|
|
30
|
+
/** A named child entry, returned by `getChildren`. */
|
|
31
|
+
export interface NamedChildInfo {
|
|
32
|
+
name: string
|
|
33
|
+
type: string
|
|
34
|
+
hash: string
|
|
35
|
+
mode: number
|
|
36
|
+
}
|
|
37
|
+
/**
|
|
38
|
+
* A blob entry in a flattened blob map, returned by `getBlobMap`. `path` is
|
|
39
|
+
* relative to the navigated subtree.
|
|
40
|
+
*/
|
|
41
|
+
export interface BlobEntry {
|
|
42
|
+
path: string
|
|
43
|
+
hash: string
|
|
44
|
+
mode: number
|
|
45
|
+
}
|
|
46
|
+
/**
|
|
47
|
+
* Options for `Tree.merge`. `mode` is `"overlay"`, `"replace"`, or
|
|
48
|
+
* `"underlay"`; `files` is an optional list of glob patterns restricting which
|
|
49
|
+
* paths merge (omit to merge everything).
|
|
50
|
+
*/
|
|
51
|
+
export interface MergeOpts {
|
|
52
|
+
files?: Array<string>
|
|
53
|
+
mode: string
|
|
54
|
+
}
|
|
55
|
+
/**
|
|
56
|
+
* A handle to a git repository, backed by gix.
|
|
57
|
+
*
|
|
58
|
+
* Stored as a `ThreadSafeRepository` so the handle is `Send + Sync` and can be
|
|
59
|
+
* cheaply cloned into each `Tree`; every call derives a thread-local
|
|
60
|
+
* `gix::Repository` via `to_thread_local()`.
|
|
61
|
+
*/
|
|
62
|
+
export declare class Repo {
|
|
63
|
+
/**
|
|
64
|
+
* Open a repository at `gitDir` (a `.git` directory, or any path gix can
|
|
65
|
+
* discover a repo from).
|
|
66
|
+
*/
|
|
67
|
+
static open(gitDir: string): Repo
|
|
68
|
+
/**
|
|
69
|
+
* Resolve a ref (branch, tag, or commit hash) to its tree and return a
|
|
70
|
+
* mutable, in-memory view of it.
|
|
71
|
+
*/
|
|
72
|
+
createTreeFromRef(gitRef: string): Tree
|
|
73
|
+
/** Create a fresh empty, mutable in-memory tree rooted at this repo. */
|
|
74
|
+
createTree(): Tree
|
|
75
|
+
/**
|
|
76
|
+
* Write a commit object pointing at `treeHash` with `parents`. `author`
|
|
77
|
+
* and `committer` are optional; each falls back to the repo's configured
|
|
78
|
+
* identity, then a "holo-tree" default. Returns the new commit hash.
|
|
79
|
+
*/
|
|
80
|
+
commitTree(treeHash: string, parents: Array<string>, message: string, author?: Signature | undefined | null, committer?: Signature | undefined | null): string
|
|
81
|
+
/**
|
|
82
|
+
* Point a ref at an object hash.
|
|
83
|
+
*
|
|
84
|
+
* When `expectedOldHash` is provided this is a **compare-and-swap**: the
|
|
85
|
+
* update only succeeds if the ref currently resolves to exactly that hash,
|
|
86
|
+
* so a concurrent writer who moved the ref makes the swap fail rather than
|
|
87
|
+
* silently clobbering their commit. Omit it to force the ref (the prior
|
|
88
|
+
* unconditional behavior).
|
|
89
|
+
*/
|
|
90
|
+
updateRef(refname: string, hash: string, expectedOldHash?: string | undefined | null): void
|
|
91
|
+
/**
|
|
92
|
+
* Resolve a ref / rev-spec (branch, tag, `HEAD`, hash, …) to its commit
|
|
93
|
+
* hash, peeling annotated tags. Returns `null` when the ref does not
|
|
94
|
+
* resolve — the natural "does this ref exist?" probe before a CAS
|
|
95
|
+
* `updateRef`.
|
|
96
|
+
*/
|
|
97
|
+
resolveRef(gitRef: string): string | null
|
|
98
|
+
/**
|
|
99
|
+
* Hash raw bytes as a loose blob in the ODB and return its hash, without
|
|
100
|
+
* inserting it into any tree. Binary-safe.
|
|
101
|
+
*/
|
|
102
|
+
writeBlob(content: Buffer): string
|
|
103
|
+
}
|
|
104
|
+
/**
|
|
105
|
+
* A mutable, in-memory git tree.
|
|
106
|
+
*
|
|
107
|
+
* Holds its own clone of the repo handle so JS callers don't thread a repo
|
|
108
|
+
* argument through every call.
|
|
109
|
+
*
|
|
110
|
+
* Phase-C finding #1: holo-tree's `MutableTree` takes `&gix::Repository` on
|
|
111
|
+
* nearly every method and keeps a *thread-local* tree cache. We smooth the
|
|
112
|
+
* first half here (the handle lives on the `Tree`) but NOT the second: each
|
|
113
|
+
* call does `to_thread_local()`, and whether holo-tree's thread-local cache
|
|
114
|
+
* stays warm across libuv-dispatched calls is the open ergonomics question to
|
|
115
|
+
* resolve upstream (e.g. a repo-bound tree handle, or an explicit session/
|
|
116
|
+
* cache object the consumer owns).
|
|
117
|
+
*/
|
|
118
|
+
export declare class Tree {
|
|
119
|
+
/**
|
|
120
|
+
* Hash `content` (UTF-8 text) as a blob and insert it at `path`, creating
|
|
121
|
+
* intermediate trees as needed. Returns the blob hash.
|
|
122
|
+
*/
|
|
123
|
+
writeChild(path: string, content: string): string
|
|
124
|
+
/** Hash raw bytes as a blob and insert at `path`. Binary-safe. */
|
|
125
|
+
writeChildBytes(path: string, content: Buffer): string
|
|
126
|
+
/**
|
|
127
|
+
* Place an already-written blob at `path` by its `hash`, without reading its
|
|
128
|
+
* bytes. Unlike `writeChildBytes` (which re-hashes content), this grafts a
|
|
129
|
+
* blob already in the ODB — validated to exist and be a blob via a header
|
|
130
|
+
* lookup, so a large attachment isn't read back and re-hashed. `mode` is the
|
|
131
|
+
* git filemode: `0o100644` regular, `0o100755` executable, `0o120000`
|
|
132
|
+
* symlink. Returns the placed hash.
|
|
133
|
+
*/
|
|
134
|
+
writeChildHash(path: string, hash: string, mode: number): string
|
|
135
|
+
/** Read a blob's bytes at `path`, or `null` if no blob exists there. */
|
|
136
|
+
readBlob(path: string): Buffer | null
|
|
137
|
+
/**
|
|
138
|
+
* Read-only: look up the child at a deep `path` and report its type,
|
|
139
|
+
* hash, and mode, or `null` if nothing exists there.
|
|
140
|
+
*/
|
|
141
|
+
getChild(path: string): ChildInfo | null
|
|
142
|
+
/**
|
|
143
|
+
* Read-only: list the direct children of the subtree at `path` (use `"."`
|
|
144
|
+
* for the root). Returns an empty array if `path` is missing or not a tree.
|
|
145
|
+
*/
|
|
146
|
+
getChildren(path: string): Array<NamedChildInfo>
|
|
147
|
+
/**
|
|
148
|
+
* Read-only: recursively collect every blob under the subtree at `path`
|
|
149
|
+
* (defaults to the whole tree) into a flat list. Each `path` is relative
|
|
150
|
+
* to the navigated subtree. Returns an empty array if `path` is missing.
|
|
151
|
+
*/
|
|
152
|
+
getBlobMap(path?: string | undefined | null): Array<BlobEntry>
|
|
153
|
+
/** Delete a child at a deep `path`. Returns whether it existed. */
|
|
154
|
+
deleteChildDeep(path: string): boolean
|
|
155
|
+
/**
|
|
156
|
+
* Clear all children under a deep `path` in O(1) — replace the subtree
|
|
157
|
+
* there with the empty tree (and dirty its ancestors) without loading the
|
|
158
|
+
* cleared subtree's contents. `path == "."` clears the whole tree. Used to
|
|
159
|
+
* wipe a directory before a full rewrite.
|
|
160
|
+
*/
|
|
161
|
+
clearChildren(path: string): void
|
|
162
|
+
/**
|
|
163
|
+
* Merge another tree into this one in place, per `options.mode`
|
|
164
|
+
* (`overlay`/`replace`/`underlay`) and optional `options.files` globs.
|
|
165
|
+
* `other` must be a *different* `Tree` instance.
|
|
166
|
+
*/
|
|
167
|
+
merge(other: Tree, options: MergeOpts): void
|
|
168
|
+
/** Flush dirty subtrees to the ODB and return the resulting tree hash. */
|
|
169
|
+
write(): string
|
|
170
|
+
}
|