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/// ```
68pub const 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    return write_atomic(path, encode_journal(journal).as_bytes(), "journal");
602}
603
604/// What:
605///  Writes one private file atomically beside its final name; a failure names `subject` and leaves no temporary.
606/// Why:
607///   Journals and stage claims share one rule: a reader sees a complete file or none.
608///
609/// In TS you'd write (pseudocode):
610/// ```ts
611/// await writeFileAtomically({ path, bytes, subject });
612/// ```
613pub fn write_atomic(path: &Path, bytes: &[u8], subject: &str) -> Result<(), WorktreeCopyError> {
614    let unique: String = random_uuid().map_err(|error| {
615        return failure_with_cause(
616            format!(
617                "cli-git: could not persist worktree-copy {subject} {}.",
618                quote(path.to_string_lossy().as_ref())
619            )
620            .as_str(),
621            Some(&error.0),
622        );
623    })?;
624    let temporary: PathBuf = PathBuf::from(format!(
625        "{}.{unique}{TEMPORARY_SUFFIX}",
626        path.to_string_lossy()
627    ));
628    let persisted: std::io::Result<()> =
629        write_exclusive_synced(temporary.as_path(), bytes)
630            .and_then(|()| return std::fs::rename(temporary.as_path(), path))
631            .and_then(|()| match path.parent() {
632                Some(parent) => return sync_directory(parent),
633                None => return Ok(()),
634            });
635    // A failed write removes its own temporary, so the directory keeps no partial journal.
636    if let Err(error) = persisted {
637        let _ignored_removal: std::io::Result<()> = std::fs::remove_file(temporary.as_path());
638        return Err(failure_with_cause(
639            format!(
640                "cli-git: could not persist worktree-copy {subject} {}.",
641                quote(path.to_string_lossy().as_ref())
642            )
643            .as_str(),
644            Some(&error),
645        ));
646    }
647    return Ok(());
648}
649
650/// What:
651///  Creates the staged journal of one destination, durable before any destination is touched.
652/// Why:
653///   Recovery needs the stage and destination named before installation starts.
654///
655/// In TS you'd write (pseudocode):
656/// ```ts
657/// await createWorktreeCopyJournal({ commonDir, destinationRoot, snapshot });
658/// ```
659pub fn create_journal(
660    common_dir: &Path,
661    destination_root: &Path,
662    snapshot: &StagedSnapshot,
663) -> Result<PendingJournal, WorktreeCopyError> {
664    let root: PathBuf = ensure_journal_root(common_dir)?;
665    let unique: String = random_uuid().map_err(|error| return raw_failure(&error.0))?;
666    let path: PathBuf = root.join(format!("{unique}{JOURNAL_SUFFIX}"));
667    let journal: Journal = Journal {
668        created_entries: Vec::new(),
669        intended_entries: Vec::new(),
670        destination_root: destination_root.to_string_lossy().into_owned(),
671        phase: Phase::Staged,
672        selected_roots: snapshot.selected_roots.clone(),
673        source_root: snapshot.source_root.to_string_lossy().into_owned(),
674        stage_container: snapshot.stage_container.to_string_lossy().into_owned(),
675        stage_root: snapshot.stage_root.to_string_lossy().into_owned(),
676    };
677    write_journal(path.as_path(), &journal)?;
678    return Ok(PendingJournal { path, journal });
679}
680
681/// What:
682///  Lists the journal files of a common directory, validated, in journal-name order.
683/// Why:
684///   Recovery and the pending check read the same validated set.
685///
686/// In TS you'd write (pseudocode):
687/// ```ts
688/// await readPendingWorktreeCopyJournals(commonDir);
689/// ```
690pub fn read_pending_journals(common_dir: &Path) -> Result<Vec<PendingJournal>, WorktreeCopyError> {
691    let root: PathBuf = journal_root(common_dir);
692    // `match` treats an absent root as having no journals, and every other failure as fatal.
693    match std::fs::symlink_metadata(root.as_path()) {
694        Ok(_) => {}
695        Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(Vec::new()),
696        Err(error) => return Err(raw_failure(&error)),
697    }
698    assert_private_path(
699        root.parent().unwrap_or(root.as_path()),
700        Role::JournalDirectory,
701    )?;
702    assert_private_path(root.as_path(), Role::JournalDirectory)?;
703    let mut names: Vec<String> = Vec::new();
704    let listing = std::fs::read_dir(root.as_path()).map_err(|error| {
705        let message: String = format!(
706            "cli-git: could not inspect worktree-copy journals under {}.",
707            quote(root.to_string_lossy().as_ref())
708        );
709        return failure_with_cause(message.as_str(), Some(&error));
710    })?;
711    for listed in listing {
712        let entry: std::fs::DirEntry = listed.map_err(|error| return raw_failure(&error))?;
713        let name: String = entry.file_name().to_string_lossy().into_owned();
714        if name.ends_with(JOURNAL_SUFFIX) {
715            names.push(name);
716        }
717    }
718    names.sort_by(|left, right| return js_order(left, right));
719    let mut pending: Vec<PendingJournal> = Vec::new();
720    for name in names {
721        let path: PathBuf = root.join(name.as_str());
722        assert_private_path(path.as_path(), Role::JournalFile)?;
723        let text: String = read_journal_text(path.as_path())?;
724        let journal: Journal = parse_journal(text.as_str(), path.as_path(), root.as_path())?;
725        pending.push(PendingJournal { path, journal });
726    }
727    return Ok(pending);
728}
729
730/// What:
731///  The text of one journal file, read as UTF-8 with replacement, as the incumbent reads it.
732/// Why:
733///   An unreadable journal is named, not skipped.
734///
735/// In TS you'd write (pseudocode):
736/// ```ts
737/// await readFile(path, 'utf8')
738/// ```
739fn read_journal_text(path: &Path) -> Result<String, WorktreeCopyError> {
740    let bytes: Vec<u8> = std::fs::read(path).map_err(|error| {
741        let message: String = format!(
742            "cli-git: could not read worktree-copy journal {}.",
743            quote(path.to_string_lossy().as_ref())
744        );
745        return failure_with_cause(message.as_str(), Some(&error));
746    })?;
747    return Ok(String::from_utf8_lossy(bytes.as_slice()).into_owned());
748}
749
750/// What:
751///  Makes every directory of a stage owner-accessible before its entries are deleted.
752/// Why:
753///   An installed directory can carry a mode that forbids its own removal; only the stage's own directories are changed.
754///
755/// In TS you'd write (pseudocode):
756/// ```ts
757/// await prepareStageRemoval(stageContainer);
758/// ```
759pub fn prepare_stage_removal(stage_container: &Path) -> Result<(), WorktreeCopyError> {
760    use std::os::unix::fs::PermissionsExt;
761    let mut pending: Vec<PathBuf> = vec![stage_container.to_path_buf()];
762    while let Some(directory) = pending.pop() {
763        // `match` skips a directory already removed and refuses anything else that cannot be inspected.
764        let metadata: std::fs::Metadata = match std::fs::symlink_metadata(directory.as_path()) {
765            Ok(metadata) => metadata,
766            Err(error) if error.kind() == std::io::ErrorKind::NotFound => continue,
767            Err(error) => return Err(raw_failure(&error)),
768        };
769        if !metadata.is_dir() {
770            continue;
771        }
772        std::fs::set_permissions(directory.as_path(), std::fs::Permissions::from_mode(0o700))
773            .map_err(|error| return raw_failure(&error))?;
774        for listed in
775            std::fs::read_dir(directory.as_path()).map_err(|error| return raw_failure(&error))?
776        {
777            let child: std::fs::DirEntry = listed.map_err(|error| return raw_failure(&error))?;
778            let child_type: std::fs::FileType = child
779                .file_type()
780                .map_err(|error| return raw_failure(&error))?;
781            if child_type.is_dir() {
782                pending.push(child.path());
783            }
784        }
785    }
786    return Ok(());
787}
788
789/// What:
790///  Removes a stage container with everything in it, whatever modes the installed directories carry.
791/// Why:
792///   Completed transactions and abandoned stages are removed the same way, and an absent stage is already removed.
793///
794/// In TS you'd write (pseudocode):
795/// ```ts
796/// await prepareStageRemoval(stageContainer); await rm(stageContainer, { recursive: true, force: true });
797/// ```
798pub fn remove_stage(stage_container: &Path) -> Result<(), WorktreeCopyError> {
799    prepare_stage_removal(stage_container)?;
800    return remove_tree_if_present(stage_container);
801}
802
803/// What:
804///  Removes one completed transaction: its stage, its unfinished temporaries, and its journal.
805/// Why:
806///   A complete phase is written first, so an interruption during cleanup resumes as cleanup, not as installation.
807///
808/// In TS you'd write (pseudocode):
809/// ```ts
810/// await removeWorktreeCopyJournal(pending);
811/// ```
812pub fn remove_pending_journal(pending: &PendingJournal) -> Result<(), WorktreeCopyError> {
813    if pending.journal.phase != Phase::Complete {
814        let completed: Journal = Journal {
815            phase: Phase::Complete,
816            ..pending.journal.clone()
817        };
818        write_journal(pending.path.as_path(), &completed)?;
819    }
820    remove_stage(Path::new(pending.journal.stage_container.as_str()))?;
821    remove_file_if_present(pending.path.as_path())?;
822    remove_unfinished_writes(pending.path.as_path())?;
823    if let Some(parent) = pending.path.parent() {
824        sync_directory(parent).map_err(|error| return raw_failure(&error))?;
825    }
826    return Ok(());
827}
828
829/// What:
830///  Removes a directory tree, treating an absent tree as removed.
831/// Why:
832///   Cleanup is retried after interruptions, so an already-removed tree is not an error.
833///
834/// In TS you'd write (pseudocode):
835/// ```ts
836/// await rm(path, { recursive: true, force: true });
837/// ```
838fn remove_tree_if_present(path: &Path) -> Result<(), WorktreeCopyError> {
839    // `match` accepts absence and reports every other removal failure.
840    match std::fs::remove_dir_all(path) {
841        Ok(()) => return Ok(()),
842        Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(()),
843        Err(error) => return Err(raw_failure(&error)),
844    }
845}
846
847/// What:
848///  Removes one file, treating an absent file as removed.
849/// Why:
850///   The same retry reasoning as for trees.
851///
852/// In TS you'd write (pseudocode):
853/// ```ts
854/// await rm(path, { force: true });
855/// ```
856pub fn remove_file_if_present(path: &Path) -> Result<(), WorktreeCopyError> {
857    // `match` accepts absence and reports every other removal failure.
858    match std::fs::remove_file(path) {
859        Ok(()) => return Ok(()),
860        Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(()),
861        Err(error) => return Err(raw_failure(&error)),
862    }
863}
864
865/// What:
866///  Removes the temporary writes an interrupted write of one journal left beside it.
867/// Why:
868///   Only temporaries named after this journal are removed, so another journal's write is never touched.
869///
870/// In TS you'd write (pseudocode):
871/// ```ts
872/// removeUnfinishedJournalWrites(path)
873/// ```
874fn remove_unfinished_writes(path: &Path) -> Result<(), WorktreeCopyError> {
875    let Some(parent) = path.parent() else {
876        return Ok(());
877    };
878    let prefix: String = format!(
879        "{}.",
880        path.file_name().unwrap_or_default().to_string_lossy()
881    );
882    for listed in std::fs::read_dir(parent).map_err(|error| return raw_failure(&error))? {
883        let entry: std::fs::DirEntry = listed.map_err(|error| return raw_failure(&error))?;
884        let name: String = entry.file_name().to_string_lossy().into_owned();
885        if name.starts_with(prefix.as_str()) && name.ends_with(TEMPORARY_SUFFIX) {
886            remove_file_if_present(entry.path().as_path())?;
887        }
888    }
889    return Ok(());
890}
891
892/// Journal controls stay out of the release executable.
893#[cfg(test)]
894#[path = "worktree_copy_journal_tests.rs"]
895mod tests;