Skip to main content

git_branchless_record/
lib.rs

1//! Commit changes in the working copy.
2
3#![warn(missing_docs)]
4#![warn(
5    clippy::all,
6    clippy::as_conversions,
7    clippy::clone_on_ref_ptr,
8    clippy::dbg_macro
9)]
10#![allow(clippy::too_many_arguments, clippy::blocks_in_conditions)]
11
12use std::collections::HashSet;
13use std::ffi::OsString;
14use std::fmt::Write;
15use std::time::SystemTime;
16
17use git_branchless_invoke::CommandContext;
18use git_branchless_opts::{MessageArgs, RecordArgs, ResolveRevsetOptions, Revset};
19use git_branchless_reword::{ResolveFixupCommitError, edit_message, resolve_commit_to_fixup};
20use itertools::Itertools;
21use lib::core::check_out::{CheckOutCommitOptions, CheckoutTarget, check_out_commit};
22use lib::core::config::{get_commit_template, get_restack_preserve_timestamps};
23use lib::core::dag::{CommitSet, Dag};
24use lib::core::effects::{Effects, OperationType};
25use lib::core::eventlog::{EventLogDb, EventReplayer, EventTransactionId};
26use lib::core::formatting::Pluralize;
27use lib::core::repo_ext::RepoExt;
28use lib::core::rewrite::{
29    BuildRebasePlanError, BuildRebasePlanOptions, ExecuteRebasePlanOptions,
30    ExecuteRebasePlanResult, MergeConflictRemediation, RebasePlanBuilder, RebasePlanPermissions,
31    RepoResource, execute_rebase_plan,
32};
33use lib::core::untracked_file_cache::{UntrackedFileStrategy, process_untracked_files};
34use lib::git::{
35    CategorizedReferenceName, FileMode, GitRunInfo, MaybeZeroOid, NonZeroOid, Repo,
36    ResolvedReferenceInfo, Stage, UpdateIndexCommand, WorkingCopyChangesType, WorkingCopySnapshot,
37    process_diff_for_record, summarize_diff_for_temporary_commit, update_index,
38};
39use lib::try_exit_code;
40use lib::util::{ExitCode, EyreExitOr};
41use rayon::ThreadPoolBuilder;
42use scm_record::helpers::CrosstermInput;
43use scm_record::{
44    Commit, Event, RecordError, RecordInput, RecordState, Recorder, SelectedContents, TerminalKind,
45};
46use tracing::{instrument, warn};
47
48/// Commit changes in the working copy.
49#[instrument]
50pub fn command_main(ctx: CommandContext, args: RecordArgs) -> EyreExitOr<()> {
51    let CommandContext {
52        effects,
53        git_run_info,
54    } = ctx;
55    let RecordArgs {
56        message_args: MessageArgs {
57            messages,
58            commit_to_fixup,
59        },
60        interactive,
61        create,
62        detach,
63        insert,
64        stash,
65        untracked_file_strategy,
66    } = args;
67    record(
68        &effects,
69        &git_run_info,
70        messages,
71        commit_to_fixup,
72        interactive,
73        create,
74        detach,
75        insert,
76        stash,
77        untracked_file_strategy,
78    )
79}
80
81#[instrument]
82fn record(
83    effects: &Effects,
84    git_run_info: &GitRunInfo,
85    messages: Vec<String>,
86    commit_to_fixup: Option<Revset>,
87    interactive: bool,
88    branch_name: Option<String>,
89    detach: bool,
90    insert: bool,
91    stash: bool,
92    untracked_file_strategy: Option<UntrackedFileStrategy>,
93) -> EyreExitOr<()> {
94    let now = SystemTime::now();
95    let repo = Repo::from_dir(&git_run_info.working_directory)?;
96    let conn = repo.get_db_conn()?;
97    let event_log_db = EventLogDb::new(&conn)?;
98    let event_tx_id = event_log_db.make_transaction_id(now, "record")?;
99
100    let (snapshot, working_copy_changes_type, files_to_add) = {
101        let head_info = repo.get_head_info()?;
102        let index = repo.get_index()?;
103        let (snapshot, _status) =
104            repo.get_status(effects, git_run_info, &index, &head_info, Some(event_tx_id))?;
105
106        let working_copy_changes_type = snapshot.get_working_copy_changes_type()?;
107        let files_to_add = match working_copy_changes_type {
108            WorkingCopyChangesType::None => {
109                let files_to_add = if interactive {
110                    Vec::new()
111                } else {
112                    try_exit_code!(process_untracked_files(
113                        effects,
114                        git_run_info,
115                        &repo,
116                        event_tx_id,
117                        untracked_file_strategy,
118                    )?)
119                };
120
121                if files_to_add.is_empty() {
122                    writeln!(
123                        effects.get_output_stream(),
124                        "There are no changes to tracked files in the working copy to commit."
125                    )?;
126                    return Ok(Ok(()));
127                } else {
128                    files_to_add
129                }
130            }
131            WorkingCopyChangesType::Unstaged | WorkingCopyChangesType::Staged if interactive => {
132                Vec::new()
133            }
134            WorkingCopyChangesType::Staged => Vec::new(),
135            WorkingCopyChangesType::Unstaged => {
136                try_exit_code!(process_untracked_files(
137                    effects,
138                    git_run_info,
139                    &repo,
140                    event_tx_id,
141                    untracked_file_strategy,
142                )?)
143            }
144            WorkingCopyChangesType::Conflicts => {
145                writeln!(
146                    effects.get_output_stream(),
147                    "Cannot commit changes while there are unresolved merge conflicts."
148                )?;
149                writeln!(
150                    effects.get_output_stream(),
151                    "Resolve them and try again. Aborting."
152                )?;
153                return Ok(Err(ExitCode(1)));
154            }
155        };
156
157        (snapshot, working_copy_changes_type, files_to_add)
158    };
159
160    if let Some(branch_name) = branch_name {
161        try_exit_code!(check_out_commit(
162            effects,
163            git_run_info,
164            &repo,
165            &event_log_db,
166            event_tx_id,
167            None,
168            &CheckOutCommitOptions {
169                additional_args: vec![OsString::from("-b"), OsString::from(branch_name)],
170                force_detach: false,
171                reset: false,
172                render_smartlog: false,
173            },
174        )?);
175    }
176
177    if interactive {
178        if working_copy_changes_type == WorkingCopyChangesType::Staged {
179            writeln!(
180                effects.get_output_stream(),
181                "Cannot select changes interactively while there are already staged changes."
182            )?;
183            writeln!(
184                effects.get_output_stream(),
185                "Either commit or unstage your changes and try again. Aborting."
186            )?;
187            return Ok(Err(ExitCode(1)));
188        } else {
189            try_exit_code!(record_interactive(
190                effects,
191                git_run_info,
192                &repo,
193                &snapshot,
194                event_tx_id,
195                messages,
196            )?);
197        }
198    } else {
199        if !files_to_add.is_empty() {
200            // call `git add` for the new untracked files to be commited
201            //
202            // Note that `git commit` has some functionality for this, by
203            // listing files as arguments to the commit command. However, the
204            // docs for that state "the commit will ignore changes staged in the
205            // index, and instead record the current content of the listed files
206            // (which must already be known to Git);" (See
207            // https://git-scm.com/docs/git-commit#_description, item 3)
208            //
209            // The implication is that new working copy files would cause the
210            // commit to ignore any changes in the index, which is not what we
211            // want. Looking into this can be future scope; it could avoid this
212            // extra call to `git add`. On the other hand, this extra call is
213            // not very onerous.
214
215            let args = {
216                let mut args = vec!["add".to_string()];
217                // use repo-canonical paths even if adding in a repo subdir
218                args.extend(files_to_add.iter().map(|p| format!(":/{p}")));
219                args
220            };
221            let _ = git_run_info.run_direct_no_wrapping(Some(event_tx_id), &args)?;
222        }
223
224        let messages = if messages.is_empty() && stash {
225            get_default_stash_message(&repo, effects, &snapshot, &working_copy_changes_type)
226                .map(|message| vec![message])?
227        } else {
228            messages
229        };
230        let args = {
231            let mut args = vec!["commit".to_string()];
232            args.extend(
233                messages
234                    .iter()
235                    .flat_map(|message| ["--message".to_string(), message.to_string()]),
236            );
237            if working_copy_changes_type == WorkingCopyChangesType::Unstaged {
238                args.push("--all".to_string());
239            }
240            if let Some(revset) = commit_to_fixup {
241                let event_replayer =
242                    EventReplayer::from_event_log_db(effects, &repo, &event_log_db)?;
243                let event_cursor = event_replayer.make_default_cursor();
244                let references_snapshot = repo.get_references_snapshot()?;
245                let mut dag = Dag::open_and_sync(
246                    effects,
247                    &repo,
248                    &event_replayer,
249                    event_cursor,
250                    &references_snapshot,
251                )?;
252                let head: Option<&[lib::git::Commit<'_>]> =
253                    snapshot.head_commit.as_ref().map(std::slice::from_ref);
254                let commit = match resolve_commit_to_fixup(
255                    &repo,
256                    &mut dag,
257                    effects,
258                    &revset,
259                    &ResolveRevsetOptions::default(),
260                    head,
261                )? {
262                    Ok(commit) => commit,
263                    Err(ResolveFixupCommitError::NotAnAncestor) => {
264                        writeln!(
265                            effects.get_error_stream(),
266                            "The commit supplied to --fixup must be an ancestor of the commit being created.\nAborting.",
267                        )?;
268                        return Ok(Err(ExitCode(1)));
269                    }
270                    Err(ResolveFixupCommitError::MoreThanOneCommit {
271                        revset_to_fixup,
272                        commit_count,
273                    }) => {
274                        writeln!(
275                            effects.get_error_stream(),
276                            "--fixup expects exactly 1 commit, but '{revset_to_fixup}' evaluated to {commit_count}.\nAborting.",
277                        )?;
278                        return Ok(Err(ExitCode(1)));
279                    }
280                };
281                let commit = commit.get_oid().to_string();
282                args.extend(["--fixup".to_string(), commit]);
283            };
284            args
285        };
286        try_exit_code!(git_run_info.run_direct_no_wrapping(Some(event_tx_id), &args)?);
287    }
288
289    if detach || stash {
290        let head_info = repo.get_head_info()?;
291        let checkout_target = match &head_info {
292            ResolvedReferenceInfo {
293                oid: None,
294                reference_name: Some(reference_name),
295            } => {
296                // FIXME: unborn HEAD, what to do?
297                Some(CheckoutTarget::Reference(reference_name.clone()))
298            }
299
300            ResolvedReferenceInfo {
301                oid: Some(oid),
302                reference_name: Some(reference_name),
303            } => {
304                let head_commit = repo.find_commit_or_fail(*oid)?;
305                match head_commit.get_parents().as_slice() {
306                    [] => try_exit_code!(git_run_info.run(
307                        effects,
308                        Some(event_tx_id),
309                        &[
310                            "update-ref",
311                            "-d",
312                            reference_name.as_str(),
313                            &oid.to_string(),
314                        ],
315                    )?),
316                    [parent_commit] => {
317                        let branch_name =
318                            CategorizedReferenceName::new(reference_name).render_suffix();
319                        repo.detach_head(&head_info)?;
320                        try_exit_code!(git_run_info.run(
321                            effects,
322                            Some(event_tx_id),
323                            &[
324                                "branch",
325                                "-f",
326                                &branch_name,
327                                &parent_commit.get_oid().to_string(),
328                            ],
329                        )?);
330                    }
331                    parent_commits => {
332                        eyre::bail!(
333                            "git-branchless record --detach called on a merge commit, but it should only be capable of creating zero- or one-parent commits. Parents: {parent_commits:?}"
334                        );
335                    }
336                }
337
338                Some(CheckoutTarget::Reference(reference_name.clone()))
339            }
340
341            ResolvedReferenceInfo {
342                oid: Some(oid),
343                reference_name: None,
344            } => {
345                let head_commit = repo.find_commit_or_fail(*oid)?;
346                match head_commit.get_parents().as_slice() {
347                    [] => {
348                        eyre::bail!(
349                            "git-branchless record --stash seems to have created a root commit (commit without parents), but this should be impossible."
350                        );
351                    }
352                    [parent_commit] => Some(CheckoutTarget::Oid(parent_commit.get_oid())),
353                    parent_commits => {
354                        eyre::bail!(
355                            "git-branchless record --stash seems to have created a merge commit, but this should be impossible. Parents: {parent_commits:?}"
356                        );
357                    }
358                }
359            }
360
361            ResolvedReferenceInfo {
362                oid: None,
363                reference_name: None,
364            } => None,
365        };
366
367        if stash && checkout_target.is_some() {
368            try_exit_code!(check_out_commit(
369                effects,
370                git_run_info,
371                &repo,
372                &event_log_db,
373                event_tx_id,
374                checkout_target,
375                &CheckOutCommitOptions {
376                    additional_args: vec![],
377                    force_detach: false,
378                    reset: false,
379                    render_smartlog: false,
380                },
381            )?);
382        }
383    }
384
385    if insert {
386        try_exit_code!(insert_before_siblings(
387            effects,
388            git_run_info,
389            now,
390            event_tx_id
391        )?);
392    }
393
394    Ok(Ok(()))
395}
396
397#[instrument]
398fn record_interactive(
399    effects: &Effects,
400    git_run_info: &GitRunInfo,
401    repo: &Repo,
402    snapshot: &WorkingCopySnapshot,
403    event_tx_id: EventTransactionId,
404    messages: Vec<String>,
405) -> EyreExitOr<()> {
406    let old_tree = snapshot.commit_stage0.get_tree()?;
407    let new_tree = snapshot.commit_unstaged.get_tree()?;
408    let files = {
409        let (effects, _progress) = effects.start_operation(OperationType::CalculateDiff);
410        let diff = repo.get_diff_between_trees(
411            &effects,
412            Some(&old_tree),
413            &new_tree,
414            // We manually add context to the git-record output, so suppress the context lines here.
415            0,
416        )?;
417        process_diff_for_record(repo, &diff)?
418    };
419    let record_state = RecordState {
420        is_read_only: false,
421        commits: vec![
422            Commit {
423                message: Some(messages.iter().join("\n\n")),
424            },
425            Commit { message: None },
426        ],
427        files,
428    };
429
430    struct Input<'a> {
431        git_run_info: &'a GitRunInfo,
432        repo: &'a Repo,
433    }
434    impl RecordInput for Input<'_> {
435        fn terminal_kind(&self) -> TerminalKind {
436            TerminalKind::Crossterm
437        }
438
439        fn next_events(&mut self) -> Result<Vec<Event>, RecordError> {
440            CrosstermInput.next_events()
441        }
442
443        fn edit_commit_message(&mut self, message: &str) -> Result<String, RecordError> {
444            let Self { git_run_info, repo } = self;
445            let commit_template = get_commit_template(repo).map_err(|err| {
446                RecordError::Other(format!("Could not read commit message template: {err}",))
447            })?;
448            let message = if message.is_empty() {
449                commit_template.as_deref().unwrap_or("")
450            } else {
451                message
452            };
453            edit_message(git_run_info, repo, message)
454                .map_err(|err| RecordError::Other(err.to_string()))
455        }
456    }
457    let mut input = Input { git_run_info, repo };
458    let recorder = Recorder::new(record_state, &mut input);
459    let result = recorder.run();
460    let RecordState {
461        is_read_only: _,
462        commits,
463        files: result,
464    } = match result {
465        Ok(result) => result,
466        Err(RecordError::Cancelled) => {
467            println!("Aborted.");
468            return Ok(Err(ExitCode(1)));
469        }
470        Err(RecordError::Bug(message)) => {
471            println!("BUG: {message}");
472            println!("This is a bug. Please report it.");
473            return Ok(Err(ExitCode(1)));
474        }
475        Err(
476            err @ (RecordError::SetUpTerminal(_)
477            | RecordError::CleanUpTerminal(_)
478            | RecordError::ReadInput(_)
479            | RecordError::RenderFrame(_)
480            | RecordError::SerializeJson(_)
481            | RecordError::WriteFile(_)
482            | RecordError::Other(_)),
483        ) => {
484            println!("Error: {err}");
485            return Ok(Err(ExitCode(1)));
486        }
487    };
488    let message = commits[0].message.clone().unwrap_or_default();
489
490    let update_index_script: Vec<UpdateIndexCommand> = result
491        .into_iter()
492        .map(|file| -> eyre::Result<UpdateIndexCommand> {
493            let (selected, _unselected) = file.get_selected_contents();
494
495            let mode = {
496                let default_mode = FileMode::Blob;
497                match selected.file_mode {
498                    scm_record::FileMode::Absent => {
499                        warn!(
500                            ?file,
501                            ?default_mode,
502                            "No file mode was set for file, using default"
503                        );
504                        default_mode
505                    }
506                    scm_record::FileMode::Unix(mode) => match i32::try_from(mode) {
507                        Ok(mode) => FileMode::from(mode),
508                        Err(err) => {
509                            warn!(
510                                ?mode,
511                                ?default_mode,
512                                ?err,
513                                "File mode did not fit into i32, using default"
514                            );
515                            default_mode
516                        }
517                    },
518                }
519            };
520
521            let oid = match selected.file_mode {
522                scm_record::FileMode::Absent => MaybeZeroOid::Zero,
523                scm_record::FileMode::Unix(_) => match selected.contents {
524                    SelectedContents::Unchanged => {
525                        old_tree.get_oid_for_path(&file.path)?.unwrap_or_default()
526                    }
527                    SelectedContents::Binary {
528                        old_description: _,
529                        new_description: _,
530                    } => new_tree.get_oid_for_path(&file.path)?.unwrap(),
531                    SelectedContents::Text { contents } => {
532                        MaybeZeroOid::NonZero(repo.create_blob_from_contents(contents.as_bytes())?)
533                    }
534                },
535            };
536            let command = match oid {
537                MaybeZeroOid::Zero => UpdateIndexCommand::Delete {
538                    path: file.path.clone().into_owned(),
539                },
540                MaybeZeroOid::NonZero(oid) => UpdateIndexCommand::Update {
541                    path: file.path.clone().into_owned(),
542                    stage: Stage::Stage0,
543                    mode,
544                    oid,
545                },
546            };
547            Ok(command)
548        })
549        .try_collect()?;
550    let index = repo.get_index()?;
551    update_index(
552        git_run_info,
553        repo,
554        &index,
555        event_tx_id,
556        &update_index_script,
557    )?;
558
559    let args = {
560        let mut args = vec!["commit"];
561        if !message.is_empty() {
562            args.extend(["--message", &message]);
563        }
564        args
565    };
566    git_run_info.run_direct_no_wrapping(Some(event_tx_id), &args)
567}
568
569#[instrument]
570fn insert_before_siblings(
571    effects: &Effects,
572    git_run_info: &GitRunInfo,
573    now: SystemTime,
574    event_tx_id: EventTransactionId,
575) -> EyreExitOr<()> {
576    // Reopen the repository since references may have changed.
577    let repo = Repo::from_dir(&git_run_info.working_directory)?;
578    let conn = repo.get_db_conn()?;
579    let event_log_db = EventLogDb::new(&conn)?;
580    let references_snapshot = repo.get_references_snapshot()?;
581    let event_replayer = EventReplayer::from_event_log_db(effects, &repo, &event_log_db)?;
582    let event_cursor = event_replayer.make_default_cursor();
583    let head_info = repo.get_head_info()?;
584    let head_oid = match head_info {
585        ResolvedReferenceInfo {
586            oid: Some(head_oid),
587            reference_name: _,
588        } => head_oid,
589        ResolvedReferenceInfo {
590            oid: None,
591            reference_name: _,
592        } => {
593            return Ok(Ok(()));
594        }
595    };
596
597    let dag = Dag::open_and_sync(
598        effects,
599        &repo,
600        &event_replayer,
601        event_cursor,
602        &references_snapshot,
603    )?;
604    let head_commit = repo.find_commit_or_fail(head_oid)?;
605    let head_commit_set = CommitSet::from(head_oid);
606    let parents = dag.query_parents(head_commit_set.clone())?;
607    let children = dag.query_children(parents)?;
608    let siblings = children.difference(&head_commit_set);
609    let siblings = dag.filter_visible_commits(siblings)?;
610    let build_options = BuildRebasePlanOptions {
611        force_rewrite_public_commits: false,
612        dump_rebase_constraints: false,
613        dump_rebase_plan: false,
614        detect_duplicate_commits_via_patch_id: true,
615    };
616
617    let rebase_plan_result =
618        match RebasePlanPermissions::verify_rewrite_set(&dag, build_options, &siblings)? {
619            Err(err) => Err(err),
620            Ok(permissions) => {
621                let head_commit_parents: HashSet<_> =
622                    head_commit.get_parent_oids().into_iter().collect();
623                let mut builder = RebasePlanBuilder::new(&dag, permissions);
624                for sibling_oid in dag.commit_set_to_vec(&siblings)? {
625                    let sibling_commit = repo.find_commit_or_fail(sibling_oid)?;
626                    let parent_oids = sibling_commit.get_parent_oids();
627                    let new_parent_oids = parent_oids
628                        .into_iter()
629                        .map(|parent_oid| {
630                            if head_commit_parents.contains(&parent_oid) {
631                                head_oid
632                            } else {
633                                parent_oid
634                            }
635                        })
636                        .collect_vec();
637                    builder.move_subtree(sibling_oid, new_parent_oids)?;
638                }
639                let thread_pool = ThreadPoolBuilder::new().build()?;
640                let repo_pool = RepoResource::new_pool(&repo)?;
641                builder.build(effects, &thread_pool, &repo_pool)?
642            }
643        };
644
645    let rebase_plan = match rebase_plan_result {
646        Ok(Some(rebase_plan)) => rebase_plan,
647
648        Ok(None) => {
649            // Nothing to do, since there were no siblings to move.
650            return Ok(Ok(()));
651        }
652
653        Err(BuildRebasePlanError::ConstraintCycle { .. }) => {
654            writeln!(
655                effects.get_output_stream(),
656                "BUG: constraint cycle detected when moving siblings, which shouldn't be possible."
657            )?;
658            return Ok(Err(ExitCode(1)));
659        }
660
661        Err(err @ BuildRebasePlanError::MoveIllegalCommits { .. }) => {
662            err.describe(effects, &repo, &dag)?;
663            return Ok(Err(ExitCode(1)));
664        }
665
666        Err(BuildRebasePlanError::MovePublicCommits {
667            public_commits_to_move,
668        }) => {
669            let example_bad_commit_oid = dag
670                .set_first(&public_commits_to_move)?
671                .ok_or_else(|| eyre::eyre!("BUG: could not get OID of a public commit to move"))?;
672            let example_bad_commit_oid = NonZeroOid::try_from(example_bad_commit_oid)?;
673            let example_bad_commit = repo.find_commit_or_fail(example_bad_commit_oid)?;
674            writeln!(
675                effects.get_output_stream(),
676                "\
677You are trying to rewrite {}, such as: {}
678It is generally not advised to rewrite public commits, because your
679collaborators will have difficulty merging your changes.
680To proceed anyways, run: git move -f -s 'siblings(.)",
681                Pluralize {
682                    determiner: None,
683                    amount: dag.set_count(&public_commits_to_move)?,
684                    unit: ("public commit", "public commits")
685                },
686                effects
687                    .get_glyphs()
688                    .render(example_bad_commit.friendly_describe(effects.get_glyphs())?)?,
689            )?;
690            return Ok(Ok(()));
691        }
692    };
693
694    let execute_options = ExecuteRebasePlanOptions {
695        now,
696        event_tx_id,
697        preserve_timestamps: get_restack_preserve_timestamps(&repo)?,
698        force_in_memory: true,
699        force_on_disk: false,
700        dry_run: false,
701        resolve_merge_conflicts: false,
702        check_out_commit_options: Default::default(),
703    };
704    let result = execute_rebase_plan(
705        effects,
706        git_run_info,
707        &repo,
708        &event_log_db,
709        &rebase_plan,
710        &execute_options,
711    )?;
712    match result {
713        ExecuteRebasePlanResult::Succeeded { rewritten_oids: _ }
714        | ExecuteRebasePlanResult::WouldSucceed => Ok(Ok(())),
715        ExecuteRebasePlanResult::DeclinedToMerge { failed_merge_info } => {
716            failed_merge_info.describe(effects, &repo, MergeConflictRemediation::Insert)?;
717            Ok(Ok(()))
718        }
719        ExecuteRebasePlanResult::Failed { exit_code } => Ok(Err(exit_code)),
720    }
721}
722
723#[instrument]
724fn get_default_stash_message(
725    repo: &Repo,
726    effects: &Effects,
727    snapshot: &WorkingCopySnapshot,
728    working_copy_changes_type: &WorkingCopyChangesType,
729) -> eyre::Result<String> {
730    let (old_tree, new_tree) = match working_copy_changes_type {
731        WorkingCopyChangesType::Unstaged => {
732            let old_tree = snapshot.commit_stage0.get_tree()?;
733            let new_tree = snapshot.commit_unstaged.get_tree()?;
734            (Some(old_tree), new_tree)
735        }
736        WorkingCopyChangesType::Staged => {
737            let old_tree = match snapshot.head_commit {
738                None => None,
739                Some(ref commit) => Some(commit.get_tree()?),
740            };
741            let new_tree = snapshot.commit_stage0.get_tree()?;
742            (old_tree, new_tree)
743        }
744        WorkingCopyChangesType::None | WorkingCopyChangesType::Conflicts => {
745            unreachable!("already handled via early exit")
746        }
747    };
748
749    let diff = repo.get_diff_between_trees(
750        effects,
751        old_tree.as_ref(),
752        &new_tree,
753        0, // we don't care about the context here
754    )?;
755
756    Ok(format!(
757        "stash: {}",
758        summarize_diff_for_temporary_commit(&diff)?
759    ))
760}