1use super::super::lease::{FileWork, WorktreeLease};
10use super::super::name::WorktreeName;
11use super::super::weight::measure;
12use super::Worktrees;
13use crate::error::StorageError;
14
15const LEASE_REASON: &str = "held by a sprawling run";
17
18pub(super) enum Standing {
20 Held,
21 Kept(git2::Worktree),
22 Absent,
23}
24
25impl Worktrees {
26 pub fn lift_abandoned_leases(
37 city_root: &std::path::Path,
38 _writer: &crate::JsonlLedger,
39 ) -> Result<(), StorageError> {
40 let refuse = |op: &'static str, err: git2::Error| StorageError::Worktree {
41 op,
42 detail: format!("{}: {err}", city_root.display()),
43 };
44 let repo = match git2::Repository::open(city_root) {
45 Ok(repo) => repo,
46 Err(err) if err.code() == git2::ErrorCode::NotFound => return Ok(()),
47 Err(err) => return Err(refuse("open the city repository", err)),
48 };
49 let names = repo
50 .worktrees()
51 .map_err(|err| refuse("list worktrees", err))?;
52 for name in names.iter().flatten().flatten() {
53 let tree = repo
54 .find_worktree(name)
55 .map_err(|err| refuse("find a worktree", err))?;
56 let lock = tree
57 .is_locked()
58 .map_err(|err| refuse("read a worktree lock", err))?;
59 match lock {
60 git2::WorktreeLockStatus::Locked(_) => tree
61 .unlock()
62 .map_err(|err| refuse("unlock a worktree", err))?,
63 git2::WorktreeLockStatus::Unlocked => {}
64 }
65 }
66 Ok(())
67 }
68
69 pub(super) fn standing(&self, name: &WorktreeName) -> Result<Standing, StorageError> {
78 let tree = match self.repo.find_worktree(name.as_str()) {
79 Ok(tree) => tree,
80 Err(err) if err.code() == git2::ErrorCode::NotFound => return Ok(Standing::Absent),
81 Err(err) => {
82 return Err(StorageError::Worktree {
83 op: "find a worktree",
84 detail: format!("{}: {err}", name.as_str()),
85 });
86 }
87 };
88 if !tree.path().exists() {
89 self.forget(name.as_str())?;
90 return Ok(Standing::Absent);
91 }
92 let lock = tree.is_locked().map_err(|err| StorageError::Worktree {
93 op: "read a worktree lock",
94 detail: format!("{}: {err}", name.as_str()),
95 })?;
96 Ok(match lock {
97 git2::WorktreeLockStatus::Locked(_) => Standing::Held,
98 git2::WorktreeLockStatus::Unlocked => Standing::Kept(tree),
99 })
100 }
101
102 pub(super) fn reattach(
120 &self,
121 name: &WorktreeName,
122 tree: &git2::Worktree,
123 scopes: &[String],
124 ) -> Result<WorktreeLease, StorageError> {
125 self.follow_trunk(name)?;
126 let work = restore(tree, name.as_str(), scopes)?;
127 tree.lock(Some(LEASE_REASON))
128 .map_err(|err| StorageError::Worktree {
129 op: "lock a worktree",
130 detail: format!("{}: {err}", name.as_str()),
131 })?;
132 let weight = measure(tree.path())?;
133 Ok(WorktreeLease {
134 name: name.clone(),
135 path: tree.path().to_path_buf(),
136 disk: weight.bytes,
137 work: FileWork {
138 walked: weight.walked,
139 ..work
140 },
141 })
142 }
143
144 fn follow_trunk(&self, name: &WorktreeName) -> Result<(), StorageError> {
148 let refuse = |op: &'static str, err: git2::Error| StorageError::Worktree {
149 op,
150 detail: format!("{}: {err}", name.as_str()),
151 };
152 let mut branch = self
153 .repo
154 .find_branch(name.as_str(), git2::BranchType::Local)
155 .map_err(|err| refuse("find a node branch", err))?;
156 let tip = branch
157 .get()
158 .peel_to_commit()
159 .map_err(|err| refuse("read a node branch", err))?
160 .id();
161 let trunk = self
162 .repo
163 .head()
164 .and_then(|head| head.peel_to_commit())
165 .map_err(|err| refuse("read the city trunk", err))?
166 .id();
167 let behind = tip != trunk
168 && self
169 .repo
170 .graph_descendant_of(trunk, tip)
171 .map_err(|err| refuse("judge a fast-forward", err))?;
172 if behind {
173 branch
174 .get_mut()
175 .set_target(trunk, "follow the trunk")
176 .map_err(|err| refuse("move a node branch", err))?;
177 }
178 Ok(())
179 }
180}
181
182pub(super) fn restore(
197 tree: &git2::Worktree,
198 branch: &str,
199 scopes: &[String],
200) -> Result<FileWork, StorageError> {
201 let refuse = |op: &'static str, err: git2::Error| StorageError::Worktree {
202 op,
203 detail: format!("{branch}: {err}"),
204 };
205 let repo = git2::Repository::open_from_worktree(tree)
206 .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}