1#![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#[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 let args = {
216 let mut args = vec!["add".to_string()];
217 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 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 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 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 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, )?;
755
756 Ok(format!(
757 "stash: {}",
758 summarize_diff_for_temporary_commit(&diff)?
759 ))
760}