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_if_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::RecordArgs;
19use git_branchless_reword::edit_message;
20use itertools::Itertools;
21use lib::core::check_out::{check_out_commit, CheckOutCommitOptions, CheckoutTarget};
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    execute_rebase_plan, BuildRebasePlanError, BuildRebasePlanOptions, ExecuteRebasePlanOptions,
30    ExecuteRebasePlanResult, MergeConflictRemediation, RebasePlanBuilder, RebasePlanPermissions,
31    RepoResource,
32};
33use lib::git::{
34    process_diff_for_record, update_index, CategorizedReferenceName, FileMode, GitRunInfo,
35    MaybeZeroOid, NonZeroOid, Repo, ResolvedReferenceInfo, Stage, UpdateIndexCommand,
36    WorkingCopyChangesType, WorkingCopySnapshot,
37};
38use lib::try_exit_code;
39use lib::util::{ExitCode, EyreExitOr};
40use rayon::ThreadPoolBuilder;
41use scm_record::helpers::CrosstermInput;
42use scm_record::{
43    Commit, Event, RecordError, RecordInput, RecordState, Recorder, SelectedContents, TerminalKind,
44};
45use tracing::{instrument, warn};
46
47/// Commit changes in the working copy.
48#[instrument]
49pub fn command_main(ctx: CommandContext, args: RecordArgs) -> EyreExitOr<()> {
50    let CommandContext {
51        effects,
52        git_run_info,
53    } = ctx;
54    let RecordArgs {
55        messages,
56        interactive,
57        create,
58        detach,
59        insert,
60        stash,
61    } = args;
62    record(
63        &effects,
64        &git_run_info,
65        messages,
66        interactive,
67        create,
68        detach,
69        insert,
70        stash,
71    )
72}
73
74#[instrument]
75fn record(
76    effects: &Effects,
77    git_run_info: &GitRunInfo,
78    messages: Vec<String>,
79    interactive: bool,
80    branch_name: Option<String>,
81    detach: bool,
82    insert: bool,
83    stash: bool,
84) -> EyreExitOr<()> {
85    let now = SystemTime::now();
86    let repo = Repo::from_dir(&git_run_info.working_directory)?;
87    let conn = repo.get_db_conn()?;
88    let event_log_db = EventLogDb::new(&conn)?;
89    let event_tx_id = event_log_db.make_transaction_id(now, "record")?;
90
91    let (snapshot, working_copy_changes_type) = {
92        let head_info = repo.get_head_info()?;
93        let index = repo.get_index()?;
94        let (snapshot, _status) =
95            repo.get_status(effects, git_run_info, &index, &head_info, Some(event_tx_id))?;
96
97        let working_copy_changes_type = snapshot.get_working_copy_changes_type()?;
98        match working_copy_changes_type {
99            WorkingCopyChangesType::None => {
100                writeln!(
101                    effects.get_output_stream(),
102                    "There are no changes to tracked files in the working copy to commit."
103                )?;
104                return Ok(Ok(()));
105            }
106            WorkingCopyChangesType::Unstaged | WorkingCopyChangesType::Staged => {}
107            WorkingCopyChangesType::Conflicts => {
108                writeln!(
109                    effects.get_output_stream(),
110                    "Cannot commit changes while there are unresolved merge conflicts."
111                )?;
112                writeln!(
113                    effects.get_output_stream(),
114                    "Resolve them and try again. Aborting."
115                )?;
116                return Ok(Err(ExitCode(1)));
117            }
118        }
119        (snapshot, working_copy_changes_type)
120    };
121
122    if let Some(branch_name) = branch_name {
123        try_exit_code!(check_out_commit(
124            effects,
125            git_run_info,
126            &repo,
127            &event_log_db,
128            event_tx_id,
129            None,
130            &CheckOutCommitOptions {
131                additional_args: vec![OsString::from("-b"), OsString::from(branch_name)],
132                reset: false,
133                render_smartlog: false,
134            },
135        )?);
136    }
137
138    if interactive {
139        if working_copy_changes_type == WorkingCopyChangesType::Staged {
140            writeln!(
141                effects.get_output_stream(),
142                "Cannot select changes interactively while there are already staged changes."
143            )?;
144            writeln!(
145                effects.get_output_stream(),
146                "Either commit or unstage your changes and try again. Aborting."
147            )?;
148            return Ok(Err(ExitCode(1)));
149        } else {
150            try_exit_code!(record_interactive(
151                effects,
152                git_run_info,
153                &repo,
154                &snapshot,
155                event_tx_id,
156                messages,
157            )?);
158        }
159    } else {
160        let args = {
161            let mut args = vec!["commit"];
162            args.extend(messages.iter().flat_map(|message| ["--message", message]));
163            if working_copy_changes_type == WorkingCopyChangesType::Unstaged {
164                args.push("--all");
165            }
166            args
167        };
168        try_exit_code!(git_run_info.run_direct_no_wrapping(Some(event_tx_id), &args)?);
169    }
170
171    if detach || stash {
172        let head_info = repo.get_head_info()?;
173        if let ResolvedReferenceInfo {
174            oid: Some(oid),
175            reference_name: Some(reference_name),
176        } = &head_info
177        {
178            let head_commit = repo.find_commit_or_fail(*oid)?;
179            match head_commit.get_parents().as_slice() {
180                [] => try_exit_code!(git_run_info.run(
181                    effects,
182                    Some(event_tx_id),
183                    &[
184                        "update-ref",
185                        "-d",
186                        reference_name.as_str(),
187                        &oid.to_string(),
188                    ],
189                )?),
190                [parent_commit] => {
191                    let branch_name = CategorizedReferenceName::new(reference_name).render_suffix();
192                    repo.detach_head(&head_info)?;
193                    try_exit_code!(git_run_info.run(
194                        effects,
195                        Some(event_tx_id),
196                        &[
197                            "branch",
198                            "-f",
199                            &branch_name,
200                            &parent_commit.get_oid().to_string(),
201                        ],
202                    )?);
203                }
204                parent_commits => {
205                    eyre::bail!("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:?}");
206                }
207            }
208        }
209        let checkout_target = match head_info {
210            ResolvedReferenceInfo {
211                oid: _,
212                reference_name: Some(reference_name),
213            } => Some(CheckoutTarget::Reference(reference_name.clone())),
214            ResolvedReferenceInfo {
215                oid: Some(oid),
216                reference_name: _,
217            } => Some(CheckoutTarget::Oid(oid)),
218            _ => None,
219        };
220        if stash && checkout_target.is_some() {
221            try_exit_code!(check_out_commit(
222                effects,
223                git_run_info,
224                &repo,
225                &event_log_db,
226                event_tx_id,
227                checkout_target,
228                &CheckOutCommitOptions {
229                    additional_args: vec![],
230                    reset: false,
231                    render_smartlog: false,
232                },
233            )?);
234        }
235    }
236
237    if insert {
238        try_exit_code!(insert_before_siblings(
239            effects,
240            git_run_info,
241            now,
242            event_tx_id
243        )?);
244    }
245
246    Ok(Ok(()))
247}
248
249#[instrument]
250fn record_interactive(
251    effects: &Effects,
252    git_run_info: &GitRunInfo,
253    repo: &Repo,
254    snapshot: &WorkingCopySnapshot,
255    event_tx_id: EventTransactionId,
256    messages: Vec<String>,
257) -> EyreExitOr<()> {
258    let old_tree = snapshot.commit_stage0.get_tree()?;
259    let new_tree = snapshot.commit_unstaged.get_tree()?;
260    let files = {
261        let (effects, _progress) = effects.start_operation(OperationType::CalculateDiff);
262        let diff = repo.get_diff_between_trees(
263            &effects,
264            Some(&old_tree),
265            &new_tree,
266            // We manually add context to the git-record output, so suppress the context lines here.
267            0,
268        )?;
269        process_diff_for_record(repo, &diff)?
270    };
271    let record_state = RecordState {
272        is_read_only: false,
273        commits: vec![
274            Commit {
275                message: Some(messages.iter().join("\n\n")),
276            },
277            Commit { message: None },
278        ],
279        files,
280    };
281
282    struct Input<'a> {
283        git_run_info: &'a GitRunInfo,
284        repo: &'a Repo,
285    }
286    impl RecordInput for Input<'_> {
287        fn terminal_kind(&self) -> TerminalKind {
288            TerminalKind::Crossterm
289        }
290
291        fn next_events(&mut self) -> Result<Vec<Event>, RecordError> {
292            CrosstermInput.next_events()
293        }
294
295        fn edit_commit_message(&mut self, message: &str) -> Result<String, RecordError> {
296            let Self { git_run_info, repo } = self;
297            let commit_template = get_commit_template(repo).map_err(|err| {
298                RecordError::Other(format!("Could not read commit message template: {err}",))
299            })?;
300            let message = if message.is_empty() {
301                commit_template.as_deref().unwrap_or("")
302            } else {
303                message
304            };
305            edit_message(git_run_info, repo, message)
306                .map_err(|err| RecordError::Other(err.to_string()))
307        }
308    }
309    let mut input = Input { git_run_info, repo };
310    let recorder = Recorder::new(record_state, &mut input);
311    let result = recorder.run();
312    let RecordState {
313        is_read_only: _,
314        commits,
315        files: result,
316    } = match result {
317        Ok(result) => result,
318        Err(RecordError::Cancelled) => {
319            println!("Aborted.");
320            return Ok(Err(ExitCode(1)));
321        }
322        Err(RecordError::Bug(message)) => {
323            println!("BUG: {message}");
324            println!("This is a bug. Please report it.");
325            return Ok(Err(ExitCode(1)));
326        }
327        Err(
328            err @ (RecordError::SetUpTerminal(_)
329            | RecordError::CleanUpTerminal(_)
330            | RecordError::ReadInput(_)
331            | RecordError::RenderFrame(_)
332            | RecordError::SerializeJson(_)
333            | RecordError::WriteFile(_)
334            | RecordError::Other(_)),
335        ) => {
336            println!("Error: {err}");
337            return Ok(Err(ExitCode(1)));
338        }
339    };
340    let message = commits[0].message.clone().unwrap_or_default();
341
342    let update_index_script: Vec<UpdateIndexCommand> = result
343        .into_iter()
344        .map(|file| -> eyre::Result<UpdateIndexCommand> {
345            let mode = {
346                let default_mode = FileMode::Blob;
347                match file.get_file_mode() {
348                    None => {
349                        warn!(
350                            ?file,
351                            ?default_mode,
352                            "No file mode was set for file, using default"
353                        );
354                        default_mode
355                    }
356                    Some(mode) => match i32::try_from(mode) {
357                        Ok(mode) => FileMode::from(mode),
358                        Err(err) => {
359                            warn!(
360                                ?mode,
361                                ?default_mode,
362                                ?err,
363                                "File mode did not fit into i32, using default"
364                            );
365                            default_mode
366                        }
367                    },
368                }
369            };
370
371            let (selected, _unselected) = file.get_selected_contents();
372            let oid = match selected {
373                SelectedContents::Absent => MaybeZeroOid::Zero,
374                SelectedContents::Unchanged => {
375                    old_tree.get_oid_for_path(&file.path)?.unwrap_or_default()
376                }
377                SelectedContents::Binary {
378                    old_description: _,
379                    new_description: _,
380                } => new_tree.get_oid_for_path(&file.path)?.unwrap(),
381                SelectedContents::Present { contents } => {
382                    MaybeZeroOid::NonZero(repo.create_blob_from_contents(contents.as_bytes())?)
383                }
384            };
385            let command = match oid {
386                MaybeZeroOid::Zero => UpdateIndexCommand::Delete {
387                    path: file.path.clone().into_owned(),
388                },
389                MaybeZeroOid::NonZero(oid) => UpdateIndexCommand::Update {
390                    path: file.path.clone().into_owned(),
391                    stage: Stage::Stage0,
392                    mode,
393                    oid,
394                },
395            };
396            Ok(command)
397        })
398        .try_collect()?;
399    let index = repo.get_index()?;
400    update_index(
401        git_run_info,
402        repo,
403        &index,
404        event_tx_id,
405        &update_index_script,
406    )?;
407
408    let args = {
409        let mut args = vec!["commit"];
410        if !message.is_empty() {
411            args.extend(["--message", &message]);
412        }
413        args
414    };
415    git_run_info.run_direct_no_wrapping(Some(event_tx_id), &args)
416}
417
418#[instrument]
419fn insert_before_siblings(
420    effects: &Effects,
421    git_run_info: &GitRunInfo,
422    now: SystemTime,
423    event_tx_id: EventTransactionId,
424) -> EyreExitOr<()> {
425    // Reopen the repository since references may have changed.
426    let repo = Repo::from_dir(&git_run_info.working_directory)?;
427    let conn = repo.get_db_conn()?;
428    let event_log_db = EventLogDb::new(&conn)?;
429    let references_snapshot = repo.get_references_snapshot()?;
430    let event_replayer = EventReplayer::from_event_log_db(effects, &repo, &event_log_db)?;
431    let event_cursor = event_replayer.make_default_cursor();
432    let head_info = repo.get_head_info()?;
433    let head_oid = match head_info {
434        ResolvedReferenceInfo {
435            oid: Some(head_oid),
436            reference_name: _,
437        } => head_oid,
438        ResolvedReferenceInfo {
439            oid: None,
440            reference_name: _,
441        } => {
442            return Ok(Ok(()));
443        }
444    };
445
446    let dag = Dag::open_and_sync(
447        effects,
448        &repo,
449        &event_replayer,
450        event_cursor,
451        &references_snapshot,
452    )?;
453    let head_commit = repo.find_commit_or_fail(head_oid)?;
454    let head_commit_set = CommitSet::from(head_oid);
455    let parents = dag.query_parents(head_commit_set.clone())?;
456    let children = dag.query_children(parents)?;
457    let siblings = children.difference(&head_commit_set);
458    let siblings = dag.filter_visible_commits(siblings)?;
459    let build_options = BuildRebasePlanOptions {
460        force_rewrite_public_commits: false,
461        dump_rebase_constraints: false,
462        dump_rebase_plan: false,
463        detect_duplicate_commits_via_patch_id: true,
464    };
465
466    let rebase_plan_result =
467        match RebasePlanPermissions::verify_rewrite_set(&dag, build_options, &siblings)? {
468            Err(err) => Err(err),
469            Ok(permissions) => {
470                let head_commit_parents: HashSet<_> =
471                    head_commit.get_parent_oids().into_iter().collect();
472                let mut builder = RebasePlanBuilder::new(&dag, permissions);
473                for sibling_oid in dag.commit_set_to_vec(&siblings)? {
474                    let sibling_commit = repo.find_commit_or_fail(sibling_oid)?;
475                    let parent_oids = sibling_commit.get_parent_oids();
476                    let new_parent_oids = parent_oids
477                        .into_iter()
478                        .map(|parent_oid| {
479                            if head_commit_parents.contains(&parent_oid) {
480                                head_oid
481                            } else {
482                                parent_oid
483                            }
484                        })
485                        .collect_vec();
486                    builder.move_subtree(sibling_oid, new_parent_oids)?;
487                }
488                let thread_pool = ThreadPoolBuilder::new().build()?;
489                let repo_pool = RepoResource::new_pool(&repo)?;
490                builder.build(effects, &thread_pool, &repo_pool)?
491            }
492        };
493
494    let rebase_plan = match rebase_plan_result {
495        Ok(Some(rebase_plan)) => rebase_plan,
496
497        Ok(None) => {
498            // Nothing to do, since there were no siblings to move.
499            return Ok(Ok(()));
500        }
501
502        Err(BuildRebasePlanError::ConstraintCycle { .. }) => {
503            writeln!(
504                effects.get_output_stream(),
505                "BUG: constraint cycle detected when moving siblings, which shouldn't be possible."
506            )?;
507            return Ok(Err(ExitCode(1)));
508        }
509
510        Err(err @ BuildRebasePlanError::MoveIllegalCommits { .. }) => {
511            err.describe(effects, &repo, &dag)?;
512            return Ok(Err(ExitCode(1)));
513        }
514
515        Err(BuildRebasePlanError::MovePublicCommits {
516            public_commits_to_move,
517        }) => {
518            let example_bad_commit_oid = dag
519                .set_first(&public_commits_to_move)?
520                .ok_or_else(|| eyre::eyre!("BUG: could not get OID of a public commit to move"))?;
521            let example_bad_commit_oid = NonZeroOid::try_from(example_bad_commit_oid)?;
522            let example_bad_commit = repo.find_commit_or_fail(example_bad_commit_oid)?;
523            writeln!(
524                effects.get_output_stream(),
525                "\
526You are trying to rewrite {}, such as: {}
527It is generally not advised to rewrite public commits, because your
528collaborators will have difficulty merging your changes.
529To proceed anyways, run: git move -f -s 'siblings(.)",
530                Pluralize {
531                    determiner: None,
532                    amount: dag.set_count(&public_commits_to_move)?,
533                    unit: ("public commit", "public commits")
534                },
535                effects
536                    .get_glyphs()
537                    .render(example_bad_commit.friendly_describe(effects.get_glyphs())?)?,
538            )?;
539            return Ok(Ok(()));
540        }
541    };
542
543    let execute_options = ExecuteRebasePlanOptions {
544        now,
545        event_tx_id,
546        preserve_timestamps: get_restack_preserve_timestamps(&repo)?,
547        force_in_memory: true,
548        force_on_disk: false,
549        resolve_merge_conflicts: false,
550        check_out_commit_options: Default::default(),
551    };
552    let result = execute_rebase_plan(
553        effects,
554        git_run_info,
555        &repo,
556        &event_log_db,
557        &rebase_plan,
558        &execute_options,
559    )?;
560    match result {
561        ExecuteRebasePlanResult::Succeeded { rewritten_oids: _ } => Ok(Ok(())),
562        ExecuteRebasePlanResult::DeclinedToMerge { failed_merge_info } => {
563            failed_merge_info.describe(effects, &repo, MergeConflictRemediation::Insert)?;
564            Ok(Ok(()))
565        }
566        ExecuteRebasePlanResult::Failed { exit_code } => Ok(Err(exit_code)),
567    }
568}