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=<command> 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<OsString>, /// 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<OsString>, /// 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<PathBuf>, /// Visible traversal tips, or HEAD if omitted. #[arg(value_name = "TIP")] tips: Vec<OsString>,} #[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<PathBuf>, /// Todo file to apply; omit or use '-' to read standard input. #[arg(value_name = "FILE")] pub(super) file: Option<PathBuf>,} 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<todo::Prepared> { 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::<Vec<_>>() .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<ObjectId> { 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<ObjectId>) -> Vec<OsString> { 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("<!-- tix-rebase-state-v2"), "state is embedded"); assert!(document.contains("`@pick "), "HEAD is the generated checkout"); assert!( document.contains("2000-01-02 author middle"), "default TUI metadata is present" ); assert!( document.contains("2000-01-03 author tip"), "the subject is always present" ); Ok(()) } #[test] fn requires_a_hidden_revision_at_runtime() -> gix_testtools::Result { let (_fixture, repo) = repository()?; let err = prepare( &repo, &Todo { hide: Vec::new(), no_auto_hide: true, onto: None, update_base: false, edit_and_apply: false, materialize_conflicts: None, tips: Vec::new(), }, ) .expect_err("a hidden revision is required"); assert!(format!("{err:#}").contains("at least one -x/--hide")); Ok(()) } #[test] fn infers_the_hidden_base_from_a_remote_head() -> gix_testtools::Result { let (fixture, _repo) = repository()?; for args in [ &["branch", "base", "HEAD~2"][..], &["config", "remote.origin.url", "https://example.com/repo"][..], &["config", "remote.origin.fetch", "+refs/heads/*:refs/remotes/origin/*"][..], &["update-ref", "refs/remotes/origin/base", "refs/heads/base"][..], &["symbolic-ref", "refs/remotes/origin/HEAD", "refs/remotes/origin/base"][..], ] { let output = Command::new("git").current_dir(fixture.path()).args(args).output()?; assert!( output.status.success(), "git {args:?} prepares the remote default: {}", String::from_utf8_lossy(&output.stderr) ); } let repo = crate::test_repository::open_with( fixture.path(), ["core.abbrev=7", "user.name=todo author", "user.email=todo@example.com"], )?; let prepared = prepare( &repo, &Todo { hide: Vec::new(), no_auto_hide: false, onto: None, update_base: false, edit_and_apply: false, materialize_conflicts: None, tips: Vec::new(), }, )?; assert!( String::from_utf8(prepared.document)?.contains("# Rebase from"), "the inferred local default branch provides the rebase base" ); Ok(()) } #[test] fn update_base_uses_the_newer_hidden_local_branch_tip() -> gix_testtools::Result { let (fixture, repo) = repository()?; drop(repo); for args in [ &["branch", "base", "HEAD~2"][..], &["checkout", "-q", "base"][..], &[ "-c", "user.name=updated base", "-c", "user.email=updated@example.com", "-c", "commit.gpgSign=false", "commit", "-q", "--allow-empty", "-m", "updated base", ][..], &["checkout", "-q", "main"][..], ] { let output = Command::new("git").current_dir(fixture.path()).args(args).output()?; assert!( output.status.success(), "git {args:?} prepares the updated base: {}", String::from_utf8_lossy(&output.stderr) ); } let repo = crate::test_repository::open_with( fixture.path(), ["core.abbrev=7", "user.name=todo author", "user.email=todo@example.com"], )?; let updated = repo.rev_parse_single("base")?.detach(); let prepared = prepare( &repo, &Todo { hide: vec!["base".into()], no_auto_hide: false, onto: None, update_base: true, edit_and_apply: false, materialize_conflicts: None, tips: Vec::new(), }, )?; let document = String::from_utf8(prepared.document)?; assert!( prepared.apply_unchanged, "moving to the updated base makes an unchanged todo actionable" ); assert!( document.contains(&format!( "{} (updated-base)", crate::change_id::display_short(&repo, updated)? )), "the TUI-selected hidden tip is labelled as the updated base: {document}" ); let err = prepare( &repo, &Todo { hide: vec!["HEAD~2".into()], no_auto_hide: false, onto: None, update_base: true, edit_and_apply: false, materialize_conflicts: None, tips: Vec::new(), }, ) .expect_err("a derived revision without a hidden local branch has no update target"); assert!(format!("{err:#}").contains("no newer hidden local branch tip")); Ok(()) } Lines 1–500 of 700. Load more
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=<command> 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<OsString>, /// 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<OsString>, /// 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<PathBuf>, /// Visible traversal tips, or HEAD if omitted. #[arg(value_name = "TIP")] tips: Vec<OsString>, } #[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<PathBuf>, /// Todo file to apply; omit or use '-' to read standard input. #[arg(value_name = "FILE")] pub(super) file: Option<PathBuf>, } 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<todo::Prepared> { 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::<Vec<_>>() .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<ObjectId> { 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<ObjectId>) -> Vec<OsString> { 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("<!-- tix-rebase-state-v2"), "state is embedded"); assert!(document.contains("`@pick "), "HEAD is the generated checkout"); assert!( document.contains("2000-01-02 author middle"), "default TUI metadata is present" ); assert!( document.contains("2000-01-03 author tip"), "the subject is always present" ); Ok(()) } #[test] fn requires_a_hidden_revision_at_runtime() -> gix_testtools::Result { let (_fixture, repo) = repository()?; let err = prepare( &repo, &Todo { hide: Vec::new(), no_auto_hide: true, onto: None, update_base: false, edit_and_apply: false, materialize_conflicts: None, tips: Vec::new(), }, ) .expect_err("a hidden revision is required"); assert!(format!("{err:#}").contains("at least one -x/--hide")); Ok(()) } #[test] fn infers_the_hidden_base_from_a_remote_head() -> gix_testtools::Result { let (fixture, _repo) = repository()?; for args in [ &["branch", "base", "HEAD~2"][..], &["config", "remote.origin.url", "https://example.com/repo"][..], &["config", "remote.origin.fetch", "+refs/heads/*:refs/remotes/origin/*"][..], &["update-ref", "refs/remotes/origin/base", "refs/heads/base"][..], &["symbolic-ref", "refs/remotes/origin/HEAD", "refs/remotes/origin/base"][..], ] { let output = Command::new("git").current_dir(fixture.path()).args(args).output()?; assert!( output.status.success(), "git {args:?} prepares the remote default: {}", String::from_utf8_lossy(&output.stderr) ); } let repo = crate::test_repository::open_with( fixture.path(), ["core.abbrev=7", "user.name=todo author", "user.email=todo@example.com"], )?; let prepared = prepare( &repo, &Todo { hide: Vec::new(), no_auto_hide: false, onto: None, update_base: false, edit_and_apply: false, materialize_conflicts: None, tips: Vec::new(), }, )?; assert!( String::from_utf8(prepared.document)?.contains("# Rebase from"), "the inferred local default branch provides the rebase base" ); Ok(()) } #[test] fn update_base_uses_the_newer_hidden_local_branch_tip() -> gix_testtools::Result { let (fixture, repo) = repository()?; drop(repo); for args in [ &["branch", "base", "HEAD~2"][..], &["checkout", "-q", "base"][..], &[ "-c", "user.name=updated base", "-c", "user.email=updated@example.com", "-c", "commit.gpgSign=false", "commit", "-q", "--allow-empty", "-m", "updated base", ][..], &["checkout", "-q", "main"][..], ] { let output = Command::new("git").current_dir(fixture.path()).args(args).output()?; assert!( output.status.success(), "git {args:?} prepares the updated base: {}", String::from_utf8_lossy(&output.stderr) ); } let repo = crate::test_repository::open_with( fixture.path(), ["core.abbrev=7", "user.name=todo author", "user.email=todo@example.com"], )?; let updated = repo.rev_parse_single("base")?.detach(); let prepared = prepare( &repo, &Todo { hide: vec!["base".into()], no_auto_hide: false, onto: None, update_base: true, edit_and_apply: false, materialize_conflicts: None, tips: Vec::new(), }, )?; let document = String::from_utf8(prepared.document)?; assert!( prepared.apply_unchanged, "moving to the updated base makes an unchanged todo actionable" ); assert!( document.contains(&format!( "{} (updated-base)", crate::change_id::display_short(&repo, updated)? )), "the TUI-selected hidden tip is labelled as the updated base: {document}" ); let err = prepare( &repo, &Todo { hide: vec!["HEAD~2".into()], no_auto_hide: false, onto: None, update_base: true, edit_and_apply: false, materialize_conflicts: None, tips: Vec::new(), }, ) .expect_err("a derived revision without a hidden local branch has no update target"); assert!(format!("{err:#}").contains("no newer hidden local branch tip")); Ok(()) }