Skip to main content

git_policy_cli/worktree/
worktree_dirty_restore.rs

1//! What:
2//!  The restore of a snapshot of a main checkout that had uncommitted changes: make the copy's index and files equal the
3//!  tree of the new worktree's `HEAD` with `git read-tree --reset -u` and `git clean -f -d`, then prove it in the stage.
4//! Why:
5//!  `git worktree add` from a checkout with changes makes a worktree that holds the committed files and nothing of the changes.
6//!  The snapshot carries the changes, so the copy is reset the way Git's own `reset --hard` would reset it, from the index the
7//!  snapshot carries: that index still records the files as they were before they were edited, so the reset rewrites exactly the
8//!  edited, deleted and staged paths and leaves the rest. Untracked files that no ignore rule covers go, ignored files stay.
9//!  Both Git runs are the ladder's own steps on the new worktree (its Git directory and its stage), started as real Git
10//!  children by the lifecycle's runner, so no policy of the wrapper that guards a person's `reset` or `clean` applies to
11//!  them. The proof runs before the stage is swapped in; a proof that fails ends the rung and Git's own checkout makes the
12//!  worktree. The main checkout is read only to be snapshotted and listed, and is never written.
13//!
14//! In TS you'd write (pseudocode):
15//! ```ts
16//! await restoreDirtySnapshot({ source, target, stage, runGit, context }); // throws the reason it cannot be proven
17//! ```
18
19use crate::inner_git_words::record_words;
20use crate::transaction_git::{GitContext, GitRequest, run_git};
21use crate::worktree_copy::GitRunner;
22use crate::worktree_ladder_target::{Target, checkout_environment, run_step};
23use crate::worktree_snapshot_source::{ChangedPaths, SnapshotSource};
24use std::ffi::OsString;
25use std::path::Path;
26
27/// Tag used for diagnostics from this module.
28const TAG: &str = "worktreeDirtyRestore";
29
30/// What:
31///  The line that tells the person a snapshot of a checkout with changes left the changes out.
32/// Why:
33///   The new worktree matches a clean `git worktree add`, which the person may not expect from a copy of a dirty checkout.
34///
35/// In TS you'd write (pseudocode):
36/// ```ts
37/// `cli-git: the uncommitted changes of the main checkout (${count} paths) were left out of the new worktree.\n`
38/// ```
39pub fn left_out_note(changed: &ChangedPaths) -> String {
40    let paths: &str = if changed.count == 1 { "path" } else { "paths" };
41    return format!("cli-git: the uncommitted changes of the main checkout ({} {paths}) were left out of the new worktree, which holds what Git's own checkout holds.\n", changed.count);
42}
43
44/// What:
45///  Record in the journal, at the ordinary level, that a snapshot left the uncommitted changes of the main checkout out of the
46///  new worktree, with the destination and the number of paths as fields.
47/// Why:
48///   The line on standard error is gone when the command ends; the journal keeps what the add did with a dirty checkout.
49///
50/// In TS you'd write (pseudocode):
51/// ```ts
52/// journal.info(note, { CLI_GIT_WORKTREE: destination, CLI_GIT_DIRTY_PATHS: count });
53/// ```
54pub fn record_left_out(destination: &Path, changed: &ChangedPaths) {
55    let note: String = left_out_note(changed);
56    let fields: [(&str, String); 3] = [
57        ("CLI_GIT_WORKTREE", destination.to_string_lossy().into_owned()),
58        ("CLI_GIT_DIRTY_PATHS", changed.count.to_string()),
59        ("CLI_GIT_DIRTY_FIRST", changed.first.clone().unwrap_or_default()),
60    ];
61    crate::client_journal::record_entry(crate::journald_native::Priority::Info, note.trim_end(), &fields);
62}
63
64/// What:
65///  Run one captured Git read in the stage under the new worktree's Git directory.
66/// Why:
67///   The proof reads the stage the way the restore wrote it, never the main checkout.
68///
69/// In TS you'd write (pseudocode):
70/// ```ts
71/// const { code, stdout } = await runIn(stage, admin, ['diff-files', '--quiet']);
72/// ```
73fn ask(context: &GitContext, target: &Target, work_tree: &Path, arguments: &[&str]) -> Result<(Option<i32>, Vec<u8>), String> {
74    let request: GitRequest = GitRequest {
75        cwd: work_tree.to_path_buf(),
76        arguments: arguments.iter().map(|word: &&str| { return OsString::from(*word); }).collect(),
77        without_prefix: true,
78        environment: checkout_environment(target.admin.as_path(), work_tree),
79        ..GitRequest::default()
80    };
81    let output = run_git(context, &request).map_err(|error| { return format!("`git {}` could not start ({error})", arguments.join(" ")); })?;
82    return Ok((output.code, output.stdout));
83}
84
85/// What:
86///  Prove that the stage equals what Git's checkout of the target commit makes: the index equals the commit's tree, Git shows
87///  no untracked file, and no tracked file differs from the index.
88/// Why:
89///   The restore trusts Git's reset and clean; the proof asks Git again from the other side, so a state either of them left
90///  (a path the reset skipped, a directory the clean could not remove) ends the rung instead of reaching the person.
91///
92/// In TS you'd write (pseudocode):
93/// ```ts
94/// await proveRestored({ context, target, workTree }); // throws the first thing that is not as Git's checkout leaves it
95/// ```
96pub fn prove_restored(context: &GitContext, target: &Target, work_tree: &Path) -> Result<(), String> {
97    let cached: Option<i32> = ask(context, target, work_tree, &["diff-index", "--cached", "--quiet", target.commit.as_str()])?.0;
98    if cached != Some(0) {
99        return Err(String::from("the restored index does not equal the tree of the new HEAD"));
100    }
101    let (listed, untracked) = ask(context, target, work_tree, &["ls-files", "--others", "--exclude-standard", "--directory", "--no-empty-directory", "-z"])?;
102    if listed != Some(0) {
103        return Err(String::from("Git could not list the untracked files of the restored worktree"));
104    }
105    if let Some(name) = untracked.split(|byte: &u8| { return *byte == 0; }).find(|name: &&[u8]| { return !name.is_empty(); }) {
106        return Err(format!("{} is still untracked in the restored worktree", crate::worktree_snapshot_source::plain(String::from_utf8_lossy(name).as_ref())));
107    }
108    let files: Option<i32> = ask(context, target, work_tree, &["diff-files", "--quiet"])?.0;
109    if files != Some(0) {
110        return Err(String::from("a tracked file of the restored worktree differs from its index entry"));
111    }
112    return Ok(());
113}
114
115/// What:
116///  Restore the stage, which is a snapshot of `source` made with its own index, to the tree of the target commit, and prove it.
117/// Why:
118///   See the module description. The stage is never the source's own directory, and the Git directory is the new worktree's,
119///  so neither run can reach the main checkout.
120///
121/// In TS you'd write (pseudocode):
122/// ```ts
123/// await restoreDirty({ source, target, stage, runGit, context }); // throws the reason
124/// ```
125pub fn restore_dirty(source: &SnapshotSource, target: &Target, stage: &Path, run_git_step: &mut GitRunner, context: &GitContext) -> Result<(), String> {
126    if stage == source.root.as_path() || target.admin == source.git_dir || target.admin == source.common_dir {
127        return Err(String::from("the restore would run in the source's own directory"));
128    }
129    let began: std::time::Instant = std::time::Instant::now();
130    let environment = checkout_environment(target.admin.as_path(), stage);
131    let in_stage = |words: &[&str]| -> Vec<OsString> {
132        let mut arguments: Vec<OsString> = vec![OsString::from("-C"), stage.as_os_str().to_os_string()];
133        arguments.extend(words.iter().map(|word: &&str| { return OsString::from(*word); }));
134        return arguments;
135    };
136    let reset = run_step(run_git_step, in_stage(&["read-tree", "--reset", "-u", target.commit.as_str()]).as_slice(), environment.as_slice())?;
137    if !reset.succeeded() {
138        return Err(format!("`git read-tree --reset -u` ended with {:?}", reset.code));
139    }
140    let reset_ms: u128 = began.elapsed().as_millis();
141    // The `clean` carries no `--quiet`, and what it says ("Removing notes.txt" for each untracked file of the stage) is about
142    //  the stage, which the person's command never names, so Git's words are kept in the journal and not printed. It is run
143    //  captured, like the proof that follows, in the same stage under the same Git directory.
144    let clean_request: GitRequest = GitRequest {
145        cwd: stage.to_path_buf(),
146        arguments: ["clean", "--force", "-d"].iter().map(OsString::from).collect(),
147        without_prefix: true,
148        environment: environment.clone(),
149        ..GitRequest::default()
150    };
151    let clean = run_git(context, &clean_request).map_err(|error| { return format!("`git clean --force -d` could not start ({error})"); })?;
152    if !clean.succeeded() {
153        return Err(format!("`git clean --force -d` ended with {:?}", clean.code));
154    }
155    record_words(&["clean", "--force", "-d"], &clean);
156    let clean_ms: u128 = began.elapsed().as_millis();
157    prove_restored(context, target, stage)?;
158    crate::diagnostic_log::debug(
159        TAG,
160        format!("restored {} to {} and proved it: reset done at {reset_ms} ms, clean at {clean_ms} ms, proof at {} ms", stage.display(), target.commit, began.elapsed().as_millis()).as_str(),
161    );
162    return Ok(());
163}
164
165/// Dirty restore controls stay out of the release executable.
166#[cfg(test)]
167#[path = "worktree_dirty_restore_tests.rs"]
168mod tests;