700 lines · 26.1 KB · Rust
RawDownload
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, &notice, 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.

Keyboard shortcuts

General

Show keyboard shortcuts
?
Focus the current search
/

Issues and pull requests

Open filters
f

Issues, pull requests and Inbox

Select next item
j↓
Select previous item
k↑
Open selected item
Enter
Dismiss filters or clear selection
Esc

Repository code

Go to file
t