Skip to main content

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