1use super::capture_store::{
22 CaptureError, capture_store_path, ensure_capture_store, read_next_capture_sequence,
23 read_store_file, read_worktree_id,
24};
25use super::diagnostic_log::{debug, warn};
27use super::json_record::{
29 MAX_SAFE_INTEGER, ObjectWriter, parse_object, safe_integer, string_array,
30};
31use super::private_storage::write_private_file_unsynced;
33use super::transaction_journal::JOURNAL_SCHEMA_VERSION;
35use super::transaction_registry::{
37 EntryKind, RegistryEntry, ensure_transaction_root, list_transaction_entries,
38};
39use serde_json::{Map, Value};
41use std::path::{Path, PathBuf};
43
44pub const CAPTURED_FILENAME: &str = "captured.json";
46
47pub const LANDED_DIRECTORY_NAME: &str = "landed";
49
50pub const LANDED_RECORD_SUFFIX: &str = ".json";
52
53pub const LANDED_SCHEMA_VERSION: i64 = 1;
55
56#[derive(Clone, Debug, Eq, PartialEq)]
69pub struct CapturedRecord {
70 pub worktree_id: String,
72 pub sequence: i64,
75 pub next_sequence_before_base: i64,
77 pub worktree_paths: Vec<String>,
81}
82
83#[derive(Clone, Debug, Eq, PartialEq)]
94pub struct LandedCaptureRecord {
95 pub commit: String,
97 pub transaction_id: String,
99 pub worktree_id: String,
101 pub sequence: i64,
103 pub next_sequence_after_landing: i64,
105 pub worktree_paths: Vec<String>,
108}
109
110fn record_object(text: &str, name: &str) -> Result<Map<String, Value>, CaptureError> {
122 match parse_object(text) {
123 Some(map) => return Ok(map),
124 None => return Err(CaptureError(format!("{name} is not a JSON object."))),
125 }
126}
127
128fn malformed(name: &str, key: &str) -> CaptureError {
138 return CaptureError(format!("{name} has a malformed {key} field."));
139}
140
141fn text_field(map: &Map<String, Value>, key: &str, name: &str) -> Result<String, CaptureError> {
151 match map.get(key) {
152 Some(Value::String(text)) if !text.is_empty() => return Ok(text.clone()),
153 _ => return Err(malformed(name, key)),
154 }
155}
156
157fn positive_field(map: &Map<String, Value>, key: &str, name: &str) -> Result<i64, CaptureError> {
167 match map.get(key).and_then(safe_integer) {
168 Some(number) if number >= 1 => return Ok(number),
169 _ => return Err(malformed(name, key)),
170 }
171}
172
173fn paths_field(
183 map: &Map<String, Value>,
184 key: &str,
185 name: &str,
186) -> Result<Vec<String>, CaptureError> {
187 let Some(Value::Array(items)) = map.get(key) else {
188 return Err(malformed(name, key));
189 };
190 let mut paths: Vec<String> = Vec::with_capacity(items.len());
192 for item in items {
193 match item {
194 Value::String(path) if !path.is_empty() => paths.push(path.clone()),
195 _ => return Err(malformed(name, key)),
196 }
197 }
198 return Ok(paths);
199}
200
201pub fn parse_captured_record(text: &str) -> Result<CapturedRecord, CaptureError> {
211 let map: Map<String, Value> = record_object(text, CAPTURED_FILENAME)?;
212 if map.get("schemaVersion").and_then(safe_integer) != Some(JOURNAL_SCHEMA_VERSION)
213 || map.get("state").and_then(Value::as_str) != Some("captured")
214 {
215 return Err(CaptureError(format!(
216 "{CAPTURED_FILENAME} is not a schema-version-2 captured record."
217 )));
218 }
219 return Ok(CapturedRecord {
220 worktree_id: text_field(&map, "worktreeId", CAPTURED_FILENAME)?,
221 sequence: positive_field(&map, "sequence", CAPTURED_FILENAME)?,
222 next_sequence_before_base: positive_field(
223 &map,
224 "nextSequenceBeforeBase",
225 CAPTURED_FILENAME,
226 )?,
227 worktree_paths: paths_field(&map, "worktreePaths", CAPTURED_FILENAME)?,
228 });
229}
230
231pub fn encode_captured_record(record: &CapturedRecord) -> String {
242 let mut writer: ObjectWriter = ObjectWriter::new();
244 writer
245 .integer("schemaVersion", JOURNAL_SCHEMA_VERSION)
246 .string("state", "captured")
247 .string("worktreeId", record.worktree_id.as_str())
248 .integer("sequence", record.sequence)
249 .integer("nextSequenceBeforeBase", record.next_sequence_before_base)
250 .raw(
251 "worktreePaths",
252 string_array(record.worktree_paths.as_slice()).as_str(),
253 );
254 return format!("{}\n", writer.finish());
255}
256
257pub fn read_captured_record(directory: &Path) -> Result<Option<CapturedRecord>, CaptureError> {
268 match read_store_file(directory.join(CAPTURED_FILENAME).as_path())? {
269 Some(text) => return Ok(Some(parse_captured_record(text.as_str())?)),
270 None => return Ok(None),
271 }
272}
273
274pub fn landed_record_directory(git_dir: &Path) -> PathBuf {
284 return capture_store_path(git_dir).join(LANDED_DIRECTORY_NAME);
285}
286
287pub fn parse_landed_capture_record(
297 text: &str,
298 name: &str,
299) -> Result<LandedCaptureRecord, CaptureError> {
300 let map: Map<String, Value> = record_object(text, name)?;
301 if map.get("schemaVersion").and_then(safe_integer) != Some(LANDED_SCHEMA_VERSION) {
302 return Err(CaptureError(format!(
303 "{name} is not a schema-version-1 landed-capture record."
304 )));
305 }
306 return Ok(LandedCaptureRecord {
307 commit: text_field(&map, "commit", name)?,
308 transaction_id: text_field(&map, "transactionId", name)?,
309 worktree_id: text_field(&map, "worktreeId", name)?,
310 sequence: positive_field(&map, "sequence", name)?,
311 next_sequence_after_landing: positive_field(&map, "nextSequenceAfterLanding", name)?,
312 worktree_paths: paths_field(&map, "worktreePaths", name)?,
313 });
314}
315
316pub fn encode_landed_capture_record(record: &LandedCaptureRecord) -> String {
327 let mut writer: ObjectWriter = ObjectWriter::new();
329 writer
330 .integer("schemaVersion", LANDED_SCHEMA_VERSION)
331 .string("commit", record.commit.as_str())
332 .string("transactionId", record.transaction_id.as_str())
333 .string("worktreeId", record.worktree_id.as_str())
334 .integer("sequence", record.sequence)
335 .integer(
336 "nextSequenceAfterLanding",
337 record.next_sequence_after_landing,
338 )
339 .raw(
340 "worktreePaths",
341 string_array(record.worktree_paths.as_slice()).as_str(),
342 );
343 return format!("{}\n", writer.finish());
344}
345
346pub fn record_landed_capture(
361 git_dir: &Path,
362 transaction_directory: &Path,
363 transaction_id: &str,
364 landed_oid: &str,
365) -> Result<bool, CaptureError> {
366 let Some(captured) = read_captured_record(transaction_directory)? else {
367 debug(
368 "recordLandedCapture",
369 format!(
370 "transaction {transaction_id} captured without capture order; {landed_oid} gets no landed record"
371 )
372 .as_str(),
373 );
374 return Ok(false);
375 };
376 ensure_capture_store(git_dir)?;
377 let directory: PathBuf = landed_record_directory(git_dir);
378 if let Err(error) = ensure_transaction_root(directory.as_path()) {
379 return Err(CaptureError(error.0));
380 }
381 let record: LandedCaptureRecord = LandedCaptureRecord {
382 commit: String::from(landed_oid),
383 transaction_id: String::from(transaction_id),
384 worktree_id: captured.worktree_id,
385 sequence: captured.sequence,
386 next_sequence_after_landing: read_next_capture_sequence(git_dir)?,
387 worktree_paths: captured.worktree_paths,
388 };
389 let path: PathBuf = directory.join(format!("{landed_oid}{LANDED_RECORD_SUFFIX}"));
390 match write_private_file_unsynced(
392 path.as_path(),
393 encode_landed_capture_record(&record).as_bytes(),
394 ) {
395 Ok(()) => {
396 debug(
397 "recordLandedCapture",
398 format!(
399 "recorded capture {} for landed {landed_oid}",
400 record.sequence
401 )
402 .as_str(),
403 );
404 }
405 Err(error) if error.kind() == std::io::ErrorKind::AlreadyExists => {
406 debug(
407 "recordLandedCapture",
408 format!("landed record of {landed_oid} already exists: {error}").as_str(),
409 );
410 }
411 Err(error) => {
412 return Err(CaptureError(format!(
413 "writing {} failed: {error}",
414 path.display()
415 )));
416 }
417 }
418 return Ok(true);
419}
420
421pub fn record_landed_capture_or_warn(
436 git_dir: &Path,
437 transaction_directory: &Path,
438 transaction_id: &str,
439 landed_oid: &str,
440) -> bool {
441 match record_landed_capture(git_dir, transaction_directory, transaction_id, landed_oid) {
442 Ok(recorded) => return recorded,
443 Err(error) => {
444 warn(
445 "recordLandedCaptureOrWarn",
446 format!(
447 "could not record the capture of landed {landed_oid}; later replays over it use subsumption: {error}"
448 )
449 .as_str(),
450 );
451 return false;
452 }
453 }
454}
455
456pub fn list_landed_record_names(git_dir: &Path) -> Result<Vec<String>, CaptureError> {
467 let directory: PathBuf = landed_record_directory(git_dir);
468 let listing: std::fs::ReadDir = match std::fs::read_dir(&directory) {
469 Ok(found) => found,
470 Err(error) if error.kind() == std::io::ErrorKind::NotFound => {
471 debug(
472 "listLandedRecordNames",
473 format!("no landed-capture records in {}", git_dir.display()).as_str(),
474 );
475 return Ok(Vec::new());
476 }
477 Err(error) => {
478 return Err(CaptureError(format!(
479 "listing {} failed: {error}",
480 directory.display()
481 )));
482 }
483 };
484 let mut names: Vec<String> = Vec::new();
486 for item in listing {
487 match item {
488 Ok(entry) => names.push(entry.file_name().to_string_lossy().into_owned()),
489 Err(error) => {
490 return Err(CaptureError(format!(
491 "listing {} failed: {error}",
492 directory.display()
493 )));
494 }
495 }
496 }
497 return Ok(names);
498}
499
500fn pinning_bound(
514 registry_root: &Path,
515 worktree_id: Option<&str>,
516) -> Result<Option<i64>, CaptureError> {
517 let entries: Vec<RegistryEntry> = match list_transaction_entries(registry_root) {
518 Ok(found) => found,
519 Err(error) => return Err(CaptureError(error.0)),
520 };
521 let mut bound: i64 = i64::MAX;
523 for entry in entries {
524 if entry.kind != EntryKind::Transaction {
525 continue;
526 }
527 let Some(captured) = read_captured_record(entry.path.as_path())? else {
528 return Ok(None);
529 };
530 if Some(captured.worktree_id.as_str()) == worktree_id {
531 bound = bound.min(captured.next_sequence_before_base);
532 }
533 }
534 return Ok(Some(bound));
535}
536
537fn prunable(directory: &Path, name: &str, worktree_id: Option<&str>, bound: i64) -> bool {
553 if !name.ends_with(LANDED_RECORD_SUFFIX) {
554 return true;
555 }
556 let path: PathBuf = directory.join(name);
557 let name_text: String = path.display().to_string();
558 let parsed: Result<LandedCaptureRecord, CaptureError> = match read_store_file(path.as_path()) {
559 Ok(Some(text)) => parse_landed_capture_record(text.as_str(), name_text.as_str()),
560 Ok(None) => Err(CaptureError(format!("{name_text} vanished"))),
561 Err(error) => Err(error),
562 };
563 match parsed {
564 Ok(record) => {
565 return Some(record.worktree_id.as_str()) != worktree_id
566 || record.next_sequence_after_landing < bound;
567 }
568 Err(error) => {
569 warn(
570 "pruneLandedCaptures",
571 format!("removing unreadable landed-capture record {name_text}: {error}").as_str(),
572 );
573 return true;
574 }
575 }
576}
577
578pub fn prune_landed_captures(git_dir: &Path, registry_root: &Path) -> usize {
593 match prune_or_fail(git_dir, registry_root) {
594 Ok(count) => return count,
595 Err(error) => {
596 warn(
597 "pruneLandedCaptures",
598 format!(
599 "could not prune landed-capture records in {}; they stay for the next pruning: {error}",
600 git_dir.display()
601 )
602 .as_str(),
603 );
604 return 0;
605 }
606 }
607}
608
609fn prune_or_fail(git_dir: &Path, registry_root: &Path) -> Result<usize, CaptureError> {
620 let names: Vec<String> = list_landed_record_names(git_dir)?;
621 if names.is_empty() {
622 return Ok(0);
623 }
624 let worktree_id: Option<String> = read_worktree_id(git_dir)?;
625 let Some(bound) = pinning_bound(registry_root, worktree_id.as_deref())? else {
626 debug(
627 "pruneLandedCaptures",
628 "a published transaction has not captured yet; keeping every landed-capture record",
629 );
630 return Ok(0);
631 };
632 let directory: PathBuf = landed_record_directory(git_dir);
633 let mut removed: usize = 0;
635 for name in &names {
636 if !prunable(
637 directory.as_path(),
638 name.as_str(),
639 worktree_id.as_deref(),
640 bound,
641 ) {
642 continue;
643 }
644 let path: PathBuf = directory.join(name);
645 match std::fs::remove_file(&path) {
646 Ok(()) => removed += 1,
647 Err(error) if error.kind() == std::io::ErrorKind::NotFound => removed += 1,
648 Err(error) => {
649 return Err(CaptureError(format!(
650 "removing {} failed: {error}",
651 path.display()
652 )));
653 }
654 }
655 }
656 debug(
657 "pruneLandedCaptures",
658 format!("pruned {removed} of {} landed-capture records", names.len()).as_str(),
659 );
660 return Ok(removed);
661}
662
663const _: () = assert!(MAX_SAFE_INTEGER < i64::MAX);
665
666#[cfg(test)]
668#[path = "capture_records_tests.rs"]
669mod tests;