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.
@@ -324,57 +324,49 @@ impl MutableTree {
324
324
 
325
325
  /// Navigate to a subtree, creating intermediate empty trees as needed.
326
326
  ///
327
- /// **Marks all ancestors dirty** when any new node is created, matching
328
- /// the JS `getSubtreeStack(path, create=true)` behavior.
327
+ /// This is the **mutating** navigator: callers reach for it to change the
328
+ /// destination (write a child, insert a gitlink, …). So every node along
329
+ /// the path — root included — is marked dirty, because each one's
330
+ /// serialized form will change once `write()` propagates the mutation back
331
+ /// up. (The earlier behavior only marked the path dirty when a *new* node
332
+ /// was created, which silently dropped writes into an already-existing
333
+ /// directory: the leaf was dirtied but its clean ancestors short-circuited
334
+ /// in `write()`.)
329
335
  pub fn get_or_create_subtree(
330
336
  &mut self,
331
337
  repo: &gix::Repository,
332
338
  path: &str,
333
339
  ) -> Result<&mut MutableTree> {
334
340
  if path == "." || path.is_empty() {
335
- return Ok(self);
336
- }
337
-
338
- let parts: Vec<&str> = path.split('/').collect();
339
-
340
- // First pass: detect whether any node needs to be created.
341
- let mut needs_create = false;
342
- {
343
- let mut check = &mut *self;
344
- for part in &parts {
345
- check.ensure_children(repo)?;
346
- if !check.children.as_ref().unwrap().contains_key(*part) {
347
- needs_create = true;
348
- break;
349
- }
350
- match check.children.as_mut().unwrap().get_mut(*part) {
351
- Some(Child::Tree(ref mut t)) => check = t,
352
- _ => break,
353
- }
354
- }
355
- }
356
-
357
- if needs_create {
341
+ // Destination is the root itself. Mark dirty and load its children
342
+ // so a mutating caller (e.g. write_child_bytes for a repo-root file
343
+ // like "a.toml", whose dir is ".") can insert alongside existing
344
+ // entries rather than panic on `children.as_mut().unwrap()` when
345
+ // the root was lazily loaded from a ref. Same postcondition as the
346
+ // deep-path return below.
358
347
  self.dirty = true;
348
+ self.ensure_children(repo)?;
349
+ return Ok(self);
359
350
  }
360
351
 
361
- // Second pass: create missing nodes.
352
+ self.dirty = true;
362
353
  let mut cur = self;
363
- for part in parts {
354
+ for part in path.split('/') {
364
355
  cur.ensure_children(repo)?;
365
- let children = cur.children.as_mut().unwrap();
366
-
367
- let child = children.entry(part.to_string()).or_insert_with(|| {
368
- let mut t = MutableTree::empty();
369
- t.dirty = true;
370
- Child::Tree(t)
371
- });
356
+ let child = cur
357
+ .children
358
+ .as_mut()
359
+ .unwrap()
360
+ .entry(part.to_string())
361
+ .or_insert_with(|| {
362
+ let mut t = MutableTree::empty();
363
+ t.dirty = true;
364
+ Child::Tree(t)
365
+ });
372
366
 
373
367
  cur = match child {
374
368
  Child::Tree(ref mut t) => {
375
- if needs_create {
376
- t.dirty = true;
377
- }
369
+ t.dirty = true;
378
370
  t
379
371
  }
380
372
  _ => {
@@ -385,6 +377,14 @@ impl MutableTree {
385
377
  }
386
378
  };
387
379
  }
380
+ // Postcondition: the returned node has its children loaded. Navigation
381
+ // above only ensures children on nodes it descends *through*; a final
382
+ // node that already exists in the parent tree is lazily loaded
383
+ // (`children: None`). Callers that mutate the returned node (e.g.
384
+ // `write_child_bytes`) rely on `children` being `Some` — and loading
385
+ // here is also what preserves existing siblings when writing into an
386
+ // existing directory.
387
+ cur.ensure_children(repo)?;
388
388
  Ok(cur)
389
389
  }
390
390
 
@@ -507,6 +507,68 @@ impl MutableTree {
507
507
  Ok(blob_id)
508
508
  }
509
509
 
510
+ /// Place an already-written blob at a deep path by its hash, without reading
511
+ /// its bytes.
512
+ ///
513
+ /// Unlike [`write_child_bytes`](Self::write_child_bytes), which takes blob
514
+ /// *content* and writes (re-hashes) it, this grafts a blob that already
515
+ /// exists in the ODB. A consumer that holds a content-addressed blob hash —
516
+ /// because it wrote the blob earlier, or received the hash from elsewhere —
517
+ /// can place it without handing over the bytes again. `mode` is the git entry
518
+ /// mode for the blob: `0o100644` (regular), `0o100755` (executable), or
519
+ /// `0o120000` (symlink).
520
+ ///
521
+ /// The object is validated to exist and be a blob via an object *header*
522
+ /// lookup, which does not decode the blob payload — so placing a large
523
+ /// attachment stays independent of its byte size rather than paying a full
524
+ /// ODB read + re-hash.
525
+ ///
526
+ /// # Errors
527
+ ///
528
+ /// Returns an error if `hash` does not exist in the ODB or is not a blob, if
529
+ /// `mode` is not a valid blob mode, or if an intermediate path component
530
+ /// exists but is not a tree (same footgun guard as
531
+ /// [`write_child_bytes`](Self::write_child_bytes)).
532
+ pub fn write_child_hash(
533
+ &mut self,
534
+ repo: &gix::Repository,
535
+ path: &str,
536
+ hash: ObjectId,
537
+ mode: u16,
538
+ ) -> Result<()> {
539
+ if !matches!(mode, 0o100644 | 0o100755 | 0o120000) {
540
+ return Err(Error::Git(format!(
541
+ "invalid blob mode {mode:o} for {path}: expected 100644, 100755, or 120000"
542
+ )));
543
+ }
544
+
545
+ // Validate existence + kind from the object header, without decoding the
546
+ // blob — that byte-read is exactly what a place-by-hash caller wants to
547
+ // skip.
548
+ let header = repo
549
+ .find_header(hash)
550
+ .map_err(|e| Error::Git(e.to_string()))?;
551
+ if header.kind() != gix::object::Kind::Blob {
552
+ return Err(Error::Git(format!(
553
+ "object {hash} is not a blob (found {:?})",
554
+ header.kind()
555
+ )));
556
+ }
557
+
558
+ let (dir, name) = match path.rsplit_once('/') {
559
+ Some((d, f)) => (d, f),
560
+ None => (".", path),
561
+ };
562
+
563
+ let tree = self.get_or_create_subtree(repo, dir)?;
564
+ tree.children
565
+ .as_mut()
566
+ .unwrap()
567
+ .insert(name.to_string(), Child::Blob { mode, hash });
568
+ tree.dirty = true;
569
+ Ok(())
570
+ }
571
+
510
572
  /// Delete a child at a deep slash-separated path (not just a direct child).
511
573
  pub fn delete_child_deep(
512
574
  &mut self,
@@ -518,10 +580,67 @@ impl MutableTree {
518
580
  None => return self.delete_child(repo, path),
519
581
  };
520
582
 
521
- match self.get_subtree(repo, dir)? {
522
- Some(tree) => tree.delete_child(repo, name),
523
- None => Ok(false),
583
+ // Mark the path to `dir` dirty as we descend: removing a descendant
584
+ // changes every ancestor's serialized form, so all must be rewritten by
585
+ // write(). The previous read-only `get_subtree` navigation dirtied only
586
+ // the leaf dir, so a clean root short-circuited in write() and the
587
+ // deletion was silently dropped. (If the path doesn't fully exist there
588
+ // is nothing to delete; the few nodes dirtied along the way re-serialize
589
+ // to identical hashes, so over-dirtying on a miss is harmless.)
590
+ self.dirty = true;
591
+ let mut cur = self;
592
+ for part in dir.split('/') {
593
+ if part.is_empty() || part == "." {
594
+ continue;
595
+ }
596
+ cur.ensure_children(repo)?;
597
+ match cur.children.as_mut().unwrap().get_mut(part) {
598
+ Some(Child::Tree(t)) => {
599
+ t.dirty = true;
600
+ cur = t;
601
+ }
602
+ _ => return Ok(false),
603
+ }
604
+ }
605
+ cur.delete_child(repo, name)
606
+ }
607
+
608
+ /// Clear all children under a deep `path`, replacing the subtree there
609
+ /// with the empty tree and marking the ancestor chain dirty.
610
+ ///
611
+ /// This is an **O(1)** clear: it does not load the cleared subtree's own
612
+ /// contents from the ODB — only the ancestor trees needed to navigate to
613
+ /// its parent. Intermediate trees along `path` are created if absent; since
614
+ /// an empty subtree is pruned on `write()`, clearing a path that doesn't
615
+ /// exist is a no-op in the written result.
616
+ ///
617
+ /// `path == "."` (or empty) clears the root tree itself.
618
+ pub fn clear_children(&mut self, repo: &gix::Repository, path: &str) -> Result<()> {
619
+ if path == "." || path.is_empty() {
620
+ self.children = Some(BTreeMap::new());
621
+ self.hash = empty_tree_id();
622
+ self.dirty = true;
623
+ return Ok(());
524
624
  }
625
+
626
+ let (dir, name) = match path.rsplit_once('/') {
627
+ Some((d, f)) => (d, f),
628
+ None => (".", path),
629
+ };
630
+
631
+ // get_or_create_subtree marks the whole navigated path (root included)
632
+ // dirty, which is exactly what we need: every ancestor's serialized
633
+ // form changes once the cleared subtree is pruned on write().
634
+ let parent = self.get_or_create_subtree(repo, dir)?;
635
+ let mut empty = MutableTree::empty();
636
+ empty.dirty = true;
637
+ parent
638
+ .children
639
+ .as_mut()
640
+ .unwrap()
641
+ .insert(name.to_string(), Child::Tree(empty));
642
+ parent.dirty = true;
643
+ Ok(())
525
644
  }
526
645
 
527
646
  /// Recursively collect all blobs into a flat map.
@@ -0,0 +1,95 @@
1
+ //! Tests for `MutableTree::clear_children` — the O(1) subtree clear that
2
+ //! gitsheets uses to wipe a sheet directory before a full rewrite.
3
+
4
+ mod helpers;
5
+
6
+ use helpers::Sandbox;
7
+ use holo_tree::MutableTree;
8
+
9
+ fn list_tree(repo: &gix::Repository, hash: gix::ObjectId) -> Vec<String> {
10
+ let mut tree = MutableTree::new(hash);
11
+ let mut out = Vec::new();
12
+ for (path, _) in tree.get_blob_map(repo).unwrap() {
13
+ out.push(path);
14
+ }
15
+ out.sort();
16
+ out
17
+ }
18
+
19
+ #[test]
20
+ fn clears_a_deep_subtree_but_preserves_siblings() {
21
+ let sb = Sandbox::new();
22
+ let base = sb.write_tree(&[
23
+ ("data/widgets/1.toml", "id = 1\n"),
24
+ ("data/widgets/2.toml", "id = 2\n"),
25
+ ("data/gadgets/9.toml", "id = 9\n"),
26
+ ("README.md", "hi\n"),
27
+ ]);
28
+
29
+ let mut tree = MutableTree::new(base);
30
+ tree.clear_children(&sb.repo, "data/widgets").unwrap();
31
+ let hash = tree.write(&sb.repo).unwrap();
32
+
33
+ assert_ne!(hash, base, "clearing a populated subtree must change the hash");
34
+ assert_eq!(
35
+ list_tree(&sb.repo, hash),
36
+ vec![
37
+ "README.md".to_string(),
38
+ "data/gadgets/9.toml".to_string(),
39
+ ],
40
+ "only the widgets/ subtree should be gone",
41
+ );
42
+ }
43
+
44
+ #[test]
45
+ fn cleared_subtree_can_be_repopulated_in_the_same_pass() {
46
+ let sb = Sandbox::new();
47
+ let base = sb.write_tree(&[
48
+ ("data/widgets/old-a.toml", "id = 1\n"),
49
+ ("data/widgets/old-b.toml", "id = 2\n"),
50
+ ]);
51
+
52
+ let mut tree = MutableTree::new(base);
53
+ tree.clear_children(&sb.repo, "data/widgets").unwrap();
54
+ tree.write_child(&sb.repo, "data/widgets/new.toml", "id = 3\n")
55
+ .unwrap();
56
+ let hash = tree.write(&sb.repo).unwrap();
57
+
58
+ assert_eq!(
59
+ list_tree(&sb.repo, hash),
60
+ vec!["data/widgets/new.toml".to_string()],
61
+ "old entries cleared, new entry present",
62
+ );
63
+ }
64
+
65
+ #[test]
66
+ fn clearing_the_root_yields_the_empty_tree() {
67
+ let sb = Sandbox::new();
68
+ let base = sb.write_tree(&[("a.toml", "1\n"), ("b/c.toml", "2\n")]);
69
+
70
+ let mut tree = MutableTree::new(base);
71
+ tree.clear_children(&sb.repo, ".").unwrap();
72
+ let hash = tree.write(&sb.repo).unwrap();
73
+
74
+ assert_eq!(
75
+ hash,
76
+ holo_tree::tree::empty_tree_id(),
77
+ "clearing the root must produce git's empty tree",
78
+ );
79
+ }
80
+
81
+ #[test]
82
+ fn clearing_a_missing_path_is_a_noop_on_write() {
83
+ let sb = Sandbox::new();
84
+ let base = sb.write_tree(&[("data/widgets/1.toml", "id = 1\n")]);
85
+
86
+ let mut tree = MutableTree::new(base);
87
+ // Path doesn't exist yet; an empty subtree is pruned on write().
88
+ tree.clear_children(&sb.repo, "data/never").unwrap();
89
+ let hash = tree.write(&sb.repo).unwrap();
90
+
91
+ assert_eq!(
92
+ hash, base,
93
+ "clearing a non-existent subtree must not change the written tree",
94
+ );
95
+ }
@@ -85,6 +85,31 @@ impl Sandbox {
85
85
  self.repo.write_object(&commit).unwrap().detach()
86
86
  }
87
87
 
88
+ /// Enable reflog writes on this repo's local config (`core.logAllRefUpdates`).
89
+ ///
90
+ /// A bare repo defaults this off, so ref updates write no reflog — but a
91
+ /// normal checkout / CI runner has it on. Tests that need to exercise the
92
+ /// reflog path (see #476) turn it on to match that environment. Writes the
93
+ /// repo's own config file, so it's honored even by an `isolated()` handle
94
+ /// (which drops ambient global/system/env config but keeps local config).
95
+ pub fn enable_reflogs(&self) {
96
+ use std::io::Write;
97
+ let config_path = self.dir.path().join("config");
98
+ let mut f = std::fs::OpenOptions::new()
99
+ .append(true)
100
+ .open(&config_path)
101
+ .expect("open repo config");
102
+ writeln!(f, "[core]\n\tlogAllRefUpdates = true").expect("write config");
103
+ }
104
+
105
+ /// Reopen this repo with `isolated()` options — no environment, global, or
106
+ /// system git config (so no ambient `user.name` / `user.email`), while the
107
+ /// repo's own local config still applies.
108
+ pub fn open_isolated(&self) -> gix::Repository {
109
+ gix::open_opts(self.dir.path(), gix::open::Options::isolated())
110
+ .expect("reopen sandbox repo with isolated config")
111
+ }
112
+
88
113
  /// Create a ref pointing to an object.
89
114
  pub fn set_ref(&self, name: &str, target: ObjectId) {
90
115
  self.repo
@@ -0,0 +1,130 @@
1
+ //! Tests for repo-level helpers used by the napi binding / gitsheets:
2
+ //! `resolve_ref` and the compare-and-swap variant of `update_ref`.
3
+
4
+ mod helpers;
5
+
6
+ use helpers::Sandbox;
7
+ use holo_tree::repo::{resolve_ref, update_ref};
8
+
9
+ /// Build two distinct commits (A then B, B child of A) on an empty tree.
10
+ fn two_commits(sb: &Sandbox) -> (gix::ObjectId, gix::ObjectId) {
11
+ let tree_a = sb.write_tree(&[("a.toml", "id = 1\n")]);
12
+ let a = sb.commit(tree_a, None, "a");
13
+ let tree_b = sb.write_tree(&[("b.toml", "id = 2\n")]);
14
+ let b = sb.commit(tree_b, Some(a), "b");
15
+ (a, b)
16
+ }
17
+
18
+ #[test]
19
+ fn resolve_ref_returns_commit_hash_for_a_branch() {
20
+ let sb = Sandbox::new();
21
+ let (a, _) = two_commits(&sb);
22
+ sb.set_ref("refs/heads/work", a);
23
+
24
+ assert_eq!(resolve_ref(&sb.repo, "refs/heads/work").unwrap(), Some(a));
25
+ // A bare object id resolves to itself.
26
+ assert_eq!(resolve_ref(&sb.repo, &a.to_string()).unwrap(), Some(a));
27
+ }
28
+
29
+ #[test]
30
+ fn resolve_ref_returns_none_for_unknown_ref() {
31
+ let sb = Sandbox::new();
32
+ let (a, _) = two_commits(&sb);
33
+ sb.set_ref("refs/heads/work", a);
34
+
35
+ assert_eq!(resolve_ref(&sb.repo, "refs/heads/nope").unwrap(), None);
36
+ assert_eq!(resolve_ref(&sb.repo, "does-not-exist").unwrap(), None);
37
+ }
38
+
39
+ #[test]
40
+ fn update_ref_cas_succeeds_when_expected_matches() {
41
+ let sb = Sandbox::new();
42
+ let (a, b) = two_commits(&sb);
43
+ sb.set_ref("refs/heads/work", a);
44
+
45
+ update_ref(&sb.repo, "refs/heads/work", b, Some(a)).unwrap();
46
+ assert_eq!(resolve_ref(&sb.repo, "refs/heads/work").unwrap(), Some(b));
47
+ }
48
+
49
+ #[test]
50
+ fn update_ref_cas_fails_when_expected_does_not_match() {
51
+ let sb = Sandbox::new();
52
+ let (a, b) = two_commits(&sb);
53
+ sb.set_ref("refs/heads/work", b); // ref is at B...
54
+
55
+ // ...but we claim it should be at A — the swap must be rejected.
56
+ let err = update_ref(&sb.repo, "refs/heads/work", a, Some(a)).unwrap_err();
57
+ assert!(
58
+ matches!(err, holo_tree::Error::Git(_)),
59
+ "CAS mismatch should surface as a git error, got {err:?}",
60
+ );
61
+ // Ref is unchanged.
62
+ assert_eq!(resolve_ref(&sb.repo, "refs/heads/work").unwrap(), Some(b));
63
+ }
64
+
65
+ #[test]
66
+ fn update_ref_without_expected_forces() {
67
+ let sb = Sandbox::new();
68
+ let (a, b) = two_commits(&sb);
69
+ sb.set_ref("refs/heads/work", a);
70
+
71
+ // No expected_old → unconditional set, even though current != target's parent.
72
+ update_ref(&sb.repo, "refs/heads/work", b, None).unwrap();
73
+ assert_eq!(resolve_ref(&sb.repo, "refs/heads/work").unwrap(), Some(b));
74
+ }
75
+
76
+ /// The reflog identity must come from the committer of the commit the ref now
77
+ /// points at — not from ambient git config. The sandbox commits are stamped
78
+ /// "Test <test@test>", so that is what the reflog entry must record, regardless
79
+ /// of whatever `user.name` / `user.email` the machine running the test has.
80
+ #[test]
81
+ fn update_ref_reflog_identity_comes_from_commit_committer() {
82
+ let sb = Sandbox::new();
83
+ let (a, _) = two_commits(&sb);
84
+ sb.enable_reflogs();
85
+ let repo = sb.open_isolated();
86
+
87
+ update_ref(&repo, "refs/heads/work", a, None).unwrap();
88
+
89
+ let reference = repo.find_reference("refs/heads/work").unwrap();
90
+ let mut platform = reference.log_iter();
91
+ let iter = platform
92
+ .all()
93
+ .unwrap()
94
+ .expect("update_ref should have written a reflog");
95
+
96
+ let mut last = None;
97
+ for line in iter {
98
+ let line = line.unwrap();
99
+ last = Some((
100
+ line.signature.name.to_string(),
101
+ line.signature.email.to_string(),
102
+ ));
103
+ }
104
+ let (name, email) = last.expect("reflog should have at least one entry");
105
+ assert_eq!(name, "Test", "reflog name should be the commit's committer");
106
+ assert_eq!(
107
+ email, "test@test",
108
+ "reflog email should be the commit's committer"
109
+ );
110
+ }
111
+
112
+ /// Regression for #476: a ref update on a fully-specified commit must not depend
113
+ /// on ambient git config. We reopen the sandbox repo with `isolated()` options —
114
+ /// which ignore environment and global/system git config, so no `user.name` /
115
+ /// `user.email` is visible — and confirm `update_ref` still succeeds. gix's
116
+ /// convenience `reference()` fails here with "The reflog could not be created or
117
+ /// updated"; deriving the reflog identity from the commit's committer fixes it.
118
+ #[test]
119
+ fn update_ref_succeeds_without_ambient_git_identity() {
120
+ let sb = Sandbox::new();
121
+ let (a, b) = two_commits(&sb);
122
+ sb.set_ref("refs/heads/work", a);
123
+ // Reproduce a CI checkout: reflogs on (so the reflog identity is actually
124
+ // needed) but no ambient git identity available.
125
+ sb.enable_reflogs();
126
+ let isolated = sb.open_isolated();
127
+
128
+ update_ref(&isolated, "refs/heads/work", b, Some(a)).unwrap();
129
+ assert_eq!(resolve_ref(&isolated, "refs/heads/work").unwrap(), Some(b));
130
+ }