1use std::path::{Path, PathBuf};
17
18use super::super::lease::{FileWork, WorktreeLease};
19use super::super::name::WorktreeName;
20use super::super::weight::Weight;
21use super::Worktrees;
22use super::kept::restore;
23use super::open_tree;
24use crate::error::StorageError;
25
26pub(super) const STOCK: &str = "+spare";
31
32const STOCKING: &str = "being stocked by sprawling";
35
36enum Stock {
38 Absent,
39 Busy,
41 Broken,
44 Ready(git2::Worktree, Links),
45}
46
47struct Links {
50 tree: PathBuf,
51 admin: PathBuf,
52 dot_git: String,
54 gitdir: String,
56}
57
58impl Links {
59 fn of(&self, name: &str) -> (String, String) {
61 (
62 format!("{}worktrees/{name}/\n", self.dot_git),
63 format!("{}/{name}/.git\n", self.gitdir),
64 )
65 }
66}
67
68impl Worktrees {
69 pub fn stock(&self) -> Result<FileWork, StorageError> {
82 match self.stock_standing()? {
83 Stock::Absent => self.place_stock(),
84 Stock::Busy => Ok(FileWork::default()),
85 Stock::Broken => {
86 self.forget(STOCK)?;
87 self.place_stock()
88 }
89 Stock::Ready(tree, _) => self.refresh_stock(&tree),
90 }
91 }
92
93 pub(super) fn adopt(
102 &self,
103 name: &WorktreeName,
104 city: &Weight,
105 ) -> Result<Option<WorktreeLease>, StorageError> {
106 let Stock::Ready(_, links) = self.stock_standing()? else {
107 return Ok(None);
108 };
109 let target = self.home.join(name.as_str());
110 crate::alias::WriteTarget::within("place a worktree", &self.city_root, &target)?;
111 self.branch_at_trunk(name)?;
112 match std::fs::rename(&links.tree, &target) {
113 Ok(()) => {}
114 Err(err) if err.kind() == std::io::ErrorKind::NotFound => return Ok(None),
115 Err(source) => return Err(io("take the stock", &links.tree, source)),
116 }
117 let (dot_git, gitdir) = links.of(name.as_str());
118 let admin = links.admin.with_file_name(name.as_str());
119 write(&target.join(".git"), &dot_git)?;
120 std::fs::rename(&links.admin, &admin)
121 .map_err(|source| io("register the stock as a node's tree", &links.admin, source))?;
122 write(&admin.join("gitdir"), &gitdir)?;
123 let tree = self
124 .repo
125 .find_worktree(name.as_str())
126 .map_err(|err| git("find a taken stock", name.as_str(), &err))?;
127 let lease = self.reattach(name, &tree, &[])?;
128 Ok(Some(WorktreeLease {
129 work: FileWork {
130 walked: lease.work.walked.saturating_add(city.walked),
131 ..lease.work
132 },
133 ..lease
134 }))
135 }
136
137 fn stock_standing(&self) -> Result<Stock, StorageError> {
139 let admin = self.repo.commondir().join(super::WORKTREE_DIR).join(STOCK);
140 let tree = match self.repo.find_worktree(STOCK) {
141 Ok(tree) => tree,
142 Err(err) if err.code() == git2::ErrorCode::NotFound => return Ok(Stock::Absent),
143 Err(_) if admin.is_dir() => return Ok(Stock::Broken),
153 Err(err) => return Err(git("find the stock", STOCK, &err)),
154 };
155 let lock = match tree.is_locked() {
156 Ok(lock) => lock,
157 Err(err) if err.code() == git2::ErrorCode::NotFound => return Ok(Stock::Absent),
163 Err(err) => return Err(git("read the stock's lock", STOCK, &err)),
164 };
165 if let git2::WorktreeLockStatus::Locked(_) = lock {
166 return Ok(Stock::Busy);
167 }
168 let read = |path: &Path| match std::fs::read_to_string(path) {
169 Ok(text) => Ok(Some(text)),
170 Err(err) if err.kind() == std::io::ErrorKind::NotFound => Ok(None),
171 Err(source) => Err(io("read the stock's links", path, source)),
172 };
173 let dot_git = read(&tree.path().join(".git"))?;
174 let gitdir = read(&admin.join("gitdir"))?;
175 let links = dot_git
176 .as_deref()
177 .and_then(|text| text.strip_suffix(&format!("worktrees/{STOCK}/\n")))
178 .zip(
179 gitdir
180 .as_deref()
181 .and_then(|text| text.strip_suffix(&format!("/{STOCK}/.git\n"))),
182 );
183 Ok(match links {
184 Some((dot_git, gitdir)) => {
185 let links = Links {
186 tree: tree.path().to_path_buf(),
187 admin,
188 dot_git: dot_git.to_owned(),
189 gitdir: gitdir.to_owned(),
190 };
191 Stock::Ready(tree, links)
192 }
193 None => Stock::Broken,
194 })
195 }
196
197 fn place_stock(&self) -> Result<FileWork, StorageError> {
199 let city = self.refuse_oversized(STOCK)?;
200 self.clear_unregistered(STOCK)?;
201 let trunk = self
202 .repo
203 .head()
204 .and_then(|head| head.peel_to_commit())
205 .map_err(|err| git("read the city trunk", STOCK, &err))?;
206 let branch = self
207 .repo
208 .branch(STOCK, &trunk, true)
209 .map_err(|err| git("branch the stock", STOCK, &err))?;
210 let (_, written) = match self.check_out_tree(STOCK, Some(branch.get())) {
211 Ok(placed) => placed,
212 Err(refusal) => return Err(self.take_back(refusal)),
215 };
216 let tree = self
217 .repo
218 .find_worktree(STOCK)
219 .map_err(|err| git("find the stock", STOCK, &err))?;
220 cache_trees(&tree)?;
221 tree.unlock()
222 .map_err(|err| git("unlock the stock", STOCK, &err))?;
223 Ok(FileWork {
224 created: written.files,
225 walked: city.walked,
226 ..FileWork::default()
227 })
228 }
229
230 fn refresh_stock(&self, tree: &git2::Worktree) -> Result<FileWork, StorageError> {
232 tree.lock(Some(STOCKING))
233 .map_err(|err| git("lock the stock", STOCK, &err))?;
234 let trunk = self
235 .repo
236 .head()
237 .and_then(|head| head.peel_to_commit())
238 .map_err(|err| git("read the city trunk", STOCK, &err))?;
239 self.repo
240 .branch(STOCK, &trunk, true)
241 .map_err(|err| git("move the stock to the trunk", STOCK, &err))?;
242 let work = restore(tree, STOCK, &[]).and_then(|work| cache_trees(tree).map(|()| work));
246 tree.unlock()
247 .map_err(|err| git("unlock the stock", STOCK, &err))?;
248 work
249 }
250
251 fn take_back(&self, refusal: StorageError) -> StorageError {
255 match self.forget(STOCK) {
256 Ok(()) => refusal,
257 Err(left) => StorageError::Worktree {
258 op: "take back a stock that did not check out",
259 detail: format!("{left}; the checkout failed with: {refusal}"),
260 },
261 }
262 }
263
264 fn branch_at_trunk(&self, name: &WorktreeName) -> Result<(), StorageError> {
268 match self
269 .repo
270 .find_branch(name.as_str(), git2::BranchType::Local)
271 {
272 Ok(_) => Ok(()),
273 Err(err) if err.code() == git2::ErrorCode::NotFound => {
274 let trunk = self
275 .repo
276 .head()
277 .and_then(|head| head.peel_to_commit())
278 .map_err(|err| git("read the city trunk", name.as_str(), &err))?;
279 self.repo
280 .branch(name.as_str(), &trunk, false)
281 .map(drop)
282 .map_err(|err| git("branch a worktree", name.as_str(), &err))
283 }
284 Err(err) => Err(git("find a node branch", name.as_str(), &err)),
285 }
286 }
287}
288
289fn cache_trees(tree: &git2::Worktree) -> Result<(), StorageError> {
296 let repo = open_tree(tree).map_err(|err| git("open the stock", STOCK, &err))?;
297 let head = repo
298 .head()
299 .and_then(|head| head.peel_to_tree())
300 .map_err(|err| git("read the stock's branch", STOCK, &err))?;
301 let mut index = repo
302 .index()
303 .map_err(|err| git("read the stock's index", STOCK, &err))?;
304 index
305 .read_tree(&head)
306 .and_then(|()| index.write())
307 .map_err(|err| git("cache the stock's trees", STOCK, &err))
308}
309
310fn write(path: &Path, text: &str) -> Result<(), StorageError> {
312 std::fs::write(path, text).map_err(|source| io("rewrite a worktree link", path, source))
313}
314
315fn io(op: &'static str, path: &Path, source: std::io::Error) -> StorageError {
316 StorageError::Io {
317 op,
318 path: path.to_path_buf(),
319 source,
320 }
321}
322
323fn git(op: &'static str, id: &str, err: &git2::Error) -> StorageError {
324 StorageError::Worktree {
325 op,
326 detail: format!("{id}: {err}"),
327 }
328}
329
330#[cfg(test)]
331#[allow(
332 clippy::unwrap_used,
333 clippy::arithmetic_side_effects,
334 reason = "test code"
335)]
336mod tests {
337 use std::path::Path;
338
339 use kernel::TimeMs;
340
341 use super::super::super::lease::FileWork;
342 use super::super::Landing;
343 use super::super::Worktrees;
344 use super::super::tests::{bulk_city, entries, name, owner};
345 use super::STOCK;
346 use crate::checkpoint::Checkpoint;
347
348 fn files_under(dir: &Path) -> u64 {
349 u64::try_from(std::fs::read_dir(dir).unwrap().count()).unwrap()
350 }
351
352 #[test]
364 fn a_registration_git_will_not_read_is_stocked_again() {
365 let dir = tempfile::tempdir().unwrap();
366 let trees = bulk_city(dir.path(), 8);
367 trees.stock().unwrap();
368 let registration = trees
369 .repo
370 .commondir()
371 .join(super::super::WORKTREE_DIR)
372 .join(STOCK);
373 std::fs::remove_file(registration.join("gitdir")).unwrap();
374
375 trees.stock().unwrap();
376 let placed = trees.claim(&name("node-1"), &["bulk".to_owned()]).unwrap();
377 assert_eq!(
378 std::fs::read_dir(placed.path().join("bulk"))
379 .unwrap()
380 .count(),
381 8,
382 "the stock is whole again after the cut registration was taken back"
383 );
384 }
385
386 #[test]
391 fn a_placement_from_the_stock_creates_no_file_at_either_size() {
392 for files in [32, 64] {
393 let dir = tempfile::tempdir().unwrap();
394 let trees = bulk_city(dir.path(), files);
395 let city = entries(dir.path());
396 let stocked = trees.stock().unwrap();
397 let city_at_claim = entries(dir.path());
398 let placed = trees.claim(&name("node-1"), &["bulk".to_owned()]).unwrap();
399 assert_eq!(
400 (
401 stocked,
402 placed.work(),
403 files_under(&placed.path().join("bulk"))
404 ),
405 (
406 FileWork {
407 created: files,
408 walked: city,
409 ..FileWork::default()
410 },
411 FileWork {
412 walked: city_at_claim + entries(placed.path()),
413 ..FileWork::default()
414 },
415 files,
416 ),
417 "a stock and the placement that takes it at {files} files"
418 );
419 }
420 }
421
422 #[test]
427 fn a_stock_behind_the_trunk_is_placed_at_the_trunk_and_merges() {
428 let dir = tempfile::tempdir().unwrap();
429 let trees = bulk_city(dir.path(), 8);
430 trees.stock().unwrap();
431 std::fs::write(dir.path().join("bulk").join("file-0000.txt"), b"moved on").unwrap();
432 Checkpoint::open(dir.path())
433 .unwrap()
434 .land(&[], TimeMs::new(2_000), &owner(), "checkpoint: bulk")
435 .unwrap();
436
437 let bulk = ["bulk".to_owned()];
438 let placed = trees.claim(&name("node-1"), &bulk).unwrap();
439 let tree = placed.path().to_path_buf();
440 let repo = git2::Repository::open(&tree).unwrap();
441 assert_eq!(
442 (
443 placed.work().created,
444 placed.work().rewritten,
445 std::fs::read_to_string(tree.join("bulk").join("file-0000.txt")).unwrap(),
446 repo.head().unwrap().name().ok().map(str::to_owned),
447 trees.live().unwrap(),
448 ),
449 (
450 0,
451 1,
452 "moved on".to_owned(),
453 Some("refs/heads/node-1".to_owned()),
454 vec![name("node-1")],
455 ),
456 "the stock is the node's tree, at the trunk, on the node's branch"
457 );
458
459 std::fs::write(tree.join("bulk").join("file-0001.txt"), b"from the node").unwrap();
460 Checkpoint::open(&tree)
461 .unwrap()
462 .land(&bulk, TimeMs::new(3_000), &owner(), "offer: bulk")
463 .unwrap();
464 trees.release(placed).unwrap();
465 let of = owner();
466 trees
467 .plan_merge(&name("node-1"))
468 .unwrap()
469 .apply(&Landing {
470 t: TimeMs::new(4_000),
471 of: &of,
472 subject: "merge: node-1",
473 reviewed_by_person: false,
474 })
475 .unwrap();
476 assert_eq!(
477 std::fs::read_to_string(dir.path().join("bulk").join("file-0001.txt")).unwrap(),
478 "from the node"
479 );
480 }
481
482 #[test]
489 fn a_room_placed_while_the_stock_is_being_made_checks_out_once_and_the_stock_survives() {
490 for files in [8, 16, 32] {
491 let dir = tempfile::tempdir().unwrap();
492 let trees = bulk_city(dir.path(), files);
493 trees.stock().unwrap();
494 let first = trees.claim(&name("node-1"), &[]).unwrap().work().created;
495 let restocked = trees.stock().unwrap().created;
496 let making = git2::Repository::open(dir.path())
497 .unwrap()
498 .find_worktree(super::STOCK)
499 .unwrap();
500 making.lock(Some(super::STOCKING)).unwrap();
501 let during = trees.claim(&name("node-2"), &[]).unwrap().work().created;
502 making.unlock().unwrap();
503 let refreshed = trees.stock().unwrap().created;
504 let after = trees.claim(&name("node-3"), &[]).unwrap().work().created;
505 assert_eq!(
506 (first, restocked, during, refreshed, after),
507 (0, files, files, 0, 0),
508 "files created per step at {files} files"
509 );
510 }
511 }
512
513 #[test]
516 fn the_stock_is_no_nodes_tree_and_outlives_the_sweep() {
517 let dir = tempfile::tempdir().unwrap();
518 let trees = bulk_city(dir.path(), 8);
519 trees.stock().unwrap();
520 let swept = Worktrees::sweep_abandoned(dir.path(), &[]).unwrap();
521 let listed = trees.live().unwrap();
522 let placed = trees.claim(&name("node-1"), &[]).unwrap();
523 assert_eq!(
524 (swept, listed, placed.work().created),
525 (Vec::new(), Vec::new(), 0),
526 "the stock survives the sweep and the next placement creates nothing"
527 );
528 }
529}