Skip to main content

git_policy_cli/worktree_copying/
worktree_copy_journal.rs

1//! What:
2//!  The durable journal of one destination's linked-worktree copy:
3//!  its exact JSON encoding, strict validation of every field recovery will act on,
4//!  private storage under `<common>/cli-git-worktree-copy/v1`,
5//!  and removal of a finished transaction's stage and journal.
6//! Why:
7//!  A crash can happen after any step, so the journal must say which phase the copy reached
8//!  and which paths it may have touched, and recovery must never act on a journal it cannot prove
9//!  is its own.
10//!
11//! In TS you'd write (pseudocode):
12//! ```ts
13//! await writeJournal({ path, record });
14//! const pending = await readPendingWorktreeCopyJournals(commonDir);
15//! ```
16
17use crate::json_record::quote;
18use crate::parallel_budget::{HostResources, sliced_workers};
19use crate::private_storage::{sync_directory, write_exclusive_scratch, write_exclusive_synced};
20use crate::random_id::random_uuid;
21use crate::worktree_copy_error::{WorktreeCopyError, failure_with_cause, raw_failure};
22use crate::worktree_copy_install_entry::InstalledPath;
23use crate::worktree_copy_owner::{CopyOwner, current_copy_owner, token_owner_is_dead};
24use crate::worktree_copy_selection::{STAGE_PREFIX, assert_safe_repository_path, js_order};
25use crate::worktree_copy_stage::{PARALLEL_COPY_MAX_WORKERS, StagedSnapshot};
26use std::path::{Path, PathBuf};
27
28/// What:
29///  The parent directory name of the journals, beneath the common Git directory.
30/// Why:
31///   Every linked worktree of one repository shares the same journal location.
32///
33/// In TS you'd write (pseudocode):
34/// ```ts
35/// const JOURNAL_PARENT_NAME = 'cli-git-worktree-copy';
36/// ```
37pub const JOURNAL_PARENT_NAME: &str = "cli-git-worktree-copy";
38
39/// What:
40///  The schema directory of the journals.
41/// Why:
42///   A later schema gets its own directory, so an older wrapper never reads a newer record.
43///
44/// In TS you'd write (pseudocode):
45/// ```ts
46/// const JOURNAL_VERSION_NAME = 'v1';
47/// ```
48pub const JOURNAL_VERSION_NAME: &str = "v1";
49
50/// What:
51///  The suffix of a final journal file.
52/// Why:
53///   Only files with this suffix are journals; anything else in the directory is ignored.
54///
55/// In TS you'd write (pseudocode):
56/// ```ts
57/// const JOURNAL_SUFFIX = '.json';
58/// ```
59const JOURNAL_SUFFIX: &str = ".json";
60
61/// What:
62///  The suffix of an unpublished temporary journal write.
63/// Why:
64///   An interrupted write leaves only files with this suffix, which recovery removes.
65///
66/// In TS you'd write (pseudocode):
67/// ```ts
68/// const TEMPORARY_SUFFIX = '.tmp';
69/// ```
70pub const TEMPORARY_SUFFIX: &str = ".tmp";
71
72/// What:
73///  The group and other permission bits a private path must not have.
74/// Why:
75///   A journal or stage readable or writable by others could be planted or read by another account.
76///
77/// In TS you'd write (pseudocode):
78/// ```ts
79/// const NON_PRIVATE_MODE_BITS = 0o077;
80/// ```
81const NON_PRIVATE_MODE_BITS: u32 = 0o077;
82
83/// What:
84///  The durable phase a copy has reached.
85/// Why:
86///   Recovery resumes an installation and finishes a completed cleanup differently.
87///
88/// In TS you'd write (pseudocode):
89/// ```ts
90/// type WorktreeCopyJournalPhase = 'complete' | 'installing' | 'staged';
91/// ```
92#[derive(Clone, Copy, Debug, Eq, PartialEq)]
93pub enum Phase {
94    /// The stage is complete and nothing has been installed.
95    Staged,
96    /// Installation has begun; the intents and creations in the install log say what it touched.
97    Installing,
98    /// Installation finished; only cleanup remains.
99    Complete,
100}
101
102impl Phase {
103    /// The phase's name as the incumbent writes it.
104    fn as_str(self) -> &'static str {
105        // `match` keeps each phase's spelling next to its variant.
106        match self {
107            Phase::Staged => return "staged",
108            Phase::Installing => return "installing",
109            Phase::Complete => return "complete",
110        }
111    }
112}
113
114/// What:
115///  One durable journal record.
116/// Why:
117///   Every field is read back by recovery before any filesystem path is used.
118///
119/// In TS you'd write (pseudocode):
120/// ```ts
121/// type WorktreeCopyJournal = { createdEntries; intendedEntries; destinationRoot; phase; selectedRoots; sourceRoot; stageContainer; stageRoot; version: 1 };
122/// ```
123#[derive(Clone, Debug, Eq, PartialEq)]
124pub struct Journal {
125    /// Paths proven created, with their post-creation identities.
126    pub created_entries: Vec<InstalledPath>,
127    /// Selected paths claimed before installation.
128    pub intended_entries: Vec<String>,
129    /// The worktree receiving the copy.
130    pub destination_root: String,
131    /// The durable phase.
132    pub phase: Phase,
133    /// The ignored roots the staged payload represents.
134    pub selected_roots: Vec<String>,
135    /// The source worktree.
136    pub source_root: String,
137    /// The private container beside the destination.
138    pub stage_container: String,
139    /// The payload root inside the container.
140    pub stage_root: String,
141}
142
143/// What:
144///  A journal and the path it was read from or written to.
145/// Why:
146///   Removal and recovery need both the record and the file that must be deleted.
147///
148/// In TS you'd write (pseudocode):
149/// ```ts
150/// type PendingWorktreeCopyJournal = { path: string; record: WorktreeCopyJournal };
151/// ```
152#[derive(Clone, Debug, Eq, PartialEq)]
153pub struct PendingJournal {
154    /// Absolute journal file path.
155    pub path: PathBuf,
156    /// The validated record.
157    pub journal: Journal,
158}
159
160impl PendingJournal {
161    /// The owner token the journal's file name carries, or nothing for a name no owner is written into.
162    pub fn owner_token(&self) -> Option<String> {
163        return owner_token_of(self.path.as_path());
164    }
165}
166
167/// What:
168///  The owner token in a journal file name `<id>.<owner>.json`, if the name has that shape.
169/// Why:
170///   The owner is part of the name so that taking a journal over is one atomic rename.
171///
172/// In TS you'd write (pseudocode):
173/// ```ts
174/// ownerTokenOf('0a1b.2c3d.json') === '2c3d';
175/// ```
176pub fn owner_token_of(path: &Path) -> Option<String> {
177    let name: String = path.file_name()?.to_string_lossy().into_owned();
178    let stem: &str = name.strip_suffix(JOURNAL_SUFFIX)?;
179    let (_identifier, token) = stem.rsplit_once('.')?;
180    return Some(token.to_string());
181}
182
183/// What:
184///  The JSON text of one journal, with the incumbent's key order and a final line feed.
185/// Why:
186///   The incumbent reads these bytes, so the order and spelling must match its `JSON.stringify` output.
187///
188/// In TS you'd write (pseudocode):
189/// ```ts
190/// `${JSON.stringify(serializedRecord)}\n`
191/// ```
192pub fn encode_journal(journal: &Journal) -> String {
193    let created: Vec<String> = journal
194        .created_entries
195        .iter()
196        .map(encode_installed_entry)
197        .collect();
198    return format!(
199        "{{\"createdEntries\":[{}],\"intendedEntries\":{},\"destinationRoot\":{},\"phase\":{},\"selectedRoots\":{},\"sourceRoot\":{},\"stageContainer\":{},\"stageRoot\":{},\"version\":1}}\n",
200        created.join(","),
201        string_array(journal.intended_entries.as_slice()),
202        quote(journal.destination_root.as_str()),
203        quote(journal.phase.as_str()),
204        string_array(journal.selected_roots.as_slice()),
205        quote(journal.source_root.as_str()),
206        quote(journal.stage_container.as_str()),
207        quote(journal.stage_root.as_str()),
208    );
209}
210
211/// What:
212///  One created entry as its JSON object, with the incumbent's key order.
213/// Why:
214///   The install log and the journal share this spelling, so both wrappers read either.
215///
216/// In TS you'd write (pseudocode):
217/// ```ts
218/// JSON.stringify({ device, inode, relativePath, selected })
219/// ```
220pub fn encode_installed_entry(entry: &InstalledPath) -> String {
221    return format!(
222        "{{\"device\":\"{}\",\"inode\":\"{}\",\"relativePath\":{},\"selected\":{}}}",
223        entry.device,
224        entry.inode,
225        quote(entry.relative_path.as_str()),
226        entry.selected
227    );
228}
229
230/// What:
231///  A JSON array of strings, each quoted as `JSON.stringify` quotes it.
232/// Why:
233///   Repository paths are the only arrays a journal holds.
234///
235/// In TS you'd write (pseudocode):
236/// ```ts
237/// JSON.stringify(items)
238/// ```
239pub fn string_array(items: &[String]) -> String {
240    let quoted: Vec<String> = items
241        .iter()
242        .map(|item| return quote(item.as_str()))
243        .collect();
244    return format!("[{}]", quoted.join(","));
245}
246
247/// What:
248///  Whether a path is absolute and already in lexical canonical form.
249/// Why:
250///   A journal path that is not canonical could name a different file than the one validated.
251///
252/// In TS you'd write (pseudocode):
253/// ```ts
254/// isAbsolute(value) && resolve(value) === value
255/// ```
256fn is_canonical_absolute(value: &str) -> bool {
257    if !value.starts_with('/') {
258        return false;
259    }
260    if value == "/" {
261        return true;
262    }
263    return value[1..]
264        .split('/')
265        .all(|component| return !component.is_empty() && component != "." && component != "..");
266}
267
268/// What:
269///  Whether a text is a canonical decimal identity: digits only, no leading zero.
270/// Why:
271///   Device and inode numbers are compared as text, so only one spelling of each may exist.
272///
273/// In TS you'd write (pseudocode):
274/// ```ts
275/// function isDecimalIdentity(value: unknown): value is string;
276/// ```
277fn decimal_identity(value: &serde_json::Value) -> Option<u64> {
278    let text: &str = value.as_str()?;
279    if text.is_empty() || (text.len() > 1 && text.starts_with('0')) {
280        return None;
281    }
282    if !text.bytes().all(|byte| return byte.is_ascii_digit()) {
283        return None;
284    }
285    return text.parse::<u64>().ok();
286}
287
288/// What:
289///  One created-entry object, read strictly.
290/// Why:
291///   An identity with any wrong type is not an identity cli-git wrote.
292///
293/// In TS you'd write (pseudocode):
294/// ```ts
295/// function isInstalledEntry(value: unknown): value is InstalledWorktreePath;
296/// ```
297pub fn installed_entry(value: &serde_json::Value) -> Option<InstalledPath> {
298    let object = value.as_object()?;
299    return Some(InstalledPath {
300        device: decimal_identity(object.get("device")?)?,
301        inode: decimal_identity(object.get("inode")?)?,
302        relative_path: object.get("relativePath")?.as_str()?.to_string(),
303        selected: object.get("selected")?.as_bool()?,
304    });
305}
306
307/// What:
308///  An array of strings, read strictly.
309/// Why:
310///   A non-string path would reach the filesystem without the validation a string gets.
311///
312/// In TS you'd write (pseudocode):
313/// ```ts
314/// isStringArray(value)
315/// ```
316fn string_list(value: Option<&serde_json::Value>) -> Option<Vec<String>> {
317    let array = value?.as_array()?;
318    return array
319        .iter()
320        .map(|item| return item.as_str().map(str::to_string))
321        .collect();
322}
323
324/// What:
325///  The record's fields read strictly, before any path is checked.
326/// Why:
327///   The shape check refuses every record that a later check would have to guess about.
328///
329/// In TS you'd write (pseudocode):
330/// ```ts
331/// if (!(isRecord(value) && value.version === 1 && ...)) throw new WorktreeCopyError('... is corrupt ...');
332/// ```
333fn read_shape(value: &serde_json::Value) -> Option<Journal> {
334    let object = value.as_object()?;
335    if object.get("version")?.as_f64()? != 1.0 {
336        return None;
337    }
338    let phase: Phase = match object.get("phase")?.as_str()? {
339        "staged" => Phase::Staged,
340        "installing" => Phase::Installing,
341        "complete" => Phase::Complete,
342        _ => return None,
343    };
344    let created_entries: Vec<InstalledPath> = object
345        .get("createdEntries")?
346        .as_array()?
347        .iter()
348        .map(installed_entry)
349        .collect::<Option<Vec<InstalledPath>>>()?;
350    return Some(Journal {
351        created_entries,
352        intended_entries: string_list(object.get("intendedEntries"))?,
353        destination_root: object.get("destinationRoot")?.as_str()?.to_string(),
354        phase,
355        selected_roots: string_list(object.get("selectedRoots"))?,
356        source_root: object.get("sourceRoot")?.as_str()?.to_string(),
357        stage_container: object.get("stageContainer")?.as_str()?.to_string(),
358        stage_root: object.get("stageRoot")?.as_str()?.to_string(),
359    });
360}
361
362/// What:
363///  Parses and validates one journal's text, refusing every shape recovery cannot trust.
364/// Why:
365///   Recovery turns journal paths into deletions, so every relation between the paths is checked first.
366///
367/// In TS you'd write (pseudocode):
368/// ```ts
369/// validateJournalValue({ value: JSON.parse(text), path, journalRoot });
370/// ```
371pub fn parse_journal(
372    text: &str,
373    path: &Path,
374    journal_root: &Path,
375) -> Result<Journal, WorktreeCopyError> {
376    let value: serde_json::Value = serde_json::from_str(text).map_err(|error| {
377        let message: String = format!(
378            "cli-git: could not read worktree-copy journal {}.",
379            quote(path.to_string_lossy().as_ref())
380        );
381        return failure_with_cause(message.as_str(), Some(&error));
382    })?;
383    let Some(journal) = read_shape(&value) else {
384        return Err(corrupt(path));
385    };
386    if path.parent() != Some(journal_root) {
387        return Err(failure_with_cause(
388            format!(
389                "cli-git: worktree-copy journal escaped private root: {}.",
390                quote(path.to_string_lossy().as_ref())
391            )
392            .as_str(),
393            None,
394        ));
395    }
396    for (field, field_value) in [
397        ("destinationRoot", journal.destination_root.as_str()),
398        ("sourceRoot", journal.source_root.as_str()),
399        ("stageContainer", journal.stage_container.as_str()),
400        ("stageRoot", journal.stage_root.as_str()),
401    ] {
402        if !is_canonical_absolute(field_value) {
403            return Err(failure_with_cause(
404                format!(
405                    "cli-git: worktree-copy journal has unsafe {field}: {}.",
406                    quote(field_value)
407                )
408                .as_str(),
409                None,
410            ));
411        }
412    }
413    let stage_relation: bool = Path::new(journal.stage_container.as_str()).parent()
414        == Path::new(journal.destination_root.as_str()).parent()
415        && Path::new(journal.stage_container.as_str())
416            .file_name()
417            .is_some_and(|name| return name.to_string_lossy().starts_with(STAGE_PREFIX))
418        && journal.stage_root == format!("{}/payload", journal.stage_container);
419    if !stage_relation {
420        return Err(failure_with_cause(
421            format!(
422                "cli-git: worktree-copy journal has unsafe private stage relation: {}.",
423                quote(path.to_string_lossy().as_ref())
424            )
425            .as_str(),
426            None,
427        ));
428    }
429    for selected in &journal.selected_roots {
430        assert_safe_repository_path(selected)?;
431    }
432    for intended in &journal.intended_entries {
433        assert_safe_repository_path(intended)?;
434    }
435    for created in &journal.created_entries {
436        assert_safe_repository_path(created.relative_path.as_str())?;
437    }
438    let created_paths: Vec<&str> = journal
439        .created_entries
440        .iter()
441        .map(|entry| return entry.relative_path.as_str())
442        .collect();
443    let duplicated: bool = has_duplicates(journal.selected_roots.as_slice())
444        || has_duplicates(journal.intended_entries.as_slice())
445        || has_duplicates(created_paths.as_slice());
446    if duplicated {
447        return Err(failure_with_cause(
448            format!(
449                "cli-git: worktree-copy journal contains duplicate repository paths: {}.",
450                quote(path.to_string_lossy().as_ref())
451            )
452            .as_str(),
453            None,
454        ));
455    }
456    return Ok(journal);
457}
458
459/// What:
460///  Whether a list holds the same entry twice.
461/// Why:
462///   Duplicate claims would make rollback ownership ambiguous.
463///
464/// In TS you'd write (pseudocode):
465/// ```ts
466/// new Set(items).size !== items.length
467/// ```
468fn has_duplicates<T: AsRef<str>>(items: &[T]) -> bool {
469    let mut seen: std::collections::BTreeSet<&str> = std::collections::BTreeSet::new();
470    return items.iter().any(|item| return !seen.insert(item.as_ref()));
471}
472
473/// What:
474///  The corruption failure naming one journal.
475/// Why:
476///   The user learns which file to inspect; the file itself is kept.
477///
478/// In TS you'd write (pseudocode):
479/// ```ts
480/// new WorktreeCopyError(`cli-git: worktree-copy journal is corrupt: ${JSON.stringify(path)}.`)
481/// ```
482fn corrupt(path: &Path) -> WorktreeCopyError {
483    return failure_with_cause(
484        format!(
485            "cli-git: worktree-copy journal is corrupt: {}.",
486            quote(path.to_string_lossy().as_ref())
487        )
488        .as_str(),
489        None,
490    );
491}
492
493/// What:
494///  The role a private path plays, which selects its expected entry kind.
495/// Why:
496///   A journal file must be a single-link file; a directory must be a directory.
497///
498/// In TS you'd write (pseudocode):
499/// ```ts
500/// type PrivateWorktreeCopyPathRole = 'journal directory' | 'journal file' | 'private stage';
501/// ```
502#[derive(Clone, Copy, Debug, Eq, PartialEq)]
503pub enum Role {
504    /// A journal parent or root directory.
505    JournalDirectory,
506    /// A journal file.
507    JournalFile,
508    /// A private stage container or payload.
509    PrivateStage,
510}
511
512impl Role {
513    /// The role's name in diagnostics.
514    fn name(self) -> &'static str {
515        // `match` keeps each role's diagnostic spelling next to its variant.
516        match self {
517            Role::JournalDirectory => return "journal directory",
518            Role::JournalFile => return "journal file",
519            Role::PrivateStage => return "private stage",
520        }
521    }
522}
523
524/// What:
525///  Checks that a path is canonical, owned by this account, private, and of the role's kind.
526/// Why:
527///   A journal or stage that another account can write, or that a link redirects, is never acted on.
528///
529/// In TS you'd write (pseudocode):
530/// ```ts
531/// await assertPrivateWorktreeCopyPath({ path, role });
532/// ```
533pub fn assert_private_path(path: &Path, role: Role) -> Result<(), WorktreeCopyError> {
534    use std::os::unix::fs::MetadataExt;
535    let metadata: std::fs::Metadata =
536        std::fs::symlink_metadata(path).map_err(|error| return raw_failure(&error))?;
537    let shape: bool = match role {
538        Role::JournalFile => metadata.is_file() && metadata.nlink() == 1,
539        Role::JournalDirectory | Role::PrivateStage => metadata.is_dir(),
540    };
541    // SAFETY: `geteuid` has no preconditions and cannot fail.
542    let effective_user: u32 = unsafe { libc::geteuid() };
543    let canonical: bool = std::fs::canonicalize(path)
544        .map_err(|error| return raw_failure(&error))?
545        .as_path()
546        == path;
547    if !shape
548        || (metadata.mode() & NON_PRIVATE_MODE_BITS) != 0
549        || metadata.uid() != effective_user
550        || !canonical
551    {
552        return Err(failure_with_cause(
553            format!(
554                "cli-git: worktree-copy {} is unsafe: {}.",
555                role.name(),
556                quote(path.to_string_lossy().as_ref())
557            )
558            .as_str(),
559            None,
560        ));
561    }
562    return Ok(());
563}
564
565/// What:
566///  The journal root beneath a common Git directory, without creating anything.
567/// Why:
568///   Readers check for pending state before they decide whether to create it.
569///
570/// In TS you'd write (pseudocode):
571/// ```ts
572/// join(commonDir, 'cli-git-worktree-copy', 'v1')
573/// ```
574pub fn journal_root(common_dir: &Path) -> PathBuf {
575    return common_dir
576        .join(JOURNAL_PARENT_NAME)
577        .join(JOURNAL_VERSION_NAME);
578}
579
580/// What:
581///  Creates one private directory, or accepts an existing one, and checks it.
582/// Why:
583///   An interrupted creation or a second writer must not fail the other writer.
584///
585/// In TS you'd write (pseudocode):
586/// ```ts
587/// await mkdir(path, { mode: 0o700 }) // EEXIST accepted; then assertPrivateWorktreeCopyPath
588/// ```
589fn ensure_private_directory(path: &Path) -> Result<(), WorktreeCopyError> {
590    use std::os::unix::fs::DirBuilderExt;
591    // `match` accepts an existing directory and reports every other creation failure.
592    match std::fs::DirBuilder::new().mode(0o700).create(path) {
593        Ok(()) => {}
594        Err(error) if error.kind() == std::io::ErrorKind::AlreadyExists => {}
595        Err(error) => return Err(raw_failure(&error)),
596    }
597    return assert_private_path(path, Role::JournalDirectory);
598}
599
600/// What:
601///  Creates and checks the journal parent and root, and returns the root.
602/// Why:
603///   Every writer needs the same private location before it writes.
604///
605/// In TS you'd write (pseudocode):
606/// ```ts
607/// await ensureWorktreeCopyJournalRoot(commonDir);
608/// ```
609pub fn ensure_journal_root(common_dir: &Path) -> Result<PathBuf, WorktreeCopyError> {
610    ensure_private_directory(common_dir.join(JOURNAL_PARENT_NAME).as_path())?;
611    let root: PathBuf = journal_root(common_dir);
612    ensure_private_directory(root.as_path())?;
613    return Ok(root);
614}
615
616/// What:
617///  Writes one complete journal atomically: a private exclusive temporary, synced, renamed, and its directory synced.
618/// Why:
619///   A reader sees either the previous complete journal or the new complete one, never a fragment.
620///
621/// In TS you'd write (pseudocode):
622/// ```ts
623/// await writeJournal({ path, record });
624/// ```
625pub fn write_journal(path: &Path, journal: &Journal) -> Result<(), WorktreeCopyError> {
626    return write_atomic(path, encode_journal(journal).as_bytes(), "journal");
627}
628
629/// What:
630///  The name of the temporary file a write of `path` by `owner` uses: `<path>.<owner>.<unique>.tmp`.
631/// Why:
632///   The writer's owner is part of the temporary's name, so a sweep removes only a dead writer's temporaries and can never
633///  delete one a live writer is about to rename.
634///
635/// In TS you'd write (pseudocode):
636/// ```ts
637/// temporaryPath('/j/abc.stage', owner, 'u1') === `/j/abc.stage.${owner.token()}.u1.tmp`;
638/// ```
639pub fn temporary_path(path: &Path, owner: &CopyOwner, unique: &str) -> PathBuf {
640    return PathBuf::from(format!("{}.{}.{unique}{TEMPORARY_SUFFIX}", path.to_string_lossy(), owner.token()));
641}
642
643/// What:
644///  The writer's owner token in a temporary file's name, or nothing for a name of another shape.
645/// Why:
646///   It is the inverse of [`temporary_path`] that the sweep of dead writers' temporaries reads.
647///
648/// In TS you'd write (pseudocode):
649/// ```ts
650/// writerTokenOf('abc.stage.<owner>.u1.tmp') === '<owner>';
651/// ```
652pub fn temporary_writer_token(file_name: &str) -> Option<&str> {
653    let stem: &str = file_name.strip_suffix(TEMPORARY_SUFFIX)?;
654    let (rest, _unique) = stem.rsplit_once('.')?;
655    let (_item, token) = rest.rsplit_once('.')?;
656    return Some(token);
657}
658
659/// What:
660///  Writes one private file atomically beside its final name; a failure names `subject` and leaves no temporary.
661/// Why:
662///   Journals and stage claims share one rule: a reader sees a complete file or none.
663///
664/// In TS you'd write (pseudocode):
665/// ```ts
666/// await writeFileAtomically({ path, bytes, subject });
667/// ```
668pub fn write_atomic(path: &Path, bytes: &[u8], subject: &str) -> Result<(), WorktreeCopyError> {
669    return write_atomic_flushing(path, bytes, subject, Flush::ToDisk);
670}
671
672/// Whether a record written by [`write_atomic_flushing`] is flushed to disk before the call returns.
673#[derive(Clone, Copy, Debug, Eq, PartialEq)]
674enum Flush {
675    /// The file and its directory are flushed: a crash finds the record.
676    ToDisk,
677    /// Neither is flushed: the file is published whole by the rename, and the filesystem makes it durable with its next commit.
678    Never,
679}
680
681/// What:
682///  Writes one private file atomically beside its final name without flushing the file or its directory to disk; a failure
683///  names `subject` and leaves no temporary.
684/// Why:
685///   A flush after a subvolume was swapped into place in the same btrfs transaction is a commit of the whole filesystem (the
686///  kernel sets `btrfs_set_log_full_commit` for the swap), which waits for every other writer's data. A record that only
687///  silences a notice is published whole by the rename and made durable by the commit the filesystem makes anyway.
688///
689/// In TS you'd write (pseudocode):
690/// ```ts
691/// await writeFileAtomicallyUnflushed({ path, bytes, subject });
692/// ```
693pub fn write_atomic_unsynced(path: &Path, bytes: &[u8], subject: &str) -> Result<(), WorktreeCopyError> {
694    return write_atomic_flushing(path, bytes, subject, Flush::Never);
695}
696
697/// `write_atomic` and `write_atomic_unsynced`: one rename publishes the whole file; `flush` says whether the file and its
698///  directory are flushed.
699fn write_atomic_flushing(path: &Path, bytes: &[u8], subject: &str, flush: Flush) -> Result<(), WorktreeCopyError> {
700    let unique: String = random_uuid().map_err(|error| {
701        return failure_with_cause(
702            format!(
703                "cli-git: could not persist worktree-copy {subject} {}.",
704                quote(path.to_string_lossy().as_ref())
705            )
706            .as_str(),
707            Some(&error.0),
708        );
709    })?;
710    let temporary: PathBuf = temporary_path(path, &current_copy_owner()?, unique.as_str());
711    let written: std::io::Result<()> = match flush {
712        Flush::ToDisk => write_exclusive_synced(temporary.as_path(), bytes),
713        Flush::Never => write_exclusive_scratch(temporary.as_path(), bytes),
714    };
715    let persisted: std::io::Result<()> =
716        written
717            .and_then(|()| return std::fs::rename(temporary.as_path(), path))
718            .and_then(|()| match (flush, path.parent()) {
719                (Flush::ToDisk, Some(parent)) => return sync_directory(parent),
720                _ => return Ok(()),
721            });
722    // A failed write removes its own temporary, so the directory keeps no partial journal.
723    if let Err(error) = persisted {
724        let _ignored_removal: std::io::Result<()> = std::fs::remove_file(temporary.as_path());
725        return Err(failure_with_cause(
726            format!(
727                "cli-git: could not persist worktree-copy {subject} {}.",
728                quote(path.to_string_lossy().as_ref())
729            )
730            .as_str(),
731            Some(&error),
732        ));
733    }
734    return Ok(());
735}
736
737/// What:
738///  Creates the staged journal of one destination, durable before any destination is touched.
739/// Why:
740///   Recovery needs the stage and destination named before installation starts.
741///
742/// In TS you'd write (pseudocode):
743/// ```ts
744/// await createWorktreeCopyJournal({ commonDir, destinationRoot, snapshot });
745/// ```
746pub fn create_journal(
747    common_dir: &Path,
748    destination_root: &Path,
749    snapshot: &StagedSnapshot,
750) -> Result<PendingJournal, WorktreeCopyError> {
751    return create_journal_for(common_dir, destination_root, snapshot, &current_copy_owner()?);
752}
753
754/// What:
755///  Creates the staged journal of one destination for a named owner, durable before any destination is touched.
756/// Why:
757///   The journal's name carries its owner; a control or a hand-over can name an owner other than the running process.
758///
759/// In TS you'd write (pseudocode):
760/// ```ts
761/// await createWorktreeCopyJournalFor({ commonDir, destinationRoot, snapshot, owner });
762/// ```
763pub fn create_journal_for(
764    common_dir: &Path,
765    destination_root: &Path,
766    snapshot: &StagedSnapshot,
767    owner: &CopyOwner,
768) -> Result<PendingJournal, WorktreeCopyError> {
769    let root: PathBuf = ensure_journal_root(common_dir)?;
770    let unique: String = random_uuid().map_err(|error| return raw_failure(&error.0))?;
771    let path: PathBuf = root.join(format!("{unique}.{}{JOURNAL_SUFFIX}", owner.token()));
772    let journal: Journal = Journal {
773        created_entries: Vec::new(),
774        intended_entries: Vec::new(),
775        destination_root: destination_root.to_string_lossy().into_owned(),
776        phase: Phase::Staged,
777        selected_roots: snapshot.selected_roots.clone(),
778        source_root: snapshot.source_root.to_string_lossy().into_owned(),
779        stage_container: snapshot.stage_container.to_string_lossy().into_owned(),
780        stage_root: snapshot.stage_root.to_string_lossy().into_owned(),
781    };
782    write_journal(path.as_path(), &journal)?;
783    return Ok(PendingJournal { path, journal });
784}
785
786/// What:
787///  Takes over a journal whose owner is dead, by renaming it to a name that carries the new owner.
788/// Why:
789///   One rename is the whole claim: a command that loses the race finds the old name gone and leaves the journal to the
790///  winner, so two commands never install one journal. This is the lock-free single winner for recovery; no file of a lock
791///  exists, none is waited for and none can go stale (if the winner dies, the journal's name shows a dead owner again).
792///
793/// In TS you'd write (pseudocode):
794/// ```ts
795/// const mine = await adoptJournal({ pending, owner }); // undefined when another command won
796/// ```
797pub fn adopt_journal(pending: &PendingJournal, owner: &CopyOwner) -> Result<Option<PendingJournal>, WorktreeCopyError> {
798    let Some(parent) = pending.path.parent() else {
799        return Ok(None);
800    };
801    let name: String = pending.path.file_name().unwrap_or_default().to_string_lossy().into_owned();
802    let identifier: &str = name.split('.').next().unwrap_or_default();
803    let adopted: PathBuf = parent.join(format!("{identifier}.{}{JOURNAL_SUFFIX}", owner.token()));
804    // `match` on the rename: a vanished source means another command took the journal first; any other failure is real.
805    match std::fs::rename(pending.path.as_path(), adopted.as_path()) {
806        Ok(()) => {}
807        Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(None),
808        Err(error) => return Err(raw_failure(&error)),
809    }
810    sync_directory(parent).map_err(|error| return raw_failure(&error))?;
811    return Ok(Some(PendingJournal { path: adopted, journal: pending.journal.clone() }));
812}
813
814/// What:
815///  Whether the journal's owner is dead, so any command may take it over.
816/// Why:
817///   A journal with a live owner belongs to a copy that is still running; recovering it would install it twice.
818///
819/// In TS you'd write (pseudocode):
820/// ```ts
821/// journalOwnerIsDead(pending);
822/// ```
823pub fn journal_owner_is_dead(pending: &PendingJournal) -> bool {
824    return token_owner_is_dead(pending.owner_token().as_deref(), pending.path.as_path());
825}
826
827/// What:
828///  Reads one journal a listing named, or nothing when the file is gone by the time it is read.
829/// Why:
830///   Another command finishes or takes over a journal at any moment, which is no fault of this reader; every other failure,
831///  a corrupt or unsafe journal included, is reported.
832///
833/// In TS you'd write (pseudocode):
834/// ```ts
835/// const pending = await readListedJournal(root, name); // undefined when it vanished
836/// ```
837fn read_listed_journal(root: &Path, name: &str) -> Result<Option<PendingJournal>, WorktreeCopyError> {
838    let path: PathBuf = root.join(name);
839    let loaded: Result<Journal, WorktreeCopyError> = (|| {
840        assert_private_path(path.as_path(), Role::JournalFile)?;
841        let text: String = read_journal_text(path.as_path())?;
842        return parse_journal(text.as_str(), path.as_path(), root);
843    })();
844    // `match` on the load: a vanished journal is skipped; any other failure is reported.
845    match loaded {
846        Ok(journal) => return Ok(Some(PendingJournal { path, journal })),
847        Err(error) => {
848            let vanished: bool = matches!(std::fs::symlink_metadata(path.as_path()), Err(ref lookup) if lookup.kind() == std::io::ErrorKind::NotFound);
849            if vanished {
850                return Ok(None);
851            }
852            return Err(error);
853        }
854    }
855}
856
857/// What:
858///  Lists the journal files of a common directory, validated, in journal-name order.
859/// Why:
860///   Recovery and the pending check read the same validated set.
861///
862/// In TS you'd write (pseudocode):
863/// ```ts
864/// await readPendingWorktreeCopyJournals(commonDir);
865/// ```
866pub fn read_pending_journals(common_dir: &Path) -> Result<Vec<PendingJournal>, WorktreeCopyError> {
867    let root: PathBuf = journal_root(common_dir);
868    // `match` treats an absent root as having no journals, and every other failure as fatal.
869    match std::fs::symlink_metadata(root.as_path()) {
870        Ok(_) => {}
871        Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(Vec::new()),
872        Err(error) => return Err(raw_failure(&error)),
873    }
874    assert_private_path(
875        root.parent().unwrap_or(root.as_path()),
876        Role::JournalDirectory,
877    )?;
878    assert_private_path(root.as_path(), Role::JournalDirectory)?;
879    let mut names: Vec<String> = Vec::new();
880    let listing = std::fs::read_dir(root.as_path()).map_err(|error| {
881        let message: String = format!(
882            "cli-git: could not inspect worktree-copy journals under {}.",
883            quote(root.to_string_lossy().as_ref())
884        );
885        return failure_with_cause(message.as_str(), Some(&error));
886    })?;
887    for listed in listing {
888        let entry: std::fs::DirEntry = listed.map_err(|error| return raw_failure(&error))?;
889        let name: String = entry.file_name().to_string_lossy().into_owned();
890        if name.ends_with(JOURNAL_SUFFIX) {
891            names.push(name);
892        }
893    }
894    names.sort_by(|left, right| return js_order(left, right));
895    let mut pending: Vec<PendingJournal> = Vec::new();
896    for name in names {
897        if let Some(found) = read_listed_journal(root.as_path(), name.as_str())? {
898            pending.push(found);
899        }
900    }
901    return Ok(pending);
902}
903
904/// What:
905///  The text of one journal file, read as UTF-8 with replacement, as the incumbent reads it.
906/// Why:
907///   An unreadable journal is named, not skipped.
908///
909/// In TS you'd write (pseudocode):
910/// ```ts
911/// await readFile(path, 'utf8')
912/// ```
913fn read_journal_text(path: &Path) -> Result<String, WorktreeCopyError> {
914    let bytes: Vec<u8> = std::fs::read(path).map_err(|error| {
915        let message: String = format!(
916            "cli-git: could not read worktree-copy journal {}.",
917            quote(path.to_string_lossy().as_ref())
918        );
919        return failure_with_cause(message.as_str(), Some(&error));
920    })?;
921    return Ok(String::from_utf8_lossy(bytes.as_slice()).into_owned());
922}
923
924/// What:
925///  Makes every directory of a stage owner-accessible before its entries are deleted.
926/// Why:
927///   An installed directory can carry a mode that forbids its own removal; only the stage's own directories are changed.
928///
929/// In TS you'd write (pseudocode):
930/// ```ts
931/// await prepareStageRemoval(stageContainer);
932/// ```
933pub fn prepare_stage_removal(stage_container: &Path) -> Result<(), WorktreeCopyError> {
934    use std::os::unix::fs::PermissionsExt;
935    let mut pending: Vec<PathBuf> = vec![stage_container.to_path_buf()];
936    while let Some(directory) = pending.pop() {
937        // `match` skips a directory already removed and refuses anything else that cannot be inspected.
938        let metadata: std::fs::Metadata = match std::fs::symlink_metadata(directory.as_path()) {
939            Ok(metadata) => metadata,
940            Err(error) if error.kind() == std::io::ErrorKind::NotFound => continue,
941            Err(error) => return Err(raw_failure(&error)),
942        };
943        if !metadata.is_dir() {
944            continue;
945        }
946        std::fs::set_permissions(directory.as_path(), std::fs::Permissions::from_mode(0o700))
947            .map_err(|error| return raw_failure(&error))?;
948        for listed in
949            std::fs::read_dir(directory.as_path()).map_err(|error| return raw_failure(&error))?
950        {
951            let child: std::fs::DirEntry = listed.map_err(|error| return raw_failure(&error))?;
952            let child_type: std::fs::FileType = child
953                .file_type()
954                .map_err(|error| return raw_failure(&error))?;
955            if child_type.is_dir() {
956                pending.push(child.path());
957            }
958        }
959    }
960    return Ok(());
961}
962
963/// What:
964///  Removes a stage container with everything in it, whatever modes the installed directories carry.
965/// Why:
966///   Completed transactions and abandoned stages are removed the same way, and an absent stage is already removed.
967///
968/// In TS you'd write (pseudocode):
969/// ```ts
970/// await prepareStageRemoval(stageContainer); await rm(stageContainer, { recursive: true, force: true });
971/// ```
972pub fn remove_stage(stage_container: &Path) -> Result<(), WorktreeCopyError> {
973    // A stage made by a snapshot is a subvolume; where the mount allows it one call removes the whole tree.
974    #[cfg(target_os = "linux")]
975    if crate::fs_kind::is_subvolume_root(stage_container).unwrap_or(false) && crate::fs_btrfs_ioctl::subvolume_destroy_at(stage_container).is_ok() {
976        return Ok(());
977    }
978    prepare_stage_removal(stage_container)?;
979    return remove_tree_if_present(stage_container);
980}
981
982/// What:
983///  Removes one completed transaction: its stage, its unfinished temporaries, and its journal.
984/// Why:
985///   A complete phase is written first, so an interruption during cleanup resumes as cleanup, not as installation.
986///
987/// In TS you'd write (pseudocode):
988/// ```ts
989/// await removeWorktreeCopyJournal(pending);
990/// ```
991pub fn remove_pending_journal(pending: &PendingJournal) -> Result<(), WorktreeCopyError> {
992    if pending.journal.phase != Phase::Complete {
993        let completed: Journal = Journal {
994            phase: Phase::Complete,
995            ..pending.journal.clone()
996        };
997        write_journal(pending.path.as_path(), &completed)?;
998    }
999    remove_stage(Path::new(pending.journal.stage_container.as_str()))?;
1000    remove_file_if_present(pending.path.as_path())?;
1001    remove_unfinished_writes(pending.path.as_path())?;
1002    if let Some(parent) = pending.path.parent() {
1003        sync_directory(parent).map_err(|error| return raw_failure(&error))?;
1004    }
1005    return Ok(());
1006}
1007
1008/// What:
1009///  How many directory levels below a removed stage are emptied by one thread before the rest is split among workers.
1010/// Why:
1011///   A stage holds a few very large trees (`node_modules`, `target`); three levels below the payload give enough
1012///  independent subtrees to share without walking the whole stage on one thread first.
1013///  Measured with `remove_tree_split` itself at depths 1 to 6 on reflinked copies of a tree of 22,717 entries of this repository
1014///  (2026-11-03, five runs each in alternating orders, other sessions running, a debug build): medians 365, 281, 304, 325, 437 and
1015///  512 ms; depths 2 and 3 overlap (251 to 305 ms and 253 to 309 ms), and depths 1, 4, 5 and 6 were slower. Kept at 3 as the coordinator's pick under the owner's delegation, open to the owner's veto
1016///  (`doc/planning/cli-git-shipped-limits-audit.md`, "Picked for these follow-ups", option A). The tree has no `node_modules` or `target`, so a stage with a few very large subtrees was not measured.
1017///
1018/// In TS you'd write (pseudocode):
1019/// ```ts
1020/// const REMOVAL_SPLIT_DEPTH = 3;
1021/// ```
1022const REMOVAL_SPLIT_DEPTH: usize = 3;
1023
1024/// What:
1025///  Removes a directory tree, sharing the subtrees below a split depth among a bounded set of threads.
1026/// Why:
1027///   Unlinking a staged copy entry by entry took longer than staging it on a tree of hundreds of thousands of entries;
1028///  the unlinks are independent, and the thread count follows the host's budget (the CPUs the process may use less a margin, and the free
1029///  memory), capped at eight.
1030///
1031/// In TS you'd write (pseudocode):
1032/// ```ts
1033/// await removeTreeSplit(path); // rm -r, with the subtrees removed concurrently
1034/// ```
1035fn remove_tree_split(path: &Path) -> std::io::Result<()> {
1036    let mut shallow: Vec<PathBuf> = vec![path.to_path_buf()];
1037    let mut level: Vec<PathBuf> = vec![path.to_path_buf()];
1038    let mut subtrees: Vec<PathBuf> = Vec::new();
1039    for depth in 0..REMOVAL_SPLIT_DEPTH {
1040        let mut next: Vec<PathBuf> = Vec::new();
1041        for directory in &level {
1042            for listed in std::fs::read_dir(directory)? {
1043                let entry: std::fs::DirEntry = listed?;
1044                let child: PathBuf = entry.path();
1045                if !entry.file_type()?.is_dir() {
1046                    std::fs::remove_file(child.as_path())?;
1047                } else if depth + 1 == REMOVAL_SPLIT_DEPTH {
1048                    subtrees.push(child);
1049                } else {
1050                    shallow.push(child.clone());
1051                    next.push(child);
1052                }
1053            }
1054        }
1055        level = next;
1056    }
1057    let workers: usize = sliced_workers(usize::MAX, PARALLEL_COPY_MAX_WORKERS, &HostResources::read());
1058    let cursor: std::sync::atomic::AtomicUsize = std::sync::atomic::AtomicUsize::new(0);
1059    let outcomes: Vec<std::io::Result<()>> = std::thread::scope(|scope| {
1060        let handles: Vec<_> = (0..workers)
1061            .map(|_worker| {
1062                return scope.spawn(|| -> std::io::Result<()> {
1063                    loop {
1064                        let index: usize = cursor.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
1065                        let Some(subtree) = subtrees.get(index) else {
1066                            return Ok(());
1067                        };
1068                        std::fs::remove_dir_all(subtree.as_path())?;
1069                    }
1070                });
1071            })
1072            .collect();
1073        return handles
1074            .into_iter()
1075            .map(|handle| return handle.join().unwrap_or_else(|_panic| return Err(std::io::Error::other("a removal worker panicked"))))
1076            .collect();
1077    });
1078    for outcome in outcomes {
1079        outcome?;
1080    }
1081    for directory in shallow.iter().rev() {
1082        std::fs::remove_dir(directory.as_path())?;
1083    }
1084    return Ok(());
1085}
1086
1087/// What:
1088///  Removes a tree if it exists, sharing its subtrees among threads.
1089/// Why:
1090///   An absent tree is the normal answer for a retried removal; a failed split removal falls back to the plain recursive
1091///  removal, which reports what remains.
1092///
1093/// In TS you'd write (pseudocode):
1094/// ```ts
1095/// await rm(path, { recursive: true, force: true });
1096/// ```
1097fn remove_tree_if_present(path: &Path) -> Result<(), WorktreeCopyError> {
1098    // `match` accepts absence and reports every other removal failure.
1099    match remove_tree_split(path).or_else(|_split_failure| return std::fs::remove_dir_all(path)) {
1100        Ok(()) => return Ok(()),
1101        Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(()),
1102        Err(error) => return Err(raw_failure(&error)),
1103    }
1104}
1105
1106/// What:
1107///  Removes one file, treating an absent file as removed.
1108/// Why:
1109///   The same retry reasoning as for trees.
1110///
1111/// In TS you'd write (pseudocode):
1112/// ```ts
1113/// await rm(path, { force: true });
1114/// ```
1115pub fn remove_file_if_present(path: &Path) -> Result<(), WorktreeCopyError> {
1116    // `match` accepts absence and reports every other removal failure.
1117    match std::fs::remove_file(path) {
1118        Ok(()) => return Ok(()),
1119        Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(()),
1120        Err(error) => return Err(raw_failure(&error)),
1121    }
1122}
1123
1124/// What:
1125///  Removes the temporary writes an interrupted write of one journal left beside it.
1126/// Why:
1127///   Only temporaries named after this journal are removed, so another journal's write is never touched.
1128///
1129/// In TS you'd write (pseudocode):
1130/// ```ts
1131/// removeUnfinishedJournalWrites(path)
1132/// ```
1133fn remove_unfinished_writes(path: &Path) -> Result<(), WorktreeCopyError> {
1134    let Some(parent) = path.parent() else {
1135        return Ok(());
1136    };
1137    let prefix: String = format!(
1138        "{}.",
1139        path.file_name().unwrap_or_default().to_string_lossy()
1140    );
1141    for listed in std::fs::read_dir(parent).map_err(|error| return raw_failure(&error))? {
1142        let entry: std::fs::DirEntry = listed.map_err(|error| return raw_failure(&error))?;
1143        let name: String = entry.file_name().to_string_lossy().into_owned();
1144        if name.starts_with(prefix.as_str()) && name.ends_with(TEMPORARY_SUFFIX) {
1145            remove_file_if_present(entry.path().as_path())?;
1146        }
1147    }
1148    return Ok(());
1149}
1150
1151/// Journal controls stay out of the release executable.
1152#[cfg(test)]
1153#[path = "worktree_copy_journal_tests.rs"]
1154mod tests;