Skip to main content

git_policy_cli/
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 super::json_record::quote;
18use super::private_storage::{sync_directory, write_exclusive_synced};
19use super::random_id::random_uuid;
20use super::worktree_copy_error::{WorktreeCopyError, failure_with_cause, raw_failure};
21use super::worktree_copy_install_entry::InstalledPath;
22use super::worktree_copy_selection::{STAGE_PREFIX, assert_safe_repository_path, js_order};
23use super::worktree_copy_stage::StagedSnapshot;
24use std::path::{Path, PathBuf};
25
26/// What:
27///  The parent directory name of the journals, beneath the common Git directory.
28/// Why:
29///   Every linked worktree of one repository shares the same journal location.
30///
31/// In TS you'd write (pseudocode):
32/// ```ts
33/// const JOURNAL_PARENT_NAME = 'cli-git-worktree-copy';
34/// ```
35pub const JOURNAL_PARENT_NAME: &str = "cli-git-worktree-copy";
36
37/// What:
38///  The schema directory of the journals.
39/// Why:
40///   A later schema gets its own directory, so an older wrapper never reads a newer record.
41///
42/// In TS you'd write (pseudocode):
43/// ```ts
44/// const JOURNAL_VERSION_NAME = 'v1';
45/// ```
46pub const JOURNAL_VERSION_NAME: &str = "v1";
47
48/// What:
49///  The suffix of a final journal file.
50/// Why:
51///   Only files with this suffix are journals; anything else in the directory is ignored.
52///
53/// In TS you'd write (pseudocode):
54/// ```ts
55/// const JOURNAL_SUFFIX = '.json';
56/// ```
57const JOURNAL_SUFFIX: &str = ".json";
58
59/// What:
60///  The suffix of an unpublished temporary journal write.
61/// Why:
62///   An interrupted write leaves only files with this suffix, which recovery removes.
63///
64/// In TS you'd write (pseudocode):
65/// ```ts
66/// const TEMPORARY_SUFFIX = '.tmp';
67/// ```
68const TEMPORARY_SUFFIX: &str = ".tmp";
69
70/// What:
71///  The group and other permission bits a private path must not have.
72/// Why:
73///   A journal or stage readable or writable by others could be planted or read by another account.
74///
75/// In TS you'd write (pseudocode):
76/// ```ts
77/// const NON_PRIVATE_MODE_BITS = 0o077;
78/// ```
79const NON_PRIVATE_MODE_BITS: u32 = 0o077;
80
81/// What:
82///  The durable phase a copy has reached.
83/// Why:
84///   Recovery resumes an installation and finishes a completed cleanup differently.
85///
86/// In TS you'd write (pseudocode):
87/// ```ts
88/// type WorktreeCopyJournalPhase = 'complete' | 'installing' | 'staged';
89/// ```
90#[derive(Clone, Copy, Debug, Eq, PartialEq)]
91pub enum Phase {
92    /// The stage is complete and nothing has been installed.
93    Staged,
94    /// Installation has begun; the intents and creations in the install log say what it touched.
95    Installing,
96    /// Installation finished; only cleanup remains.
97    Complete,
98}
99
100impl Phase {
101    /// The phase's name as the incumbent writes it.
102    fn as_str(self) -> &'static str {
103        // `match` keeps each phase's spelling next to its variant.
104        match self {
105            Phase::Staged => return "staged",
106            Phase::Installing => return "installing",
107            Phase::Complete => return "complete",
108        }
109    }
110}
111
112/// What:
113///  One durable journal record.
114/// Why:
115///   Every field is read back by recovery before any filesystem path is used.
116///
117/// In TS you'd write (pseudocode):
118/// ```ts
119/// type WorktreeCopyJournal = { createdEntries; intendedEntries; destinationRoot; phase; selectedRoots; sourceRoot; stageContainer; stageRoot; version: 1 };
120/// ```
121#[derive(Clone, Debug, Eq, PartialEq)]
122pub struct Journal {
123    /// Paths proven created, with their post-creation identities.
124    pub created_entries: Vec<InstalledPath>,
125    /// Selected paths claimed before installation.
126    pub intended_entries: Vec<String>,
127    /// The worktree receiving the copy.
128    pub destination_root: String,
129    /// The durable phase.
130    pub phase: Phase,
131    /// The ignored roots the staged payload represents.
132    pub selected_roots: Vec<String>,
133    /// The source worktree.
134    pub source_root: String,
135    /// The private container beside the destination.
136    pub stage_container: String,
137    /// The payload root inside the container.
138    pub stage_root: String,
139}
140
141/// What:
142///  A journal and the path it was read from or written to.
143/// Why:
144///   Removal and recovery need both the record and the file that must be deleted.
145///
146/// In TS you'd write (pseudocode):
147/// ```ts
148/// type PendingWorktreeCopyJournal = { path: string; record: WorktreeCopyJournal };
149/// ```
150#[derive(Clone, Debug, Eq, PartialEq)]
151pub struct PendingJournal {
152    /// Absolute journal file path.
153    pub path: PathBuf,
154    /// The validated record.
155    pub journal: Journal,
156}
157
158/// What:
159///  The JSON text of one journal, with the incumbent's key order and a final line feed.
160/// Why:
161///   The incumbent reads these bytes, so the order and spelling must match its `JSON.stringify` output.
162///
163/// In TS you'd write (pseudocode):
164/// ```ts
165/// `${JSON.stringify(serializedRecord)}\n`
166/// ```
167pub fn encode_journal(journal: &Journal) -> String {
168    let created: Vec<String> = journal
169        .created_entries
170        .iter()
171        .map(encode_installed_entry)
172        .collect();
173    return format!(
174        "{{\"createdEntries\":[{}],\"intendedEntries\":{},\"destinationRoot\":{},\"phase\":{},\"selectedRoots\":{},\"sourceRoot\":{},\"stageContainer\":{},\"stageRoot\":{},\"version\":1}}\n",
175        created.join(","),
176        string_array(journal.intended_entries.as_slice()),
177        quote(journal.destination_root.as_str()),
178        quote(journal.phase.as_str()),
179        string_array(journal.selected_roots.as_slice()),
180        quote(journal.source_root.as_str()),
181        quote(journal.stage_container.as_str()),
182        quote(journal.stage_root.as_str()),
183    );
184}
185
186/// What:
187///  One created entry as its JSON object, with the incumbent's key order.
188/// Why:
189///   The install log and the journal share this spelling, so both wrappers read either.
190///
191/// In TS you'd write (pseudocode):
192/// ```ts
193/// JSON.stringify({ device, inode, relativePath, selected })
194/// ```
195pub fn encode_installed_entry(entry: &InstalledPath) -> String {
196    return format!(
197        "{{\"device\":\"{}\",\"inode\":\"{}\",\"relativePath\":{},\"selected\":{}}}",
198        entry.device,
199        entry.inode,
200        quote(entry.relative_path.as_str()),
201        entry.selected
202    );
203}
204
205/// What:
206///  A JSON array of strings, each quoted as `JSON.stringify` quotes it.
207/// Why:
208///   Repository paths are the only arrays a journal holds.
209///
210/// In TS you'd write (pseudocode):
211/// ```ts
212/// JSON.stringify(items)
213/// ```
214pub fn string_array(items: &[String]) -> String {
215    let quoted: Vec<String> = items
216        .iter()
217        .map(|item| return quote(item.as_str()))
218        .collect();
219    return format!("[{}]", quoted.join(","));
220}
221
222/// What:
223///  Whether a path is absolute and already in lexical canonical form.
224/// Why:
225///   A journal path that is not canonical could name a different file than the one validated.
226///
227/// In TS you'd write (pseudocode):
228/// ```ts
229/// isAbsolute(value) && resolve(value) === value
230/// ```
231fn is_canonical_absolute(value: &str) -> bool {
232    if !value.starts_with('/') {
233        return false;
234    }
235    if value == "/" {
236        return true;
237    }
238    return value[1..]
239        .split('/')
240        .all(|component| return !component.is_empty() && component != "." && component != "..");
241}
242
243/// What:
244///  Whether a text is a canonical decimal identity: digits only, no leading zero.
245/// Why:
246///   Device and inode numbers are compared as text, so only one spelling of each may exist.
247///
248/// In TS you'd write (pseudocode):
249/// ```ts
250/// function isDecimalIdentity(value: unknown): value is string;
251/// ```
252fn decimal_identity(value: &serde_json::Value) -> Option<u64> {
253    let text: &str = value.as_str()?;
254    if text.is_empty() || (text.len() > 1 && text.starts_with('0')) {
255        return None;
256    }
257    if !text.bytes().all(|byte| return byte.is_ascii_digit()) {
258        return None;
259    }
260    return text.parse::<u64>().ok();
261}
262
263/// What:
264///  One created-entry object, read strictly.
265/// Why:
266///   An identity with any wrong type is not an identity cli-git wrote.
267///
268/// In TS you'd write (pseudocode):
269/// ```ts
270/// function isInstalledEntry(value: unknown): value is InstalledWorktreePath;
271/// ```
272pub fn installed_entry(value: &serde_json::Value) -> Option<InstalledPath> {
273    let object = value.as_object()?;
274    return Some(InstalledPath {
275        device: decimal_identity(object.get("device")?)?,
276        inode: decimal_identity(object.get("inode")?)?,
277        relative_path: object.get("relativePath")?.as_str()?.to_string(),
278        selected: object.get("selected")?.as_bool()?,
279    });
280}
281
282/// What:
283///  An array of strings, read strictly.
284/// Why:
285///   A non-string path would reach the filesystem without the validation a string gets.
286///
287/// In TS you'd write (pseudocode):
288/// ```ts
289/// isStringArray(value)
290/// ```
291fn string_list(value: Option<&serde_json::Value>) -> Option<Vec<String>> {
292    let array = value?.as_array()?;
293    return array
294        .iter()
295        .map(|item| return item.as_str().map(str::to_string))
296        .collect();
297}
298
299/// What:
300///  The record's fields read strictly, before any path is checked.
301/// Why:
302///   The shape check refuses every record that a later check would have to guess about.
303///
304/// In TS you'd write (pseudocode):
305/// ```ts
306/// if (!(isRecord(value) && value.version === 1 && ...)) throw new WorktreeCopyError('... is corrupt ...');
307/// ```
308fn read_shape(value: &serde_json::Value) -> Option<Journal> {
309    let object = value.as_object()?;
310    if object.get("version")?.as_f64()? != 1.0 {
311        return None;
312    }
313    let phase: Phase = match object.get("phase")?.as_str()? {
314        "staged" => Phase::Staged,
315        "installing" => Phase::Installing,
316        "complete" => Phase::Complete,
317        _ => return None,
318    };
319    let created_entries: Vec<InstalledPath> = object
320        .get("createdEntries")?
321        .as_array()?
322        .iter()
323        .map(installed_entry)
324        .collect::<Option<Vec<InstalledPath>>>()?;
325    return Some(Journal {
326        created_entries,
327        intended_entries: string_list(object.get("intendedEntries"))?,
328        destination_root: object.get("destinationRoot")?.as_str()?.to_string(),
329        phase,
330        selected_roots: string_list(object.get("selectedRoots"))?,
331        source_root: object.get("sourceRoot")?.as_str()?.to_string(),
332        stage_container: object.get("stageContainer")?.as_str()?.to_string(),
333        stage_root: object.get("stageRoot")?.as_str()?.to_string(),
334    });
335}
336
337/// What:
338///  Parses and validates one journal's text, refusing every shape recovery cannot trust.
339/// Why:
340///   Recovery turns journal paths into deletions, so every relation between the paths is checked first.
341///
342/// In TS you'd write (pseudocode):
343/// ```ts
344/// validateJournalValue({ value: JSON.parse(text), path, journalRoot });
345/// ```
346pub fn parse_journal(
347    text: &str,
348    path: &Path,
349    journal_root: &Path,
350) -> Result<Journal, WorktreeCopyError> {
351    let value: serde_json::Value = serde_json::from_str(text).map_err(|error| {
352        let message: String = format!(
353            "cli-git: could not read worktree-copy journal {}.",
354            quote(path.to_string_lossy().as_ref())
355        );
356        return failure_with_cause(message.as_str(), Some(&error));
357    })?;
358    let Some(journal) = read_shape(&value) else {
359        return Err(corrupt(path));
360    };
361    if path.parent() != Some(journal_root) {
362        return Err(failure_with_cause(
363            format!(
364                "cli-git: worktree-copy journal escaped private root: {}.",
365                quote(path.to_string_lossy().as_ref())
366            )
367            .as_str(),
368            None,
369        ));
370    }
371    for (field, field_value) in [
372        ("destinationRoot", journal.destination_root.as_str()),
373        ("sourceRoot", journal.source_root.as_str()),
374        ("stageContainer", journal.stage_container.as_str()),
375        ("stageRoot", journal.stage_root.as_str()),
376    ] {
377        if !is_canonical_absolute(field_value) {
378            return Err(failure_with_cause(
379                format!(
380                    "cli-git: worktree-copy journal has unsafe {field}: {}.",
381                    quote(field_value)
382                )
383                .as_str(),
384                None,
385            ));
386        }
387    }
388    let stage_relation: bool = Path::new(journal.stage_container.as_str()).parent()
389        == Path::new(journal.destination_root.as_str()).parent()
390        && Path::new(journal.stage_container.as_str())
391            .file_name()
392            .is_some_and(|name| return name.to_string_lossy().starts_with(STAGE_PREFIX))
393        && journal.stage_root == format!("{}/payload", journal.stage_container);
394    if !stage_relation {
395        return Err(failure_with_cause(
396            format!(
397                "cli-git: worktree-copy journal has unsafe private stage relation: {}.",
398                quote(path.to_string_lossy().as_ref())
399            )
400            .as_str(),
401            None,
402        ));
403    }
404    for selected in &journal.selected_roots {
405        assert_safe_repository_path(selected)?;
406    }
407    for intended in &journal.intended_entries {
408        assert_safe_repository_path(intended)?;
409    }
410    for created in &journal.created_entries {
411        assert_safe_repository_path(created.relative_path.as_str())?;
412    }
413    let created_paths: Vec<&str> = journal
414        .created_entries
415        .iter()
416        .map(|entry| return entry.relative_path.as_str())
417        .collect();
418    let duplicated: bool = has_duplicates(journal.selected_roots.as_slice())
419        || has_duplicates(journal.intended_entries.as_slice())
420        || has_duplicates(created_paths.as_slice());
421    if duplicated {
422        return Err(failure_with_cause(
423            format!(
424                "cli-git: worktree-copy journal contains duplicate repository paths: {}.",
425                quote(path.to_string_lossy().as_ref())
426            )
427            .as_str(),
428            None,
429        ));
430    }
431    return Ok(journal);
432}
433
434/// What:
435///  Whether a list holds the same entry twice.
436/// Why:
437///   Duplicate claims would make rollback ownership ambiguous.
438///
439/// In TS you'd write (pseudocode):
440/// ```ts
441/// new Set(items).size !== items.length
442/// ```
443fn has_duplicates<T: AsRef<str>>(items: &[T]) -> bool {
444    let mut seen: std::collections::BTreeSet<&str> = std::collections::BTreeSet::new();
445    return items.iter().any(|item| return !seen.insert(item.as_ref()));
446}
447
448/// What:
449///  The corruption failure naming one journal.
450/// Why:
451///   The user learns which file to inspect; the file itself is kept.
452///
453/// In TS you'd write (pseudocode):
454/// ```ts
455/// new WorktreeCopyError(`cli-git: worktree-copy journal is corrupt: ${JSON.stringify(path)}.`)
456/// ```
457fn corrupt(path: &Path) -> WorktreeCopyError {
458    return failure_with_cause(
459        format!(
460            "cli-git: worktree-copy journal is corrupt: {}.",
461            quote(path.to_string_lossy().as_ref())
462        )
463        .as_str(),
464        None,
465    );
466}
467
468/// What:
469///  The role a private path plays, which selects its expected entry kind.
470/// Why:
471///   A journal file must be a single-link file; a directory must be a directory.
472///
473/// In TS you'd write (pseudocode):
474/// ```ts
475/// type PrivateWorktreeCopyPathRole = 'journal directory' | 'journal file' | 'private stage';
476/// ```
477#[derive(Clone, Copy, Debug, Eq, PartialEq)]
478pub enum Role {
479    /// A journal parent or root directory.
480    JournalDirectory,
481    /// A journal file.
482    JournalFile,
483    /// A private stage container or payload.
484    PrivateStage,
485}
486
487impl Role {
488    /// The role's name in diagnostics.
489    fn name(self) -> &'static str {
490        // `match` keeps each role's diagnostic spelling next to its variant.
491        match self {
492            Role::JournalDirectory => return "journal directory",
493            Role::JournalFile => return "journal file",
494            Role::PrivateStage => return "private stage",
495        }
496    }
497}
498
499/// What:
500///  Checks that a path is canonical, owned by this account, private, and of the role's kind.
501/// Why:
502///   A journal or stage that another account can write, or that a link redirects, is never acted on.
503///
504/// In TS you'd write (pseudocode):
505/// ```ts
506/// await assertPrivateWorktreeCopyPath({ path, role });
507/// ```
508pub fn assert_private_path(path: &Path, role: Role) -> Result<(), WorktreeCopyError> {
509    use std::os::unix::fs::MetadataExt;
510    let metadata: std::fs::Metadata =
511        std::fs::symlink_metadata(path).map_err(|error| return raw_failure(&error))?;
512    let shape: bool = match role {
513        Role::JournalFile => metadata.is_file() && metadata.nlink() == 1,
514        Role::JournalDirectory | Role::PrivateStage => metadata.is_dir(),
515    };
516    // SAFETY: `geteuid` has no preconditions and cannot fail.
517    let effective_user: u32 = unsafe { libc::geteuid() };
518    let canonical: bool = std::fs::canonicalize(path)
519        .map_err(|error| return raw_failure(&error))?
520        .as_path()
521        == path;
522    if !shape
523        || (metadata.mode() & NON_PRIVATE_MODE_BITS) != 0
524        || metadata.uid() != effective_user
525        || !canonical
526    {
527        return Err(failure_with_cause(
528            format!(
529                "cli-git: worktree-copy {} is unsafe: {}.",
530                role.name(),
531                quote(path.to_string_lossy().as_ref())
532            )
533            .as_str(),
534            None,
535        ));
536    }
537    return Ok(());
538}
539
540/// What:
541///  The journal root beneath a common Git directory, without creating anything.
542/// Why:
543///   Readers check for pending state before they decide whether to create it.
544///
545/// In TS you'd write (pseudocode):
546/// ```ts
547/// join(commonDir, 'cli-git-worktree-copy', 'v1')
548/// ```
549pub fn journal_root(common_dir: &Path) -> PathBuf {
550    return common_dir
551        .join(JOURNAL_PARENT_NAME)
552        .join(JOURNAL_VERSION_NAME);
553}
554
555/// What:
556///  Creates one private directory, or accepts an existing one, and checks it.
557/// Why:
558///   An interrupted creation or a second writer must not fail the other writer.
559///
560/// In TS you'd write (pseudocode):
561/// ```ts
562/// await mkdir(path, { mode: 0o700 }) // EEXIST accepted; then assertPrivateWorktreeCopyPath
563/// ```
564fn ensure_private_directory(path: &Path) -> Result<(), WorktreeCopyError> {
565    use std::os::unix::fs::DirBuilderExt;
566    // `match` accepts an existing directory and reports every other creation failure.
567    match std::fs::DirBuilder::new().mode(0o700).create(path) {
568        Ok(()) => {}
569        Err(error) if error.kind() == std::io::ErrorKind::AlreadyExists => {}
570        Err(error) => return Err(raw_failure(&error)),
571    }
572    return assert_private_path(path, Role::JournalDirectory);
573}
574
575/// What:
576///  Creates and checks the journal parent and root, and returns the root.
577/// Why:
578///   Every writer needs the same private location before it writes.
579///
580/// In TS you'd write (pseudocode):
581/// ```ts
582/// await ensureWorktreeCopyJournalRoot(commonDir);
583/// ```
584pub fn ensure_journal_root(common_dir: &Path) -> Result<PathBuf, WorktreeCopyError> {
585    ensure_private_directory(common_dir.join(JOURNAL_PARENT_NAME).as_path())?;
586    let root: PathBuf = journal_root(common_dir);
587    ensure_private_directory(root.as_path())?;
588    return Ok(root);
589}
590
591/// What:
592///  Writes one complete journal atomically: a private exclusive temporary, synced, renamed, and its directory synced.
593/// Why:
594///   A reader sees either the previous complete journal or the new complete one, never a fragment.
595///
596/// In TS you'd write (pseudocode):
597/// ```ts
598/// await writeJournal({ path, record });
599/// ```
600pub fn write_journal(path: &Path, journal: &Journal) -> Result<(), WorktreeCopyError> {
601    let unique: String = random_uuid().map_err(|error| {
602        return failure_with_cause(
603            format!(
604                "cli-git: could not persist worktree-copy journal {}.",
605                quote(path.to_string_lossy().as_ref())
606            )
607            .as_str(),
608            Some(&error.0),
609        );
610    })?;
611    let temporary: PathBuf = PathBuf::from(format!(
612        "{}.{unique}{TEMPORARY_SUFFIX}",
613        path.to_string_lossy()
614    ));
615    let persisted: std::io::Result<()> =
616        write_exclusive_synced(temporary.as_path(), encode_journal(journal).as_bytes())
617            .and_then(|()| return std::fs::rename(temporary.as_path(), path))
618            .and_then(|()| match path.parent() {
619                Some(parent) => return sync_directory(parent),
620                None => return Ok(()),
621            });
622    // A failed write removes its own temporary, so the directory keeps no partial journal.
623    if let Err(error) = persisted {
624        let _ignored_removal: std::io::Result<()> = std::fs::remove_file(temporary.as_path());
625        return Err(failure_with_cause(
626            format!(
627                "cli-git: could not persist worktree-copy journal {}.",
628                quote(path.to_string_lossy().as_ref())
629            )
630            .as_str(),
631            Some(&error),
632        ));
633    }
634    return Ok(());
635}
636
637/// What:
638///  Creates the staged journal of one destination, durable before any destination is touched.
639/// Why:
640///   Recovery needs the stage and destination named before installation starts.
641///
642/// In TS you'd write (pseudocode):
643/// ```ts
644/// await createWorktreeCopyJournal({ commonDir, destinationRoot, snapshot });
645/// ```
646pub fn create_journal(
647    common_dir: &Path,
648    destination_root: &Path,
649    snapshot: &StagedSnapshot,
650) -> Result<PendingJournal, WorktreeCopyError> {
651    let root: PathBuf = ensure_journal_root(common_dir)?;
652    let unique: String = random_uuid().map_err(|error| return raw_failure(&error.0))?;
653    let path: PathBuf = root.join(format!("{unique}{JOURNAL_SUFFIX}"));
654    let journal: Journal = Journal {
655        created_entries: Vec::new(),
656        intended_entries: Vec::new(),
657        destination_root: destination_root.to_string_lossy().into_owned(),
658        phase: Phase::Staged,
659        selected_roots: snapshot.selected_roots.clone(),
660        source_root: snapshot.source_root.to_string_lossy().into_owned(),
661        stage_container: snapshot.stage_container.to_string_lossy().into_owned(),
662        stage_root: snapshot.stage_root.to_string_lossy().into_owned(),
663    };
664    write_journal(path.as_path(), &journal)?;
665    return Ok(PendingJournal { path, journal });
666}
667
668/// What:
669///  Lists the journal files of a common directory, validated, in journal-name order.
670/// Why:
671///   Recovery and the pending check read the same validated set.
672///
673/// In TS you'd write (pseudocode):
674/// ```ts
675/// await readPendingWorktreeCopyJournals(commonDir);
676/// ```
677pub fn read_pending_journals(common_dir: &Path) -> Result<Vec<PendingJournal>, WorktreeCopyError> {
678    let root: PathBuf = journal_root(common_dir);
679    // `match` treats an absent root as having no journals, and every other failure as fatal.
680    match std::fs::symlink_metadata(root.as_path()) {
681        Ok(_) => {}
682        Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(Vec::new()),
683        Err(error) => return Err(raw_failure(&error)),
684    }
685    assert_private_path(
686        root.parent().unwrap_or(root.as_path()),
687        Role::JournalDirectory,
688    )?;
689    assert_private_path(root.as_path(), Role::JournalDirectory)?;
690    let mut names: Vec<String> = Vec::new();
691    let listing = std::fs::read_dir(root.as_path()).map_err(|error| {
692        let message: String = format!(
693            "cli-git: could not inspect worktree-copy journals under {}.",
694            quote(root.to_string_lossy().as_ref())
695        );
696        return failure_with_cause(message.as_str(), Some(&error));
697    })?;
698    for listed in listing {
699        let entry: std::fs::DirEntry = listed.map_err(|error| return raw_failure(&error))?;
700        let name: String = entry.file_name().to_string_lossy().into_owned();
701        if name.ends_with(JOURNAL_SUFFIX) {
702            names.push(name);
703        }
704    }
705    names.sort_by(|left, right| return js_order(left, right));
706    let mut pending: Vec<PendingJournal> = Vec::new();
707    for name in names {
708        let path: PathBuf = root.join(name.as_str());
709        assert_private_path(path.as_path(), Role::JournalFile)?;
710        let text: String = read_journal_text(path.as_path())?;
711        let journal: Journal = parse_journal(text.as_str(), path.as_path(), root.as_path())?;
712        pending.push(PendingJournal { path, journal });
713    }
714    return Ok(pending);
715}
716
717/// What:
718///  The text of one journal file, read as UTF-8 with replacement, as the incumbent reads it.
719/// Why:
720///   An unreadable journal is named, not skipped.
721///
722/// In TS you'd write (pseudocode):
723/// ```ts
724/// await readFile(path, 'utf8')
725/// ```
726fn read_journal_text(path: &Path) -> Result<String, WorktreeCopyError> {
727    let bytes: Vec<u8> = std::fs::read(path).map_err(|error| {
728        let message: String = format!(
729            "cli-git: could not read worktree-copy journal {}.",
730            quote(path.to_string_lossy().as_ref())
731        );
732        return failure_with_cause(message.as_str(), Some(&error));
733    })?;
734    return Ok(String::from_utf8_lossy(bytes.as_slice()).into_owned());
735}
736
737/// What:
738///  Makes every directory of a stage owner-accessible before its entries are deleted.
739/// Why:
740///   An installed directory can carry a mode that forbids its own removal; only the stage's own directories are changed.
741///
742/// In TS you'd write (pseudocode):
743/// ```ts
744/// await prepareStageRemoval(stageContainer);
745/// ```
746pub fn prepare_stage_removal(stage_container: &Path) -> Result<(), WorktreeCopyError> {
747    use std::os::unix::fs::PermissionsExt;
748    let mut pending: Vec<PathBuf> = vec![stage_container.to_path_buf()];
749    while let Some(directory) = pending.pop() {
750        // `match` skips a directory already removed and refuses anything else that cannot be inspected.
751        let metadata: std::fs::Metadata = match std::fs::symlink_metadata(directory.as_path()) {
752            Ok(metadata) => metadata,
753            Err(error) if error.kind() == std::io::ErrorKind::NotFound => continue,
754            Err(error) => return Err(raw_failure(&error)),
755        };
756        if !metadata.is_dir() {
757            continue;
758        }
759        std::fs::set_permissions(directory.as_path(), std::fs::Permissions::from_mode(0o700))
760            .map_err(|error| return raw_failure(&error))?;
761        for listed in
762            std::fs::read_dir(directory.as_path()).map_err(|error| return raw_failure(&error))?
763        {
764            let child: std::fs::DirEntry = listed.map_err(|error| return raw_failure(&error))?;
765            let child_type: std::fs::FileType = child
766                .file_type()
767                .map_err(|error| return raw_failure(&error))?;
768            if child_type.is_dir() {
769                pending.push(child.path());
770            }
771        }
772    }
773    return Ok(());
774}
775
776/// What:
777///  Removes one completed transaction: its stage, its unfinished temporaries, and its journal.
778/// Why:
779///   A complete phase is written first, so an interruption during cleanup resumes as cleanup, not as installation.
780///
781/// In TS you'd write (pseudocode):
782/// ```ts
783/// await removeWorktreeCopyJournal(pending);
784/// ```
785pub fn remove_pending_journal(pending: &PendingJournal) -> Result<(), WorktreeCopyError> {
786    if pending.journal.phase != Phase::Complete {
787        let completed: Journal = Journal {
788            phase: Phase::Complete,
789            ..pending.journal.clone()
790        };
791        write_journal(pending.path.as_path(), &completed)?;
792    }
793    let stage: PathBuf = PathBuf::from(pending.journal.stage_container.as_str());
794    prepare_stage_removal(stage.as_path())?;
795    remove_tree_if_present(stage.as_path())?;
796    remove_file_if_present(pending.path.as_path())?;
797    remove_unfinished_writes(pending.path.as_path())?;
798    if let Some(parent) = pending.path.parent() {
799        sync_directory(parent).map_err(|error| return raw_failure(&error))?;
800    }
801    return Ok(());
802}
803
804/// What:
805///  Removes a directory tree, treating an absent tree as removed.
806/// Why:
807///   Cleanup is retried after interruptions, so an already-removed tree is not an error.
808///
809/// In TS you'd write (pseudocode):
810/// ```ts
811/// await rm(path, { recursive: true, force: true });
812/// ```
813fn remove_tree_if_present(path: &Path) -> Result<(), WorktreeCopyError> {
814    // `match` accepts absence and reports every other removal failure.
815    match std::fs::remove_dir_all(path) {
816        Ok(()) => return Ok(()),
817        Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(()),
818        Err(error) => return Err(raw_failure(&error)),
819    }
820}
821
822/// What:
823///  Removes one file, treating an absent file as removed.
824/// Why:
825///   The same retry reasoning as for trees.
826///
827/// In TS you'd write (pseudocode):
828/// ```ts
829/// await rm(path, { force: true });
830/// ```
831fn remove_file_if_present(path: &Path) -> Result<(), WorktreeCopyError> {
832    // `match` accepts absence and reports every other removal failure.
833    match std::fs::remove_file(path) {
834        Ok(()) => return Ok(()),
835        Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(()),
836        Err(error) => return Err(raw_failure(&error)),
837    }
838}
839
840/// What:
841///  Removes the temporary writes an interrupted write of one journal left beside it.
842/// Why:
843///   Only temporaries named after this journal are removed, so another journal's write is never touched.
844///
845/// In TS you'd write (pseudocode):
846/// ```ts
847/// removeUnfinishedJournalWrites(path)
848/// ```
849fn remove_unfinished_writes(path: &Path) -> Result<(), WorktreeCopyError> {
850    let Some(parent) = path.parent() else {
851        return Ok(());
852    };
853    let prefix: String = format!(
854        "{}.",
855        path.file_name().unwrap_or_default().to_string_lossy()
856    );
857    for listed in std::fs::read_dir(parent).map_err(|error| return raw_failure(&error))? {
858        let entry: std::fs::DirEntry = listed.map_err(|error| return raw_failure(&error))?;
859        let name: String = entry.file_name().to_string_lossy().into_owned();
860        if name.starts_with(prefix.as_str()) && name.ends_with(TEMPORARY_SUFFIX) {
861            remove_file_if_present(entry.path().as_path())?;
862        }
863    }
864    return Ok(());
865}
866
867/// Journal controls stay out of the release executable.
868#[cfg(test)]
869#[path = "worktree_copy_journal_tests.rs"]
870mod tests;