Skip to main content

cadvm_core/
checkout.rs

1//! Working-tree mutation: `checkout`, `switch` and `revert`.
2//!
3//! All three share a single, conservative restore engine that never deletes
4//! untracked files and never overwrites a locally modified file without
5//! `--force`.
6
7use std::collections::{BTreeMap, BTreeSet};
8use std::path::PathBuf;
9
10use cadvm_store::ObjectId;
11
12use crate::error::{CoreError, Result};
13use crate::model::{CommitBody, Manifest};
14use crate::repo::{Head, Repository};
15use crate::revision;
16use crate::status::working_tree_status;
17use crate::worktree;
18
19/// Summary of files changed on disk by a restore operation.
20#[derive(Debug, Clone, Default)]
21pub struct RestoreOutcome {
22    pub written: Vec<PathBuf>,
23    pub deleted: Vec<PathBuf>,
24}
25
26/// Hash every tracked-format file currently on disk.
27fn working_hashes(repo: &Repository) -> Result<BTreeMap<PathBuf, ObjectId>> {
28    let mut map = BTreeMap::new();
29    for rel in worktree::scan_step_files(repo)? {
30        map.insert(rel.clone(), worktree::hash_working_file(repo, &rel)?);
31    }
32    Ok(map)
33}
34
35/// Restore the working tree from `baseline` (currently tracked) to `target`.
36///
37/// When `only` is `Some`, the restore is *path-scoped*: only the listed paths
38/// are written and **nothing is deleted** (matching `git checkout <rev> -- f`).
39///
40/// Safety rules (unless `force`):
41/// * a file whose on-disk content differs from `baseline` (locally modified or
42///   untracked) is never overwritten or deleted;
43/// * untracked files (absent from `baseline`) are never deleted.
44fn restore(
45    repo: &Repository,
46    baseline: &Manifest,
47    target: &Manifest,
48    force: bool,
49    only: Option<&BTreeSet<PathBuf>>,
50) -> Result<RestoreOutcome> {
51    let working = working_hashes(repo)?;
52    let selected = |path: &PathBuf| only.is_none_or(|set| set.contains(path));
53
54    // First pass: detect conflicts without touching the disk.
55    if !force {
56        // Files we would write.
57        for (path, entry) in &target.files {
58            if !selected(path) {
59                continue;
60            }
61            let on_disk = working.get(path);
62            if on_disk == Some(&entry.raw_hash) {
63                continue; // already correct
64            }
65            if let Some(disk_hash) = on_disk {
66                let baseline_hash = baseline.files.get(path).map(|e| &e.raw_hash);
67                if Some(disk_hash) != baseline_hash {
68                    // Locally modified or untracked content would be clobbered.
69                    return Err(CoreError::WouldOverwriteDirtyFile(path.clone()));
70                }
71            }
72        }
73        // Files we would delete (tracked by baseline, absent from target).
74        // Path-scoped restores never delete.
75        if only.is_none() {
76            for (path, entry) in &baseline.files {
77                if target.files.contains_key(path) {
78                    continue;
79                }
80                if let Some(disk_hash) = working.get(path) {
81                    if disk_hash != &entry.raw_hash {
82                        return Err(CoreError::WouldOverwriteDirtyFile(path.clone()));
83                    }
84                }
85            }
86        }
87    }
88
89    // Second pass: apply.
90    let mut outcome = RestoreOutcome::default();
91
92    for (path, entry) in &target.files {
93        if !selected(path) {
94            continue;
95        }
96        if working.get(path) == Some(&entry.raw_hash) {
97            continue;
98        }
99        let content = repo.store().read_file_content(&entry.blob_ref)?;
100        let abs = worktree::abs_path(repo, path);
101        if let Some(parent) = abs.parent() {
102            std::fs::create_dir_all(parent).map_err(|e| CoreError::io(parent, e))?;
103        }
104        std::fs::write(&abs, &content).map_err(|e| CoreError::io(&abs, e))?;
105        outcome.written.push(path.clone());
106    }
107
108    if only.is_none() {
109        for path in baseline.files.keys() {
110            if target.files.contains_key(path) {
111                continue;
112            }
113            // Only delete files that are still present and clean (or --force).
114            if !working.contains_key(path) {
115                continue;
116            }
117            let abs = worktree::abs_path(repo, path);
118            match std::fs::remove_file(&abs) {
119                Ok(()) => outcome.deleted.push(path.clone()),
120                Err(e) if e.kind() == std::io::ErrorKind::NotFound => {}
121                Err(e) => return Err(CoreError::io(&abs, e)),
122            }
123        }
124    }
125
126    outcome.written.sort();
127    outcome.deleted.sort();
128    Ok(outcome)
129}
130
131/// Result of `checkout`.
132#[derive(Debug, Clone)]
133pub struct CheckoutOutcome {
134    pub commit_id: ObjectId,
135    pub restore: RestoreOutcome,
136}
137
138/// Restore the working tree to a revision **without moving the current branch**
139/// (a "restore-like" checkout). HEAD stays attached to its branch.
140///
141/// If `paths` is non-empty, only those files are restored from the revision
142/// (and nothing is deleted); each must exist in that revision.
143pub fn checkout(
144    repo: &Repository,
145    rev: &str,
146    paths: &[PathBuf],
147    force: bool,
148) -> Result<CheckoutOutcome> {
149    let commit_id = revision::resolve(repo, rev)?;
150    let target = repo.manifest_of_commit(&commit_id)?;
151    let baseline = repo.head_manifest()?;
152
153    let only: Option<BTreeSet<PathBuf>> = if paths.is_empty() {
154        None
155    } else {
156        let set: BTreeSet<PathBuf> = paths.iter().cloned().collect();
157        for path in &set {
158            if !target.files.contains_key(path) {
159                return Err(CoreError::PathNotInRevision {
160                    path: path.clone(),
161                    rev: rev.to_string(),
162                });
163            }
164        }
165        Some(set)
166    };
167
168    let restore = restore(repo, &baseline, &target, force, only.as_ref())?;
169    Ok(CheckoutOutcome { commit_id, restore })
170}
171
172/// Result of `switch`.
173#[derive(Debug, Clone)]
174pub struct SwitchOutcome {
175    pub branch: String,
176    pub commit_id: Option<ObjectId>,
177    pub restore: RestoreOutcome,
178}
179
180/// Switch HEAD to another branch, restoring its files. Refuses a dirty working
181/// tree unless `force`.
182pub fn switch(repo: &Repository, branch: &str, force: bool) -> Result<SwitchOutcome> {
183    if !repo.branch_exists(branch) {
184        return Err(CoreError::NoSuchBranch(branch.to_string()));
185    }
186
187    if !force && !working_tree_status(repo)?.is_clean() {
188        return Err(CoreError::DirtyWorkingTree {
189            action: "switch".to_string(),
190        });
191    }
192
193    let baseline = repo.head_manifest()?;
194    let commit_id = repo.read_ref(branch)?;
195    let target = match &commit_id {
196        Some(id) => repo.manifest_of_commit(id)?,
197        None => Manifest::empty(),
198    };
199    let restore = restore(repo, &baseline, &target, force, None)?;
200    repo.write_head(&Head::Branch(branch.to_string()))?;
201
202    Ok(SwitchOutcome {
203        branch: branch.to_string(),
204        commit_id,
205        restore,
206    })
207}
208
209/// Result of `revert`.
210#[derive(Debug, Clone)]
211pub struct RevertOutcome {
212    pub new_commit_id: ObjectId,
213    pub reverted_commit_id: ObjectId,
214    pub restore: RestoreOutcome,
215    pub branch: Option<String>,
216}
217
218/// Revert HEAD: create a new commit that restores the state of HEAD's parent.
219///
220/// Only reverting HEAD itself is supported. Refuses a dirty working tree unless
221/// `force`.
222pub fn revert(
223    repo: &Repository,
224    rev: &str,
225    force: bool,
226    timestamp_unix: i64,
227) -> Result<RevertOutcome> {
228    let target_id = revision::resolve(repo, rev)?;
229    let head_id = repo
230        .head_commit_id()?
231        .ok_or_else(|| CoreError::UnknownRevision("HEAD".to_string()))?;
232    if target_id != head_id {
233        return Err(CoreError::RevertNonHead(rev.to_string()));
234    }
235
236    if !force && !working_tree_status(repo)?.is_clean() {
237        return Err(CoreError::DirtyWorkingTree {
238            action: "revert".to_string(),
239        });
240    }
241
242    let head_commit = repo.read_commit(&head_id)?;
243    let parent_id = head_commit
244        .parents
245        .first()
246        .cloned()
247        .ok_or(CoreError::NoParent)?;
248    let parent_commit = repo.read_commit(&parent_id)?;
249
250    // Restore working tree to the parent's manifest.
251    let baseline = repo.read_manifest(&head_commit.manifest)?;
252    let target = repo.read_manifest(&parent_commit.manifest)?;
253    let restore = restore(repo, &baseline, &target, force, None)?;
254
255    // Create the revert commit, reusing the parent's manifest, with HEAD as parent.
256    let body = CommitBody {
257        parents: vec![head_id.clone()],
258        manifest: parent_commit.manifest.clone(),
259        message: format!("Revert \"{}\"", head_commit.message),
260        timestamp_unix,
261        author: Some(crate::config::resolve_author(repo)?),
262    };
263    let new_commit_id = repo.write_commit(&body)?;
264
265    let branch = match repo.read_head()? {
266        Head::Branch(name) => {
267            repo.write_ref(&name, &new_commit_id)?;
268            Some(name)
269        }
270        Head::Detached(_) => {
271            repo.write_head(&Head::Detached(new_commit_id.clone()))?;
272            None
273        }
274    };
275
276    Ok(RevertOutcome {
277        new_commit_id,
278        reverted_commit_id: head_id,
279        restore,
280        branch,
281    })
282}