use std::{ ffi::{OsStr, OsString}, io::{IsTerminal, Read, Write}, path::{Path, PathBuf}, sync::atomic::AtomicBool, }; use anyhow::{Context, Result}; use gix::ObjectId; use crate::{ app::App, edit::{self, rebase, todo}, history::{self, Authors, Decorations, Event, HistoryGraph}, }; #[derive(Debug, clap::Subcommand)] pub(super) enum Command { /// Produce a self-contained rebase todo, or edit and apply it immediately. Todo(Todo), /// Apply a self-contained rebase todo from FILE or standard input. #[command( after_long_help = "Conflicts change nothing by default. To opt in, write the continuation only when needed:\n tix rebase apply --materialize-conflicts todo.continue.md todo.md\nResolve the index, then run:\n tix rebase apply todo.continue.md\nUse --materialize-conflicts=- to write a continuation to non-terminal stdout." )] Apply(Apply), } #[derive(Debug, clap::Args)] #[command( after_long_help = "Without --edit-and-apply, the todo is written to stdout. With it, Git's normal editor selection is used; GIT_EDITOR= overrides it.\n\nExamples:\n tix rebase todo -x main topic >todo.md\n ${GIT_EDITOR:-editor} todo.md\n tix rebase apply todo.md\n tix rebase todo --edit-and-apply -x main topic\n tix rebase todo --edit-and-apply --materialize-conflicts todo.continue.md -x main topic" )] pub(super) struct Todo { /// Hide this revision and derive the editable fork point from it. #[arg(short = 'x', long, value_name = "REVSPEC")] hide: Vec, /// Do not infer hidden local branches from remote HEADs. #[arg(long)] no_auto_hide: bool, /// Rebase the derived scope onto this commit instead of its fork point. #[arg(long, value_name = "REV", conflicts_with = "update_base")] onto: Option, /// Rebase onto the newer hidden local branch tip associated with the fork point. #[arg(long)] update_base: bool, /// Open the todo in Git's editor and apply it after the editor exits. #[arg(long)] edit_and_apply: bool, /// On conflict, materialize it and write a continuation todo to FILE, or stdout if omitted or '-'. #[arg( long, value_name = "CONTINUE", num_args = 0..=1, default_missing_value = "-", requires = "edit_and_apply" )] materialize_conflicts: Option, /// Visible traversal tips, or HEAD if omitted. #[arg(value_name = "TIP")] tips: Vec, } #[derive(Debug, clap::Args)] pub(super) struct Apply { /// On conflict, materialize it and write a continuation todo to FILE, or stdout if omitted or '-'. #[arg(long, value_name = "CONTINUE", num_args = 0..=1, default_missing_value = "-")] pub(super) materialize_conflicts: Option, /// Todo file to apply; omit or use '-' to read standard input. #[arg(value_name = "FILE")] pub(super) file: Option, } pub(super) fn run(repo: gix::Repository, command: Command) -> Result<()> { match command { Command::Todo(args) => todo(repo, args), Command::Apply(args) => apply(repo, args), } } fn todo(repo: gix::Repository, args: Todo) -> Result<()> { let prepared = prepare(&repo, &args)?; if !args.edit_and_apply { std::io::stdout() .write_all(&prepared.document) .context("could not write the rebase todo")?; return Ok(()); } let editor = repo .editor_command() .context("could not prepare Git editor")? .context("no Git editor is available")?; let edited = edit::edit_document_without_terminal( editor, &prepared.document, &format!("tix-rebase-{}.md", std::process::id()), )? .unwrap_or(prepared.document); apply_document(repo, &edited, args.materialize_conflicts.as_deref()) } fn prepare(repo: &gix::Repository, args: &Todo) -> Result { let (hide, unavailable) = history::available_hidden_revisions(repo, &args.hide, !args.no_auto_hide)?; if hide.is_empty() { anyhow::bail!( "rebase todo requires at least one -x/--hide revision when no remote HEAD maps to a local branch" ); } for (revision, err) in unavailable { eprintln!( "warning: ignoring unavailable hidden revision {}: {err}", revision.to_string_lossy() ); } let refs = history::snapshot(repo, &args.tips, &hide, false)?; let authors = gix::features::threading::OwnShared::new(gix::features::threading::Mutable::new(Authors::default())); let mut app = App::new(usize::MAX); let mut decorations = Decorations::default(); let mut graph = None; history::load( repo, &args.tips, &hide, false, &authors, &AtomicBool::new(false), |event| { match event { Event::Decorations(value) => decorations = value, Event::Commits(commits) => app.extend_commits(commits), Event::HiddenCommits(commits) => app.extend_hidden_commits(commits), Event::Complete(value) => graph = Some(value), Event::VisibleComplete | Event::Cancelled => {} } true }, )?; let graph = graph.context("history traversal did not produce a graph")?; crate::update_hidden_branch_updates(&mut app, Some(&graph), &refs); let mut candidates = app.hidden_rebase_candidates(); if candidates.len() != 1 { if candidates.is_empty() { anyhow::bail!("the hidden and visible revisions have no editable fork point"); } candidates.sort_by_key(|(id, _)| *id); anyhow::bail!( "the revisions have multiple editable fork points: {}", candidates .iter() .map(|(id, _)| id.to_string()) .collect::>() .join(", ") ); } let (base, scope) = candidates.pop().context("one rebase candidate was expected")?; let (onto, onto_kind) = if args.update_base { let onto = app .hidden_branch_update(base) .context("--update-base found no newer hidden local branch tip for the derived base")?; (onto, todo::OntoKind::UpdatedBase) } else { ( args.onto .as_deref() .map(|revision| resolve_commit(repo, revision, "onto revision")) .transpose()? .unwrap_or(base), todo::OntoKind::Onto, ) }; let commits = crate::load_rebase_todo_commits(repo, &mut app, &authors, &scope)?; todo::prepare(repo, base, onto, &commits, &refs.view_tips, onto_kind, true) } fn resolve_commit(repo: &gix::Repository, revision: &OsStr, description: &str) -> Result { let revision = gix::path::os_str_into_bstr(revision).with_context(|| format!("{description} is not valid UTF-8"))?; crate::history::resolve_revision(repo, revision) .with_context(|| format!("could not resolve {description}")) .map(|(id, _reference)| id) } fn apply(repo: gix::Repository, args: Apply) -> Result<()> { let mut document = Vec::new(); match args.file.as_deref() { None => { std::io::stdin() .read_to_end(&mut document) .context("could not read the rebase todo from standard input")?; } Some(path) if path == Path::new("-") => { std::io::stdin() .read_to_end(&mut document) .context("could not read the rebase todo from standard input")?; } Some(path) => { document = std::fs::read(path).with_context(|| format!("could not read rebase todo at {}", path.display()))?; } } apply_document(repo, &document, args.materialize_conflicts.as_deref()) } fn apply_document(repo: gix::Repository, document: &[u8], materialize_conflicts: Option<&Path>) -> Result<()> { let Some(parsed) = todo::parse(&repo, document)? else { println!("no rebase performed: the todo was cancelled"); return Ok(()); }; let graph = HistoryGraph::for_commits(&repo, &parsed.plan.scope)?; let repository_path = repo.git_dir().to_owned(); let bare = repo.is_bare(); let tips = parsed.tips; match rebase::perform_plan(&repo, &graph, parsed.plan)? { rebase::PlanPerform::Complete(outcome) => { let revisions = mapped_revisions(&tips, |id| outcome.map(id)); let changes = if let Some(selected) = outcome.selected { let (notice, changes) = match edit::time_travel::checkout_plan_reporting( &repository_path, bare, &outcome, &revisions, false, ) { Ok(result) => result, Err(err) => { super::record_undo(&repo, "rebase history", Ok(outcome.ref_changes)); return Err(err); } }; let notice = notice.unwrap_or_else(|| "rebased history".into()); println!("{}", super::notice_with_change_id(&repo, ¬ice, selected)?); changes } else { println!("rebased history"); outcome.ref_changes.clone() }; super::print_ref_rewrites(&repo, &outcome.ref_rewrites)?; super::record_undo(&repo, "rebase history", Ok(changes)); Ok(()) } rebase::PlanPerform::Conflict(conflict) => { handle_plan_conflict(&repo, conflict, materialize_conflicts, &tips, "rebase") } } } pub(super) fn handle_plan_conflict( repo: &gix::Repository, mut conflict: rebase::PlanConflict, materialize_conflicts: Option<&Path>, tips: &[ObjectId], operation: &str, ) -> Result<()> { let Some(destination) = materialize_conflicts else { anyhow::bail!( "{operation} aborted without changes: conflict while applying {}; pass --materialize-conflicts to opt in", conflict.original().to_hex_with_len(7) ); }; if destination == Path::new("-") && std::io::stdout().is_terminal() { anyhow::bail!( "{operation} aborted without changes: refusing to materialize a conflict without a continuation output file" ); } conflict.persist_objects()?; let plan = conflict.continuation_plan(); let mapped_tips = tips.iter().filter_map(|id| conflict.map(*id)).collect(); let continuation = todo::prepare_continuation(conflict.repository(), &plan, mapped_tips, true)?.document; let revisions = mapped_revisions(tips, |id| conflict.map(id)); if destination == Path::new("-") { let mut stdout = std::io::stdout().lock(); stdout .write_all(&continuation) .and_then(|_| stdout.flush()) .context("could not write the continuation rebase todo")?; } else { let mut output = std::fs::OpenOptions::new() .write(true) .create_new(true) .open(destination) .with_context(|| format!("could not create continuation rebase todo at {}", destination.display()))?; output .write_all(&continuation) .with_context(|| format!("could not write continuation rebase todo at {}", destination.display()))?; } let materialized = edit::time_travel::materialize_plan_conflict_reporting( conflict, repo.git_dir(), repo.is_bare(), &revisions, false, ); let (notice, _, ref_rewrites, ref_changes) = match materialized { Ok(materialized) => materialized, Err(err) => { if destination != Path::new("-") { let _ = std::fs::remove_file(destination); } return Err(err); } }; if destination == Path::new("-") { for line in super::ref_rewrite_lines(repo, &ref_rewrites)? { eprintln!("{line}"); } } else { super::print_ref_rewrites(repo, &ref_rewrites)?; } super::record_undo(repo, "materialize rebase conflict", Ok(ref_changes)); eprintln!("{notice}; continue with `tix rebase apply {}`", destination.display()); anyhow::bail!("{operation} stopped at a materialized conflict") } fn mapped_revisions(tips: &[ObjectId], mut map: impl FnMut(ObjectId) -> Option) -> Vec { tips.iter() .filter_map(|id| map(*id)) .map(|id| OsString::from(id.to_string())) .collect() } #[cfg(test)] mod tests { use super::*; use std::process::Command; fn repository() -> gix_testtools::Result<(gix_testtools::tempfile::TempDir, gix::Repository)> { let fixture = gix_testtools::scripted_fixture_writable("rebase_edit.sh")?; let repo = crate::test_repository::open_with( fixture.path(), ["core.abbrev=7", "user.name=todo author", "user.email=todo@example.com"], )?; Ok((fixture, repo)) } #[test] fn generates_a_self_contained_todo_from_hidden_and_visible_revisions() -> gix_testtools::Result { let (_fixture, repo) = repository()?; let prepared = prepare( &repo, &Todo { hide: vec!["HEAD~2".into()], no_auto_hide: false, onto: None, update_base: false, edit_and_apply: false, materialize_conflicts: None, tips: Vec::new(), }, )?; let document = String::from_utf8(prepared.document)?; assert!(document.contains("