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_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#[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 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 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 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}