1use super::super::lease::{FileWork, WorktreeLease};
10use super::super::name::WorktreeName;
11use super::super::weight::measure;
12use super::Worktrees;
13use super::open_tree;
14use crate::error::StorageError;
15
16const LEASE_REASON: &str = "held by a sprawling run";
18
19pub(super) enum Standing {
21 Held,
22 Kept(git2::Worktree),
23 Absent,
24}
25
26impl Worktrees {
27 pub fn lift_abandoned_leases(
38 city_root: &std::path::Path,
39 _writer: &crate::JsonlLedger,
40 ) -> Result<(), StorageError> {
41 let refuse = |op: &'static str, err: git2::Error| StorageError::Worktree {
42 op,
43 detail: format!("{}: {err}", city_root.display()),
44 };
45 let repo = match git2::Repository::open(city_root) {
46 Ok(repo) => repo,
47 Err(err) if err.code() == git2::ErrorCode::NotFound => return Ok(()),
48 Err(err) => return Err(refuse("open the city repository", err)),
49 };
50 let names = repo
51 .worktrees()
52 .map_err(|err| refuse("list worktrees", err))?;
53 for name in names.iter().flatten().flatten() {
54 let tree = repo
55 .find_worktree(name)
56 .map_err(|err| refuse("find a worktree", err))?;
57 let lock = tree
58 .is_locked()
59 .map_err(|err| refuse("read a worktree lock", err))?;
60 match lock {
61 git2::WorktreeLockStatus::Locked(_) => tree
62 .unlock()
63 .map_err(|err| refuse("unlock a worktree", err))?,
64 git2::WorktreeLockStatus::Unlocked => {}
65 }
66 }
67 Ok(())
68 }
69
70 pub(super) fn standing(&self, name: &WorktreeName) -> Result<Standing, StorageError> {
79 let tree = match self.repo.find_worktree(name.as_str()) {
80 Ok(tree) => tree,
81 Err(err) if err.code() == git2::ErrorCode::NotFound => return Ok(Standing::Absent),
82 Err(err) => {
83 return Err(StorageError::Worktree {
84 op: "find a worktree",
85 detail: format!("{}: {err}", name.as_str()),
86 });
87 }
88 };
89 if !tree.path().exists() {
90 self.forget(name.as_str())?;
91 return Ok(Standing::Absent);
92 }
93 let lock = tree.is_locked().map_err(|err| StorageError::Worktree {
94 op: "read a worktree lock",
95 detail: format!("{}: {err}", name.as_str()),
96 })?;
97 Ok(match lock {
98 git2::WorktreeLockStatus::Locked(_) => Standing::Held,
99 git2::WorktreeLockStatus::Unlocked => Standing::Kept(tree),
100 })
101 }
102
103 pub(super) fn reattach(
121 &self,
122 name: &WorktreeName,
123 tree: &git2::Worktree,
124 scopes: &[String],
125 ) -> Result<WorktreeLease, StorageError> {
126 self.follow_trunk(name)?;
127 let work = restore(tree, name.as_str(), scopes)?;
128 tree.lock(Some(LEASE_REASON))
129 .map_err(|err| StorageError::Worktree {
130 op: "lock a worktree",
131 detail: format!("{}: {err}", name.as_str()),
132 })?;
133 let weight = measure(tree.path())?;
134 Ok(WorktreeLease {
135 name: name.clone(),
136 path: tree.path().to_path_buf(),
137 disk: weight.bytes,
138 work: FileWork {
139 walked: weight.walked,
140 ..work
141 },
142 })
143 }
144
145 fn follow_trunk(&self, name: &WorktreeName) -> Result<(), StorageError> {
149 let refuse = |op: &'static str, err: git2::Error| StorageError::Worktree {
150 op,
151 detail: format!("{}: {err}", name.as_str()),
152 };
153 let mut branch = self
154 .repo
155 .find_branch(name.as_str(), git2::BranchType::Local)
156 .map_err(|err| refuse("find a node branch", err))?;
157 let tip = branch
158 .get()
159 .peel_to_commit()
160 .map_err(|err| refuse("read a node branch", err))?
161 .id();
162 let trunk = self
163 .repo
164 .head()
165 .and_then(|head| head.peel_to_commit())
166 .map_err(|err| refuse("read the city trunk", err))?
167 .id();
168 let behind = tip != trunk
169 && self
170 .repo
171 .graph_descendant_of(trunk, tip)
172 .map_err(|err| refuse("judge a fast-forward", err))?;
173 if behind {
174 branch
175 .get_mut()
176 .set_target(trunk, "follow the trunk")
177 .map_err(|err| refuse("move a node branch", err))?;
178 }
179 Ok(())
180 }
181}
182
183pub(super) fn restore(
198 tree: &git2::Worktree,
199 branch: &str,
200 scopes: &[String],
201) -> Result<FileWork, StorageError> {
202 let refuse = |op: &'static str, err: git2::Error| StorageError::Worktree {
203 op,
204 detail: format!("{branch}: {err}"),
205 };
206 let repo = open_tree(tree).map_err(|err| refuse("open a kept worktree", err))?;
207 let wanted = format!("refs/heads/{branch}");
208 let pointed = repo
209 .find_reference("HEAD")
210 .map_err(|err| refuse("read a kept worktree's HEAD", err))?;
211 if pointed.symbolic_target_bytes() != Some(wanted.as_bytes()) {
212 repo.reference_symbolic("HEAD", &wanted, true, "point the tree at its node's branch")
213 .map_err(|err| refuse("point a kept worktree at its branch", err))?;
214 }
215 let head = repo
216 .head()
217 .and_then(|head| head.peel_to_tree())
218 .map_err(|err| refuse("read a kept worktree's branch", err))?;
219 let mut index = repo
220 .index()
221 .map_err(|err| refuse("read a kept worktree's index", err))?;
222 if !index_writes_tree(&mut index, head.id())
223 .map_err(|err| refuse("compare a kept worktree's index", err))?
224 {
225 index
226 .read_tree(&head)
227 .and_then(|()| index.write())
228 .map_err(|err| refuse("reset a kept worktree's index", err))?;
229 }
230 let mut work = FileWork::default();
231 let mut checkout = git2::build::CheckoutBuilder::new();
232 checkout
233 .force()
234 .remove_untracked(true)
235 .notify_on(
236 git2::CheckoutNotificationType::UPDATED | git2::CheckoutNotificationType::UNTRACKED,
237 )
238 .notify(|kind, _, _, target, workdir| {
239 count_change(&mut work, kind, (target.is_some(), workdir.is_some()));
240 true
241 });
242 for spec in crate::checkpoint::scan::pathspec::of(scopes) {
243 checkout.path(spec);
244 }
245 repo.checkout_head(Some(&mut checkout))
246 .map_err(|err| refuse("reset a kept worktree", err))?;
247 drop(checkout);
248 Ok(work)
249}
250
251fn count_change(work: &mut FileWork, kind: git2::CheckoutNotificationType, present: (bool, bool)) {
257 let count = if kind.contains(git2::CheckoutNotificationType::UNTRACKED) {
258 &mut work.removed
259 } else {
260 match present {
261 (false, _) => &mut work.removed,
262 (true, false) => &mut work.created,
263 (true, true) => &mut work.rewritten,
264 }
265 };
266 *count = count.saturating_add(1);
267}
268
269fn index_writes_tree(index: &mut git2::Index, tree: git2::Oid) -> Result<bool, git2::Error> {
277 match index.write_tree() {
278 Ok(written) => Ok(written == tree),
279 Err(err) if err.code() == git2::ErrorCode::Unmerged => Ok(false),
280 Err(err) => Err(err),
281 }
282}
283
284#[cfg(test)]
285#[allow(
286 clippy::unwrap_used,
287 clippy::arithmetic_side_effects,
288 reason = "test code"
289)]
290mod tests {
291 use super::super::super::lease::FileWork;
292 use super::super::Landing;
293 use super::super::tests::{bulk_city, city, entries, name, owner};
294 use crate::checkpoint::Checkpoint;
295 use kernel::TimeMs;
296 use std::path::Path;
297
298 fn read(path: &Path) -> String {
299 std::fs::read_to_string(path).unwrap()
300 }
301
302 #[test]
308 fn claiming_a_tree_costs_what_it_places_and_restores() {
309 for files in [32, 64] {
310 let dir = tempfile::tempdir().unwrap();
311 let trees = bulk_city(dir.path(), files);
312 let scope = ["bulk".to_owned()];
313 let city = entries(dir.path());
314
315 let placed = trees.claim(&name("node-1"), &scope).unwrap();
316 let tree = placed.path().to_path_buf();
317 assert_eq!(
318 placed.work(),
319 FileWork {
320 created: files,
321 rewritten: 0,
322 removed: 0,
323 walked: city,
324 },
325 "a placement at {files} files"
326 );
327
328 std::fs::write(tree.join("bulk").join("file-0000.txt"), b"changed by a run").unwrap();
329 std::fs::write(tree.join("bulk").join("stray.txt"), b"left by a run").unwrap();
330 trees.release(placed).unwrap();
331 let restored = trees.claim(&name("node-1"), &scope).unwrap();
332 assert_eq!(
333 restored.work(),
334 FileWork {
335 created: 0,
336 rewritten: 1,
337 removed: 1,
338 walked: entries(&tree),
339 },
340 "a reclaim after a run at {files} files"
341 );
342
343 trees.release(restored).unwrap();
344 let unchanged = trees.claim(&name("node-1"), &scope).unwrap();
345 assert_eq!(
346 unchanged.work(),
347 FileWork {
348 walked: entries(&tree),
349 ..FileWork::default()
350 },
351 "a reclaim with nothing to restore at {files} files"
352 );
353 }
354 }
355
356 #[test]
360 fn a_kept_tree_claimed_again_checks_out_only_its_scope_and_offers_only_that() {
361 let dir = tempfile::tempdir().unwrap();
362 let trees = city(dir.path());
363 let city_docs = dir.path().join("docs").join("plan.md");
364 let land_city = |t: u64| {
365 Checkpoint::open(dir.path())
366 .unwrap()
367 .land(&[], TimeMs::new(t), &owner(), "checkpoint: city")
368 .unwrap()
369 };
370 std::fs::create_dir_all(city_docs.parent().unwrap()).unwrap();
371 std::fs::write(
372 &city_docs, b"v1
373",
374 )
375 .unwrap();
376 land_city(1_500);
377 let lab = ["lab".to_owned()];
378 trees
379 .release(trees.claim(&name("node-1"), &lab).unwrap())
380 .unwrap();
381 std::fs::write(
382 &city_docs, b"v2
383",
384 )
385 .unwrap();
386 std::fs::write(
387 dir.path().join("lab").join("notes.md"),
388 b"second
389",
390 )
391 .unwrap();
392 let trunk = land_city(2_000);
393
394 let again = trees.claim(&name("node-1"), &lab).unwrap();
395 assert_eq!(
396 (
397 read(&again.path().join("lab").join("notes.md")),
398 read(&again.path().join("docs").join("plan.md"))
399 ),
400 (
401 "second
402"
403 .to_owned(),
404 "v1
405"
406 .to_owned()
407 ),
408 "the scope follows the trunk and nothing outside it is written"
409 );
410
411 std::fs::write(
412 again.path().join("lab").join("notes.md"),
413 b"from the node
414",
415 )
416 .unwrap();
417 Checkpoint::open(again.path())
418 .unwrap()
419 .land(&lab, TimeMs::new(3_000), &owner(), "offer: lab")
420 .unwrap();
421 let of = owner();
422 trees
423 .plan_merge(again.name())
424 .unwrap()
425 .apply(&Landing {
426 t: TimeMs::new(4_000),
427 of: &of,
428 subject: "merge: node-1",
429 reviewed_by_person: false,
430 })
431 .unwrap();
432 let repo = git2::Repository::open(dir.path()).unwrap();
433 let before = repo
434 .find_commit(git2::Oid::from_str(&trunk).unwrap())
435 .unwrap()
436 .tree()
437 .unwrap();
438 let after = repo.head().unwrap().peel_to_tree().unwrap();
439 let moved: Vec<String> = repo
440 .diff_tree_to_tree(Some(&before), Some(&after), None)
441 .unwrap()
442 .deltas()
443 .filter_map(|delta| delta.new_file().path().map(|p| p.display().to_string()))
444 .collect();
445 assert_eq!(
446 (moved, read(&city_docs)),
447 (
448 vec!["lab/notes.md".to_owned()],
449 "v2
450"
451 .to_owned()
452 ),
453 "the merge moves the scope alone"
454 );
455 }
456
457 #[test]
462 fn a_path_staged_outside_the_scope_does_not_ride_along_with_the_next_offer() {
463 let dir = tempfile::tempdir().unwrap();
464 let trees = city(dir.path());
465 let lab = ["lab".to_owned()];
466 let first = trees.claim(&name("node-1"), &lab).unwrap();
467 let stray = first.path().join("docs").join("stray.md");
468 std::fs::create_dir_all(stray.parent().unwrap()).unwrap();
469 std::fs::write(&stray, b"not this node's").unwrap();
470 let staging = git2::Repository::open(first.path()).unwrap();
471 let mut index = staging.index().unwrap();
472 index.add_path(Path::new("docs/stray.md")).unwrap();
473 index.write().unwrap();
474 trees.release(first).unwrap();
475
476 let again = trees.claim(&name("node-1"), &lab).unwrap();
477 std::fs::write(again.path().join("lab").join("notes.md"), b"from the node").unwrap();
478 Checkpoint::open(again.path())
479 .unwrap()
480 .land(&lab, TimeMs::new(3_000), &owner(), "offer: lab")
481 .unwrap();
482 let offered = git2::Repository::open(again.path()).unwrap();
483 let head = offered.head().unwrap().peel_to_commit().unwrap();
484 let moved: Vec<String> = offered
485 .diff_tree_to_tree(
486 Some(&head.parent(0).unwrap().tree().unwrap()),
487 Some(&head.tree().unwrap()),
488 None,
489 )
490 .unwrap()
491 .deltas()
492 .filter_map(|delta| delta.new_file().path().map(|p| p.display().to_string()))
493 .collect();
494 assert_eq!(
495 moved,
496 vec!["lab/notes.md".to_owned()],
497 "the offer moves the scope alone"
498 );
499 }
500
501 #[test]
505 fn a_released_tree_stays_on_disk_for_the_nodes_next_run() {
506 let dir = tempfile::tempdir().unwrap();
507 let trees = city(dir.path());
508 let lease = trees.claim(&name("node-1"), &[]).unwrap();
509 let path = lease.path().to_path_buf();
510 let notes = path.join("lab").join("notes.md");
511 let written = std::fs::metadata(¬es).unwrap().modified().unwrap();
512
513 trees.release(lease).unwrap();
514 assert!(notes.exists(), "a released tree keeps its files");
515 assert_eq!(trees.live().unwrap(), vec![name("node-1")]);
516
517 let again = trees.claim(&name("node-1"), &[]).unwrap();
518 assert_eq!(
521 std::fs::canonicalize(again.path()).unwrap(),
522 std::fs::canonicalize(&path).unwrap()
523 );
524 assert_eq!(
525 std::fs::metadata(¬es).unwrap().modified().unwrap(),
526 written,
527 "a file the node's branch already holds is not written again"
528 );
529 assert!(dir.path().join("lab").join("notes.md").exists());
530 }
531
532 #[test]
537 fn a_kept_tree_with_nothing_unmerged_starts_from_the_trunk_as_it_now_stands() {
538 let dir = tempfile::tempdir().unwrap();
539 let trees = city(dir.path());
540 trees
541 .release(trees.claim(&name("node-1"), &[]).unwrap())
542 .unwrap();
543 std::fs::write(
544 dir.path().join("lab").join("notes.md"),
545 b"second
546 ",
547 )
548 .unwrap();
549 Checkpoint::open(dir.path())
550 .unwrap()
551 .land(&[], TimeMs::new(2_000), &owner(), "checkpoint: lab")
552 .unwrap();
553
554 let again = trees.claim(&name("node-1"), &[]).unwrap();
555 assert_eq!(
556 std::fs::read_to_string(again.path().join("lab").join("notes.md")).unwrap(),
557 "second
558 "
559 );
560 }
561}