branchless/core/
check_out.rs

1//! Handle checking out commits on disk.
2
3use std::ffi::{OsStr, OsString};
4use std::fmt::Write;
5use std::time::{SystemTime, UNIX_EPOCH};
6
7use cursive::theme::BaseColor;
8use cursive::utils::markup::StyledString;
9use eyre::Context;
10use itertools::Itertools;
11use tracing::instrument;
12
13use crate::core::config::get_auto_switch_branches;
14use crate::git::{
15    update_index, CategorizedReferenceName, GitRunInfo, MaybeZeroOid, NonZeroOid, ReferenceName,
16    Repo, Stage, UpdateIndexCommand, WorkingCopySnapshot,
17};
18use crate::try_exit_code;
19use crate::util::EyreExitOr;
20
21use super::config::get_undo_create_snapshots;
22use super::effects::Effects;
23use super::eventlog::{Event, EventLogDb, EventTransactionId};
24use super::repo_ext::{RepoExt, RepoReferencesSnapshot};
25
26/// An entity to check out.
27#[derive(Clone, Debug)]
28pub enum CheckoutTarget {
29    /// A commit addressed directly by OID.
30    Oid(NonZeroOid),
31
32    /// A reference. If the reference is a branch, then the branch will be
33    /// checked out.
34    Reference(ReferenceName),
35
36    /// The type of checkout target is not known, as it was provided from the
37    /// user and we haven't resolved it ourselves.
38    Unknown(String),
39}
40
41/// Options for checking out a commit.
42#[derive(Clone, Debug)]
43pub struct CheckOutCommitOptions {
44    /// Additional arguments to pass to `git checkout`.
45    pub additional_args: Vec<OsString>,
46
47    /// Use `git reset` rather than `git checkout`; that is, leave the index and
48    /// working copy unchanged, and just adjust the `HEAD` pointer.
49    pub reset: bool,
50
51    /// Whether or not to render the smartlog after the checkout has completed.
52    pub render_smartlog: bool,
53}
54
55impl Default for CheckOutCommitOptions {
56    fn default() -> Self {
57        Self {
58            additional_args: Default::default(),
59            reset: false,
60            render_smartlog: true,
61        }
62    }
63}
64
65fn maybe_get_branch_name(
66    current_target: Option<String>,
67    oid: Option<NonZeroOid>,
68    repo: &Repo,
69) -> eyre::Result<Option<String>> {
70    let RepoReferencesSnapshot {
71        head_oid,
72        branch_oid_to_names,
73        ..
74    } = repo.get_references_snapshot()?;
75    let oid = match current_target {
76        Some(_) => oid,
77        None => head_oid,
78    };
79    if current_target.is_some()
80        && ((head_oid.is_some() && head_oid == oid)
81            || current_target == head_oid.map(|o| o.to_string()))
82    {
83        // Don't try to checkout the branch if we aren't actually checking anything new out.
84        return Ok(current_target);
85    }
86
87    // Determine if the oid corresponds to exactly a single branch. If so,
88    // check that out directly.
89    match oid {
90        Some(oid) => match branch_oid_to_names.get(&oid) {
91            Some(branch_names) => match branch_names.iter().exactly_one() {
92                Ok(branch_name) => {
93                    // To remove the `refs/heads/` prefix
94                    let name = CategorizedReferenceName::new(branch_name);
95                    Ok(Some(name.render_suffix()))
96                }
97                Err(_) => Ok(current_target),
98            },
99            None => Ok(current_target),
100        },
101        None => Ok(current_target),
102    }
103}
104
105/// Checks out the requested commit. If the operation succeeds, then displays
106/// the new smartlog. Otherwise displays a warning message.
107#[instrument]
108pub fn check_out_commit(
109    effects: &Effects,
110    git_run_info: &GitRunInfo,
111    repo: &Repo,
112    event_log_db: &EventLogDb,
113    event_tx_id: EventTransactionId,
114    target: Option<CheckoutTarget>,
115    options: &CheckOutCommitOptions,
116) -> EyreExitOr<()> {
117    let CheckOutCommitOptions {
118        additional_args,
119        reset,
120        render_smartlog,
121    } = options;
122
123    let (target, oid) = match target {
124        None => (None, None),
125        Some(CheckoutTarget::Reference(reference_name)) => {
126            let categorized_target = CategorizedReferenceName::new(&reference_name);
127            (Some(categorized_target.render_suffix()), None)
128        }
129        Some(CheckoutTarget::Oid(oid)) => (Some(oid.to_string()), Some(oid)),
130        Some(CheckoutTarget::Unknown(target)) => (Some(target), None),
131    };
132
133    if get_undo_create_snapshots(repo)? {
134        create_snapshot(effects, git_run_info, repo, event_log_db, event_tx_id)?;
135    }
136
137    let target = if get_auto_switch_branches(repo)? && !reset {
138        maybe_get_branch_name(target, oid, repo)?
139    } else {
140        target
141    };
142
143    if *reset {
144        if let Some(target) = &target {
145            try_exit_code!(git_run_info.run(effects, Some(event_tx_id), &["reset", target])?);
146        }
147    } else {
148        let checkout_args = {
149            let mut args = vec![OsStr::new("checkout")];
150            if let Some(target) = &target {
151                args.push(OsStr::new(target.as_str()));
152            }
153            args.extend(additional_args.iter().map(OsStr::new));
154            args
155        };
156        match git_run_info.run(effects, Some(event_tx_id), checkout_args.as_slice())? {
157            Ok(()) => {}
158            Err(exit_code) => {
159                writeln!(
160                    effects.get_output_stream(),
161                    "{}",
162                    effects.get_glyphs().render(StyledString::styled(
163                        match target {
164                            Some(target) => format!("Failed to check out commit: {target}"),
165                            None => "Failed to check out commit".to_string(),
166                        },
167                        BaseColor::Red.light()
168                    ))?
169                )?;
170                return Ok(Err(exit_code));
171            }
172        }
173    }
174
175    // Determine if we currently have a snapshot checked out, and, if so,
176    // attempt to restore it.
177    {
178        let head_info = repo.get_head_info()?;
179        if let Some(head_oid) = head_info.oid {
180            let head_commit = repo.find_commit_or_fail(head_oid)?;
181            if let Some(snapshot) = WorkingCopySnapshot::try_from_base_commit(repo, &head_commit)? {
182                try_exit_code!(restore_snapshot(
183                    effects,
184                    git_run_info,
185                    repo,
186                    event_tx_id,
187                    &snapshot
188                )?);
189            }
190        }
191    }
192
193    if *render_smartlog {
194        try_exit_code!(
195            git_run_info.run_direct_no_wrapping(Some(event_tx_id), &["branchless", "smartlog"])?
196        );
197    }
198    Ok(Ok(()))
199}
200
201/// Create a working copy snapshot containing the working copy's current contents.
202///
203/// The working copy contents are not changed by this operation. That is, the
204/// caller would be responsible for discarding local changes (which might or
205/// might not be the natural next step for the operation).
206pub fn create_snapshot<'repo>(
207    effects: &Effects,
208    git_run_info: &GitRunInfo,
209    repo: &'repo Repo,
210    event_log_db: &EventLogDb,
211    event_tx_id: EventTransactionId,
212) -> eyre::Result<WorkingCopySnapshot<'repo>> {
213    writeln!(
214        effects.get_error_stream(),
215        "branchless: creating working copy snapshot"
216    )?;
217
218    let head_info = repo.get_head_info()?;
219    let index = repo.get_index()?;
220    let (snapshot, _status) =
221        repo.get_status(effects, git_run_info, &index, &head_info, Some(event_tx_id))?;
222    event_log_db.add_events(vec![Event::WorkingCopySnapshot {
223        timestamp: SystemTime::now().duration_since(UNIX_EPOCH)?.as_secs_f64(),
224        event_tx_id,
225        head_oid: MaybeZeroOid::from(head_info.oid),
226        commit_oid: snapshot.base_commit.get_oid(),
227        ref_name: head_info.reference_name,
228    }])?;
229    Ok(snapshot)
230}
231
232/// Restore the given snapshot's contents into the working copy.
233///
234/// All tracked working copy contents are **discarded**, so the caller should
235/// take a snapshot of them first, or otherwise ensure that the user's work is
236/// not lost.
237///
238/// If there are untracked changes in the working copy, they are left intact,
239/// *unless* they would conflict with the working copy snapshot contents. In
240/// that case, the operation is aborted.
241pub fn restore_snapshot(
242    effects: &Effects,
243    git_run_info: &GitRunInfo,
244    repo: &Repo,
245    event_tx_id: EventTransactionId,
246    snapshot: &WorkingCopySnapshot,
247) -> EyreExitOr<()> {
248    writeln!(
249        effects.get_error_stream(),
250        "branchless: restoring from snapshot"
251    )?;
252
253    // Discard any working copy changes. The caller is responsible for having
254    // snapshotted them if necessary.
255    try_exit_code!(git_run_info
256        .run(effects, Some(event_tx_id), &["reset", "--hard", "HEAD"])
257        .wrap_err("Discarding working copy changes")?);
258
259    // Check out the unstaged changes. Note that we don't call `git reset --hard
260    // <target>` directly as part of the previous step, and instead do this
261    // two-step process. This second `git checkout` is so that untracked files
262    // don't get thrown away as part of checking out the snapshot, but instead
263    // abort the procedure.
264    // FIXME: it might be worth attempting to un-check-out this commit?
265    try_exit_code!(git_run_info
266        .run(
267            effects,
268            Some(event_tx_id),
269            &["checkout", &snapshot.commit_unstaged.get_oid().to_string()],
270        )
271        .wrap_err("Checking out unstaged changes (fail if conflict)")?);
272
273    // Restore any unstaged changes. They're already present in the working
274    // copy, so we just have to adjust `HEAD`.
275    match &snapshot.head_commit {
276        Some(head_commit) => {
277            try_exit_code!(git_run_info
278                .run(
279                    effects,
280                    Some(event_tx_id),
281                    &["reset", &head_commit.get_oid().to_string()],
282                )
283                .wrap_err("Update HEAD for unstaged changes")?);
284        }
285        None => {
286            // Do nothing. The branch, if any, will be restored later below.
287        }
288    }
289
290    // Check out the staged changes.
291    let update_index_script = {
292        let mut commands = Vec::new();
293        for (stage, commit) in [
294            (Stage::Stage0, &snapshot.commit_stage0),
295            (Stage::Stage1, &snapshot.commit_stage1),
296            (Stage::Stage2, &snapshot.commit_stage2),
297            (Stage::Stage3, &snapshot.commit_stage3),
298        ] {
299            let changed_paths = repo.get_paths_touched_by_commit(commit)?;
300            for path in changed_paths {
301                let tree = commit.get_tree()?;
302                let tree_entry = tree.get_path(&path)?;
303
304                let is_deleted = tree_entry.is_none();
305                if is_deleted {
306                    commands.push(UpdateIndexCommand::Delete { path: path.clone() })
307                }
308
309                if let Some(tree_entry) = tree_entry {
310                    commands.push(UpdateIndexCommand::Update {
311                        path,
312                        stage,
313                        mode: tree_entry.get_filemode(),
314                        oid: tree_entry.get_oid(),
315                    })
316                }
317            }
318        }
319        commands
320    };
321    let index = repo.get_index()?;
322    update_index(
323        git_run_info,
324        repo,
325        &index,
326        event_tx_id,
327        &update_index_script,
328    )?;
329
330    // If the snapshot had a branch, then we've just checked out the branch to
331    // the base commit, but it should point to the head commit.  Move it there.
332    if let Some(ref_name) = &snapshot.head_reference_name {
333        let head_oid = match &snapshot.head_commit {
334            Some(head_commit) => MaybeZeroOid::NonZero(head_commit.get_oid()),
335            None => MaybeZeroOid::Zero,
336        };
337        try_exit_code!(git_run_info
338            .run(
339                effects,
340                Some(event_tx_id),
341                &["update-ref", ref_name.as_str(), &head_oid.to_string()],
342            )
343            .context("Restoring snapshot branch")?);
344
345        try_exit_code!(git_run_info
346            .run(
347                effects,
348                Some(event_tx_id),
349                &["symbolic-ref", "HEAD", ref_name.as_str()],
350            )
351            .context("Checking out snapshot branch")?);
352    }
353
354    Ok(Ok(()))
355}