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
package/holo-tree/src/tree.rs
CHANGED
|
@@ -324,57 +324,49 @@ impl MutableTree {
|
|
|
324
324
|
|
|
325
325
|
/// Navigate to a subtree, creating intermediate empty trees as needed.
|
|
326
326
|
///
|
|
327
|
-
///
|
|
328
|
-
///
|
|
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
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
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
|
-
|
|
352
|
+
self.dirty = true;
|
|
362
353
|
let mut cur = self;
|
|
363
|
-
for part in
|
|
354
|
+
for part in path.split('/') {
|
|
364
355
|
cur.ensure_children(repo)?;
|
|
365
|
-
let
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
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
|
-
|
|
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
|
-
|
|
522
|
-
|
|
523
|
-
|
|
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
|
+
}
|