diff --git a/.github/workflows/lint.yml b/.github/workflows/lint.yml index d70a3a378..2d43fdae4 100644 --- a/.github/workflows/lint.yml +++ b/.github/workflows/lint.yml @@ -30,7 +30,7 @@ jobs: uses: actions-rs/toolchain@v1 with: profile: minimal - toolchain: 1.74 + toolchain: 1.85 components: rustfmt, clippy override: true diff --git a/.github/workflows/linux-git-devel.yml b/.github/workflows/linux-git-devel.yml index 50d18c895..a9372c1ba 100644 --- a/.github/workflows/linux-git-devel.yml +++ b/.github/workflows/linux-git-devel.yml @@ -1,4 +1,4 @@ -name: "Linux (Git devel)" +name: 'Linux (Git devel)' on: # TODO(#1416): re-enable once tests are passing on Git v2.46+ @@ -54,7 +54,7 @@ jobs: uses: actions-rs/toolchain@v1 with: profile: minimal - toolchain: 1.74 + toolchain: 1.85 override: true - uses: actions/checkout@v5 diff --git a/.github/workflows/linux.yml b/.github/workflows/linux.yml index faa2c65fa..b9ab32b66 100644 --- a/.github/workflows/linux.yml +++ b/.github/workflows/linux.yml @@ -18,7 +18,7 @@ jobs: matrix: # Use a tag from https://github.com/git/git/tags # Make sure to update `git-version` in the `run-tests` step as well. - git-version: ["v2.24.3", "v2.29.2", "v2.33.1", "v2.37.3"] + git-version: ['v2.24.3', 'v2.29.2', 'v2.33.1', 'v2.37.3'] steps: - uses: actions/checkout@v5 @@ -45,7 +45,7 @@ jobs: - name: Package Git run: tar -czf git.tar.gz git git-* - - name: "Upload artifact: git" + - name: 'Upload artifact: git' uses: actions/upload-artifact@v4 with: name: git-${{ matrix.git-version }} @@ -58,7 +58,7 @@ jobs: strategy: matrix: - git-version: ["v2.24.3", "v2.29.2", "v2.33.1", "v2.37.3"] + git-version: ['v2.24.3', 'v2.29.2', 'v2.33.1', 'v2.37.3'] steps: - uses: actions/checkout@v5 @@ -67,14 +67,14 @@ jobs: with: name: git-${{ matrix.git-version }} - - name: "Unpack artifact: git" + - name: 'Unpack artifact: git' run: tar -xf git.tar.gz - name: Set up Rust uses: actions-rs/toolchain@v1 with: profile: minimal - toolchain: 1.74 + toolchain: 1.85 override: true - name: Cache dependencies diff --git a/.github/workflows/macos.yml b/.github/workflows/macos.yml index 17d8b0f2c..fca99330a 100644 --- a/.github/workflows/macos.yml +++ b/.github/workflows/macos.yml @@ -3,7 +3,7 @@ name: macOS on: schedule: # Run once every day at 6:40AM UTC. - - cron: "40 6 * * *" + - cron: '40 6 * * *' push: branches: @@ -27,7 +27,7 @@ jobs: uses: actions-rs/toolchain@v1 with: profile: minimal - toolchain: 1.74 + toolchain: 1.85 override: true - name: Cache dependencies diff --git a/.github/workflows/windows.yml b/.github/workflows/windows.yml index b7c6b3e4b..6ba75f68a 100644 --- a/.github/workflows/windows.yml +++ b/.github/workflows/windows.yml @@ -20,7 +20,7 @@ jobs: uses: actions-rs/toolchain@v1 with: profile: minimal - toolchain: 1.74 + toolchain: 1.85 override: true - name: Cache dependencies diff --git a/.gitpod.yml b/.gitpod.yml index ee7f8e2c2..6d09a953e 100644 --- a/.gitpod.yml +++ b/.gitpod.yml @@ -2,10 +2,10 @@ image: file: .gitpod/Dockerfile tasks: - init: | - rustup default 1.74 + rustup default 1.85 cargo test --no-run cargo install cargo-insta cargo install git-branchless && git branchless init vscode: extensions: - - "matklad.rust-analyzer" + - 'matklad.rust-analyzer' diff --git a/git-branchless-hook/src/lib.rs b/git-branchless-hook/src/lib.rs index f4c8348a0..d339baae6 100644 --- a/git-branchless-hook/src/lib.rs +++ b/git-branchless-hook/src/lib.rs @@ -14,7 +14,7 @@ clippy::clone_on_ref_ptr, clippy::dbg_macro )] -#![allow(clippy::too_many_arguments, clippy::blocks_in_if_conditions)] +#![allow(clippy::too_many_arguments, clippy::blocks_in_conditions)] use std::fmt::Write; use std::fs::File; diff --git a/git-branchless-init/src/lib.rs b/git-branchless-init/src/lib.rs index 5097e6895..37d274cb2 100644 --- a/git-branchless-init/src/lib.rs +++ b/git-branchless-init/src/lib.rs @@ -7,7 +7,7 @@ clippy::clone_on_ref_ptr, clippy::dbg_macro )] -#![allow(clippy::too_many_arguments, clippy::blocks_in_if_conditions)] +#![allow(clippy::too_many_arguments, clippy::blocks_in_conditions)] use std::fmt::Write; use std::io::{stdin, stdout, BufRead, BufReader, Write as WriteIo}; @@ -95,6 +95,7 @@ const ALL_ALIASES: &[(&str, &str)] = &[ ("restack", "restack"), ("reword", "reword"), ("sl", "smartlog"), + ("split", "split"), ("smartlog", "smartlog"), ("submit", "submit"), ("sw", "switch"), diff --git a/git-branchless-invoke/src/lib.rs b/git-branchless-invoke/src/lib.rs index a6cd9738f..d56315bb3 100644 --- a/git-branchless-invoke/src/lib.rs +++ b/git-branchless-invoke/src/lib.rs @@ -11,7 +11,7 @@ clippy::clone_on_ref_ptr, clippy::dbg_macro )] -#![allow(clippy::too_many_arguments, clippy::blocks_in_if_conditions)] +#![allow(clippy::too_many_arguments, clippy::blocks_in_conditions)] use std::any::Any; use std::collections::HashMap; diff --git a/git-branchless-lib/src/core/check_out.rs b/git-branchless-lib/src/core/check_out.rs index 546a09828..6445424fd 100644 --- a/git-branchless-lib/src/core/check_out.rs +++ b/git-branchless-lib/src/core/check_out.rs @@ -44,6 +44,9 @@ pub struct CheckOutCommitOptions { /// Additional arguments to pass to `git checkout`. pub additional_args: Vec, + /// Ignore the `autoSwitchBranches` setting? + pub force_detach: bool, + /// Use `git reset` rather than `git checkout`; that is, leave the index and /// working copy unchanged, and just adjust the `HEAD` pointer. pub reset: bool, @@ -56,6 +59,7 @@ impl Default for CheckOutCommitOptions { fn default() -> Self { Self { additional_args: Default::default(), + force_detach: false, reset: false, render_smartlog: true, } @@ -116,6 +120,7 @@ pub fn check_out_commit( ) -> EyreExitOr<()> { let CheckOutCommitOptions { additional_args, + force_detach, reset, render_smartlog, } = options; @@ -134,7 +139,7 @@ pub fn check_out_commit( create_snapshot(effects, git_run_info, repo, event_log_db, event_tx_id)?; } - let target = if get_auto_switch_branches(repo)? && !reset { + let target = if get_auto_switch_branches(repo)? && !reset && !force_detach { maybe_get_branch_name(target, oid, repo)? } else { target diff --git a/git-branchless-lib/src/git/diff.rs b/git-branchless-lib/src/git/diff.rs index c4d4a2d3d..612ab6dd0 100644 --- a/git-branchless-lib/src/git/diff.rs +++ b/git-branchless-lib/src/git/diff.rs @@ -3,7 +3,7 @@ use std::collections::HashMap; use std::path::PathBuf; use std::sync::{Arc, Mutex}; -use eyre::Context; +use eyre::{Context, OptionExt}; use itertools::Itertools; use scm_record::helpers::make_binary_description; use scm_record::{ChangeType, File, FileMode, Section, SectionChangedLine}; @@ -15,6 +15,17 @@ pub struct Diff<'repo> { pub(super) inner: git2::Diff<'repo>, } +impl Diff<'_> { + /// Summarize this diff into a single line "short" format. + pub fn short_stats(&self) -> eyre::Result { + let stats = self.inner.stats()?; + let buf = stats.to_buf(git2::DiffStatsFormat::SHORT, usize::MAX)?; + buf.as_str() + .ok_or_eyre("converting buf to str") + .map(|s| s.trim().to_string()) + } +} + #[derive(Clone, Debug, PartialEq, Eq, Hash)] struct GitHunk { old_start: usize, @@ -23,6 +34,59 @@ struct GitHunk { new_lines: usize, } +/// Summarize a diff for use as part of a temporary commit message. +pub fn summarize_diff_for_temporary_commit(diff: &Diff) -> eyre::Result { + // this returns something like `1 file changed, 1 deletion(-)` + // diff.short_stats() + + // this returns something like `test2.txt (-1)` or `2 files (+1/-2)` + let stats = diff.inner.stats()?; + let prefix = if stats.files_changed() == 1 { + let mut prefix = None; + // returning false terminates iteration, but that also returns Err, so + // catch and ignore it + let _ = diff.inner.foreach( + &mut |delta: git2::DiffDelta, _| { + if let Some(path) = delta.old_file().path() { + // prefix = Some(format!("{}", path.file_name().unwrap().to_string_lossy())); + prefix = Some(format!("{}", path.display())); + } else if let Some(path) = delta.new_file().path() { + prefix = Some(format!("{}", path.display())); + } + + false + }, + None, + None, + None, + ); + prefix + } else { + Some(format!("{} files", stats.files_changed())) + }; + + let i = stats.insertions(); + let d = stats.deletions(); + Ok(format!( + "{prefix} ({i}{slash}{d})", + prefix = prefix.unwrap(), + i = if i > 0 { + format!("+{i}") + } else { + String::new() + }, + slash = if i > 0 && d > 0 { "/" } else { "" }, + d = if d > 0 { + format!("-{d}") + } else { + String::new() + } + )) + // stats.files_changed() + // stats.insertions() + // stats.deletions() +} + /// Calculate the diff between the index and the working copy. pub fn process_diff_for_record(repo: &Repo, diff: &Diff) -> eyre::Result>> { let Diff { inner: diff } = diff; diff --git a/git-branchless-lib/src/git/index.rs b/git-branchless-lib/src/git/index.rs index f83c3dd20..a6663051b 100644 --- a/git-branchless-lib/src/git/index.rs +++ b/git-branchless-lib/src/git/index.rs @@ -5,7 +5,7 @@ use tracing::instrument; use crate::core::eventlog::EventTransactionId; -use super::{FileMode, GitRunInfo, GitRunOpts, GitRunResult, MaybeZeroOid, NonZeroOid, Repo}; +use super::{FileMode, GitRunInfo, GitRunOpts, GitRunResult, MaybeZeroOid, NonZeroOid, Repo, Tree}; /// The possible stages for items in the index. #[derive(Copy, Clone, Debug)] @@ -88,6 +88,12 @@ impl Index { }, }) } + + /// Update the index from the given tree and write it to disk. + pub fn update_from_tree(&mut self, tree: &Tree) -> eyre::Result<()> { + self.inner.read_tree(&tree.inner)?; + self.inner.write().wrap_err("writing index") + } } /// The command to update the index, as defined by `git update-index`. diff --git a/git-branchless-lib/src/git/mod.rs b/git-branchless-lib/src/git/mod.rs index 8b6a09754..fc0b8cf4e 100644 --- a/git-branchless-lib/src/git/mod.rs +++ b/git-branchless-lib/src/git/mod.rs @@ -14,7 +14,7 @@ mod test; mod tree; pub use config::{Config, ConfigRead, ConfigValue, ConfigWrite}; -pub use diff::{process_diff_for_record, Diff}; +pub use diff::{process_diff_for_record, summarize_diff_for_temporary_commit, Diff}; pub use index::{update_index, Index, IndexEntry, Stage, UpdateIndexCommand}; pub use object::Commit; pub use oid::{MaybeZeroOid, NonZeroOid}; @@ -34,4 +34,6 @@ pub use test::{ make_test_command_slug, SerializedNonZeroOid, SerializedTestResult, TestCommand, TEST_ABORT_EXIT_CODE, TEST_INDETERMINATE_EXIT_CODE, TEST_SUCCESS_EXIT_CODE, }; -pub use tree::{dehydrate_tree, get_changed_paths_between_trees, hydrate_tree, Tree}; +pub use tree::{ + dehydrate_tree, get_changed_paths_between_trees, hydrate_tree, make_empty_tree, Tree, +}; diff --git a/git-branchless-lib/src/git/status.rs b/git-branchless-lib/src/git/status.rs index 72ba3368b..193736d51 100644 --- a/git-branchless-lib/src/git/status.rs +++ b/git-branchless-lib/src/git/status.rs @@ -88,6 +88,20 @@ impl From for FileMode { } } +impl From for git2::FileMode { + fn from(file_mode: FileMode) -> Self { + match file_mode { + FileMode::Blob => git2::FileMode::Blob, + FileMode::BlobExecutable => git2::FileMode::BlobExecutable, + FileMode::BlobGroupWritable => git2::FileMode::BlobGroupWritable, + FileMode::Commit => git2::FileMode::Commit, + FileMode::Link => git2::FileMode::Link, + FileMode::Tree => git2::FileMode::Tree, + FileMode::Unreadable => git2::FileMode::Unreadable, + } + } +} + impl From for FileMode { fn from(file_mode: i32) -> Self { if file_mode == i32::from(git2::FileMode::Blob) { diff --git a/git-branchless-lib/src/git/tree.rs b/git-branchless-lib/src/git/tree.rs index 96865bf2c..ca5adc2e3 100644 --- a/git-branchless-lib/src/git/tree.rs +++ b/git-branchless-lib/src/git/tree.rs @@ -3,6 +3,7 @@ use std::fmt::Debug; use std::path::{Path, PathBuf}; use bstr::ByteVec; +use git2::build::TreeUpdateBuilder; use itertools::Itertools; use thiserror::Error; use tracing::{instrument, warn}; @@ -116,6 +117,31 @@ impl Tree<'_> { .map(|maybe_entry| maybe_entry.map(|entry| entry.inner.id().into())) } + /// Remove the given path from the Tree, creating a new Tree in the given repo. + pub fn remove(&self, repo: &Repo, path: &Path) -> Result { + let mut builder = TreeUpdateBuilder::new(); + let tree_oid = builder + .remove(path) + .create_updated(&repo.inner, &self.inner) + .map_err(Error::BuildTree)?; + Ok(make_non_zero_oid(tree_oid)) + } + + /// Add or replace the given path/entry from the Tree, creating a new Tree in the given repo. + pub fn add_or_replace( + &self, + repo: &Repo, + path: &Path, + entry: &TreeEntry, + ) -> Result { + let mut builder = TreeUpdateBuilder::new(); + let tree_oid = builder + .upsert(path, entry.get_oid().into(), entry.get_filemode().into()) + .create_updated(&repo.inner, &self.inner) + .map_err(Error::BuildTree)?; + Ok(make_non_zero_oid(tree_oid)) + } + /// Get the (top-level) list of paths in this tree, for testing. pub fn get_entry_paths_for_testing(&self) -> impl Debug { self.inner @@ -456,6 +482,7 @@ pub fn hydrate_tree( Ok(make_non_zero_oid(tree_oid)) } +/// Create a new, empty tree. pub fn make_empty_tree(repo: &Repo) -> Result { let tree_oid = hydrate_tree(repo, None, Default::default())?; repo.find_tree_or_fail(tree_oid) diff --git a/git-branchless-lib/src/lib.rs b/git-branchless-lib/src/lib.rs index dfaeffbea..04edaac69 100644 --- a/git-branchless-lib/src/lib.rs +++ b/git-branchless-lib/src/lib.rs @@ -7,7 +7,7 @@ clippy::clone_on_ref_ptr, clippy::dbg_macro )] -#![allow(clippy::too_many_arguments, clippy::blocks_in_if_conditions)] +#![allow(clippy::too_many_arguments, clippy::blocks_in_conditions)] pub mod core; pub mod git; diff --git a/git-branchless-lib/src/testing.rs b/git-branchless-lib/src/testing.rs index cae3432d7..f4531c3bd 100644 --- a/git-branchless-lib/src/testing.rs +++ b/git-branchless-lib/src/testing.rs @@ -81,6 +81,9 @@ pub struct GitRunOptions { /// Additional environment variables to start the process with. pub env: HashMap, + + /// Subdirectory of repo to use as working directory. + pub subdir: Option, } impl Git { @@ -217,8 +220,14 @@ impl Git { expected_exit_code, input, env, + subdir, } = options; + let current_dir = subdir.as_ref().map_or(self.repo_path.clone(), |subdir| { + let mut p = self.repo_path.clone(); + p.push(subdir); + p + }); let env: BTreeMap<_, _> = self .get_base_env(*time) .into_iter() @@ -229,7 +238,7 @@ impl Git { .collect(); let mut command = Command::new(&self.path_to_git); command - .current_dir(&self.repo_path) + .current_dir(¤t_dir) .args(args) .env_clear() .envs(&env); diff --git a/git-branchless-lib/tests/test_rewrite_plan.rs b/git-branchless-lib/tests/test_rewrite_plan.rs index 8adc2c335..200349087 100644 --- a/git-branchless-lib/tests/test_rewrite_plan.rs +++ b/git-branchless-lib/tests/test_rewrite_plan.rs @@ -762,6 +762,7 @@ fn create_and_execute_plan( resolve_merge_conflicts: true, check_out_commit_options: CheckOutCommitOptions { additional_args: Default::default(), + force_detach: false, reset: false, render_smartlog: false, }, diff --git a/git-branchless-move/src/lib.rs b/git-branchless-move/src/lib.rs index 86b081dea..78b175b1d 100644 --- a/git-branchless-move/src/lib.rs +++ b/git-branchless-move/src/lib.rs @@ -10,7 +10,7 @@ clippy::clone_on_ref_ptr, clippy::dbg_macro )] -#![allow(clippy::too_many_arguments, clippy::blocks_in_if_conditions)] +#![allow(clippy::too_many_arguments, clippy::blocks_in_conditions)] use std::collections::HashMap; use std::fmt::Write; diff --git a/git-branchless-navigation/src/lib.rs b/git-branchless-navigation/src/lib.rs index 729a3603d..882361f15 100644 --- a/git-branchless-navigation/src/lib.rs +++ b/git-branchless-navigation/src/lib.rs @@ -7,7 +7,7 @@ clippy::clone_on_ref_ptr, clippy::dbg_macro )] -#![allow(clippy::too_many_arguments, clippy::blocks_in_if_conditions)] +#![allow(clippy::too_many_arguments, clippy::blocks_in_conditions)] pub mod prompt; @@ -617,6 +617,7 @@ pub fn switch( target, &CheckOutCommitOptions { additional_args, + force_detach: false, reset: false, render_smartlog: true, }, diff --git a/git-branchless-opts/src/lib.rs b/git-branchless-opts/src/lib.rs index 702ec1cbc..e5a43f83a 100644 --- a/git-branchless-opts/src/lib.rs +++ b/git-branchless-opts/src/lib.rs @@ -7,7 +7,7 @@ clippy::clone_on_ref_ptr, clippy::dbg_macro )] -#![allow(clippy::too_many_arguments, clippy::blocks_in_if_conditions)] +#![allow(clippy::too_many_arguments, clippy::blocks_in_conditions)] // These URLs are printed verbatim in help output, so we don't want to add extraneous Markdown // formatting. #![allow(rustdoc::bare_urls)] @@ -652,6 +652,37 @@ pub enum Command { subcommand: SnapshotSubcommand, }, + /// Split commits. + Split { + /// Commit to split. If a revset is given, it must resolve to a single commit. + #[clap(value_parser)] + revset: Revset, + + /// Files to extract from the commit. + #[clap(value_parser, required = true)] + files: Vec, + + /// Insert the extracted commit before (as a parent of) the split commit. + #[clap(action, short = 'b', long)] + before: bool, + + /// Restack any descendents onto the split commit, not the extracted commit. + #[clap(action, short = 'd', long)] + detach: bool, + + /// After extracting the changes, don't recommit them. + #[clap(action, short = 'D', long = "discard", conflicts_with("detach"))] + discard: bool, + + /// Options for resolving revset expressions. + #[clap(flatten)] + resolve_revset_options: ResolveRevsetOptions, + + /// Options for moving commits. + #[clap(flatten)] + move_options: MoveOptions, + }, + /// Push commits to a remote. Submit(SubmitArgs), diff --git a/git-branchless-record/src/lib.rs b/git-branchless-record/src/lib.rs index 166f6539b..0e20ccf11 100644 --- a/git-branchless-record/src/lib.rs +++ b/git-branchless-record/src/lib.rs @@ -7,7 +7,7 @@ clippy::clone_on_ref_ptr, clippy::dbg_macro )] -#![allow(clippy::too_many_arguments, clippy::blocks_in_if_conditions)] +#![allow(clippy::too_many_arguments, clippy::blocks_in_conditions)] use std::collections::HashSet; use std::ffi::OsString; @@ -31,9 +31,9 @@ use lib::core::rewrite::{ RepoResource, }; use lib::git::{ - process_diff_for_record, update_index, CategorizedReferenceName, FileMode, GitRunInfo, - MaybeZeroOid, NonZeroOid, Repo, ResolvedReferenceInfo, Stage, UpdateIndexCommand, - WorkingCopyChangesType, WorkingCopySnapshot, + process_diff_for_record, summarize_diff_for_temporary_commit, update_index, + CategorizedReferenceName, FileMode, GitRunInfo, MaybeZeroOid, NonZeroOid, Repo, + ResolvedReferenceInfo, Stage, UpdateIndexCommand, WorkingCopyChangesType, WorkingCopySnapshot, }; use lib::try_exit_code; use lib::util::{ExitCode, EyreExitOr}; @@ -129,6 +129,7 @@ fn record( None, &CheckOutCommitOptions { additional_args: vec![OsString::from("-b"), OsString::from(branch_name)], + force_detach: false, reset: false, render_smartlog: false, }, @@ -157,6 +158,41 @@ fn record( )?); } } else { + let messages = if messages.is_empty() && stash { + let diff_stats = { + let (old_tree, new_tree) = match working_copy_changes_type { + WorkingCopyChangesType::Unstaged => { + let old_tree = snapshot.commit_stage0.get_tree()?; + let new_tree = snapshot.commit_unstaged.get_tree()?; + (Some(old_tree), new_tree) + } + WorkingCopyChangesType::Staged => { + let old_tree = match snapshot.head_commit { + None => None, + Some(ref commit) => Some(commit.get_tree()?), + }; + let new_tree = snapshot.commit_stage0.get_tree()?; + (old_tree, new_tree) + } + WorkingCopyChangesType::None | WorkingCopyChangesType::Conflicts => { + unreachable!("already handled via early exit") + } + }; + + let diff = repo.get_diff_between_trees( + effects, + old_tree.as_ref(), + &new_tree, + 0, // we don't care about the context here + )?; + + summarize_diff_for_temporary_commit(&diff)? + }; + + vec![format!("stash: {diff_stats}")] + } else { + messages + }; let args = { let mut args = vec!["commit"]; args.extend(messages.iter().flat_map(|message| ["--message", message])); @@ -227,6 +263,7 @@ fn record( checkout_target, &CheckOutCommitOptions { additional_args: vec![], + force_detach: false, reset: false, render_smartlog: false, }, diff --git a/git-branchless-record/tests/test_record.rs b/git-branchless-record/tests/test_record.rs index bfb73c00c..9cfbf6bd8 100644 --- a/git-branchless-record/tests/test_record.rs +++ b/git-branchless-record/tests/test_record.rs @@ -342,6 +342,42 @@ fn test_record_stash() -> eyre::Result<()> { Ok(()) } +#[test] +fn test_record_stash_default_message() -> eyre::Result<()> { + let git = make_git()?; + + if !git.supports_reference_transactions()? { + return Ok(()); + } + git.init_repo()?; + + git.commit_file("test1", 1)?; + + { + git.write_file_txt("test1", "new test1 contents\n")?; + + let (stdout, _stderr) = git.branchless("record", &["--stash"])?; + insta::assert_snapshot!(stdout, @r###" + [master fd2ffa4] stash: test1.txt (+1/-1) + 1 file changed, 1 insertion(+), 1 deletion(-) + branchless: running command: branch -f master 62fc20d2a290daea0d52bdc2ed2ad4be6491010e + branchless: running command: checkout master + "###); + } + + { + let stdout = git.smartlog()?; + insta::assert_snapshot!(stdout, @r###" + : + @ 62fc20d (> master) create test1.txt + | + o fd2ffa4 stash: test1.txt (+1/-1) + "###); + } + + Ok(()) +} + #[test] fn test_record_create_branch() -> eyre::Result<()> { let git = make_git()?; diff --git a/git-branchless-revset/src/builtins.rs b/git-branchless-revset/src/builtins.rs index ec865e66d..d07692dde 100644 --- a/git-branchless-revset/src/builtins.rs +++ b/git-branchless-revset/src/builtins.rs @@ -25,7 +25,8 @@ use crate::pattern::{make_pattern_matcher_set, Pattern}; use crate::pattern::{PatternError, PatternMatcher}; use crate::Expr; -type FnType = &'static (dyn Fn(&mut Context, &str, &[Expr]) -> EvalResult + Sync); +type FnType = + &'static (dyn Fn(&mut Context, &str, &[Expr], &Option<&CommitSet>) -> EvalResult + Sync); lazy_static! { pub(super) static ref FUNCTIONS: HashMap<&'static str, FnType> = { let functions: &[(&'static str, FnType)] = &[ @@ -71,7 +72,7 @@ lazy_static! { } #[instrument] -fn fn_all(ctx: &mut Context, name: &str, args: &[Expr]) -> EvalResult { +fn fn_all(ctx: &mut Context, name: &str, args: &[Expr], _: &Option<&CommitSet>) -> EvalResult { eval0(ctx, name, args)?; let visible_heads = ctx .dag @@ -86,43 +87,53 @@ fn fn_all(ctx: &mut Context, name: &str, args: &[Expr]) -> EvalResult { } #[instrument] -fn fn_none(ctx: &mut Context, name: &str, args: &[Expr]) -> EvalResult { +fn fn_none(ctx: &mut Context, name: &str, args: &[Expr], _: &Option<&CommitSet>) -> EvalResult { eval0(ctx, name, args)?; Ok(CommitSet::empty()) } #[instrument] -fn fn_union(ctx: &mut Context, name: &str, args: &[Expr]) -> EvalResult { +fn fn_union(ctx: &mut Context, name: &str, args: &[Expr], _: &Option<&CommitSet>) -> EvalResult { let (lhs, rhs) = eval2(ctx, name, args)?; Ok(lhs.union(&rhs)) } #[instrument] -fn fn_intersection(ctx: &mut Context, name: &str, args: &[Expr]) -> EvalResult { +fn fn_intersection( + ctx: &mut Context, + name: &str, + args: &[Expr], + _: &Option<&CommitSet>, +) -> EvalResult { let (lhs, rhs) = eval2(ctx, name, args)?; Ok(lhs.intersection(&rhs)) } #[instrument] -fn fn_difference(ctx: &mut Context, name: &str, args: &[Expr]) -> EvalResult { +fn fn_difference( + ctx: &mut Context, + name: &str, + args: &[Expr], + _: &Option<&CommitSet>, +) -> EvalResult { let (lhs, rhs) = eval2(ctx, name, args)?; Ok(lhs.difference(&rhs)) } #[instrument] -fn fn_only(ctx: &mut Context, name: &str, args: &[Expr]) -> EvalResult { +fn fn_only(ctx: &mut Context, name: &str, args: &[Expr], _: &Option<&CommitSet>) -> EvalResult { let (lhs, rhs) = eval2(ctx, name, args)?; Ok(ctx.dag.query_only(lhs, rhs)?) } #[instrument] -fn fn_range(ctx: &mut Context, name: &str, args: &[Expr]) -> EvalResult { +fn fn_range(ctx: &mut Context, name: &str, args: &[Expr], _: &Option<&CommitSet>) -> EvalResult { let (lhs, rhs) = eval2(ctx, name, args)?; Ok(ctx.dag.query_range(lhs, rhs)?) } #[instrument] -fn fn_not(ctx: &mut Context, name: &str, args: &[Expr]) -> EvalResult { +fn fn_not(ctx: &mut Context, name: &str, args: &[Expr], _: &Option<&CommitSet>) -> EvalResult { let expr = eval1(ctx, name, args)?; let visible_heads = ctx .dag @@ -133,31 +144,41 @@ fn fn_not(ctx: &mut Context, name: &str, args: &[Expr]) -> EvalResult { } #[instrument] -fn fn_ancestors(ctx: &mut Context, name: &str, args: &[Expr]) -> EvalResult { +fn fn_ancestors( + ctx: &mut Context, + name: &str, + args: &[Expr], + _: &Option<&CommitSet>, +) -> EvalResult { let expr = eval1(ctx, name, args)?; Ok(ctx.dag.query_ancestors(expr)?) } #[instrument] -fn fn_descendants(ctx: &mut Context, name: &str, args: &[Expr]) -> EvalResult { +fn fn_descendants( + ctx: &mut Context, + name: &str, + args: &[Expr], + _: &Option<&CommitSet>, +) -> EvalResult { let expr = eval1(ctx, name, args)?; Ok(ctx.dag.query_descendants(expr)?) } #[instrument] -fn fn_parents(ctx: &mut Context, name: &str, args: &[Expr]) -> EvalResult { +fn fn_parents(ctx: &mut Context, name: &str, args: &[Expr], _: &Option<&CommitSet>) -> EvalResult { let expr = eval1(ctx, name, args)?; Ok(ctx.dag.query_parents(expr)?) } #[instrument] -fn fn_children(ctx: &mut Context, name: &str, args: &[Expr]) -> EvalResult { +fn fn_children(ctx: &mut Context, name: &str, args: &[Expr], _: &Option<&CommitSet>) -> EvalResult { let expr = eval1(ctx, name, args)?; Ok(ctx.dag.query_children(expr)?) } #[instrument] -fn fn_siblings(ctx: &mut Context, name: &str, args: &[Expr]) -> EvalResult { +fn fn_siblings(ctx: &mut Context, name: &str, args: &[Expr], _: &Option<&CommitSet>) -> EvalResult { let expr = eval1(ctx, name, args)?; let parents = ctx.dag.query_parents(expr.clone())?; let children = ctx.dag.query_children(parents)?; @@ -166,19 +187,24 @@ fn fn_siblings(ctx: &mut Context, name: &str, args: &[Expr]) -> EvalResult { } #[instrument] -fn fn_roots(ctx: &mut Context, name: &str, args: &[Expr]) -> EvalResult { +fn fn_roots(ctx: &mut Context, name: &str, args: &[Expr], _: &Option<&CommitSet>) -> EvalResult { let expr = eval1(ctx, name, args)?; Ok(ctx.dag.query_roots(expr)?) } #[instrument] -fn fn_heads(ctx: &mut Context, name: &str, args: &[Expr]) -> EvalResult { +fn fn_heads(ctx: &mut Context, name: &str, args: &[Expr], _: &Option<&CommitSet>) -> EvalResult { let expr = eval1(ctx, name, args)?; Ok(ctx.dag.query_heads(expr)?) } #[instrument] -fn fn_branches(ctx: &mut Context, name: &str, args: &[Expr]) -> EvalResult { +fn fn_branches( + ctx: &mut Context, + name: &str, + args: &[Expr], + commitset_hint: &Option<&CommitSet>, +) -> EvalResult { let pattern = match eval0_or_1_pattern(ctx, name, args)? { Some(pattern) => pattern, None => return Ok(ctx.dag.branch_commits.clone()), @@ -191,7 +217,11 @@ fn fn_branches(ctx: &mut Context, name: &str, args: &[Expr]) -> EvalResult { .map_err(EvalError::OtherError)? .branch_oid_to_names; - let branch_commits = make_pattern_matcher_for_set( + let commitset_hint = match commitset_hint { + Some(hint) => hint.intersection(&ctx.dag.branch_commits), + None => ctx.dag.branch_commits.clone(), + }; + let branch_commits = make_pattern_matcher( ctx, name, args, @@ -217,14 +247,19 @@ fn fn_branches(ctx: &mut Context, name: &str, args: &[Expr]) -> EvalResult { Ok(result) }), - Some(ctx.dag.branch_commits.clone()), + &Some(&commitset_hint), )?; Ok(branch_commits) } #[instrument] -fn fn_parents_nth(ctx: &mut Context, name: &str, args: &[Expr]) -> EvalResult { +fn fn_parents_nth( + ctx: &mut Context, + name: &str, + args: &[Expr], + _: &Option<&CommitSet>, +) -> EvalResult { let (lhs, n) = eval_number_rhs(ctx, name, args)?; let commit_oids = ctx .dag @@ -243,7 +278,12 @@ fn fn_parents_nth(ctx: &mut Context, name: &str, args: &[Expr]) -> EvalResult { } #[instrument] -fn fn_nthancestor(ctx: &mut Context, name: &str, args: &[Expr]) -> EvalResult { +fn fn_nthancestor( + ctx: &mut Context, + name: &str, + args: &[Expr], + _: &Option<&CommitSet>, +) -> EvalResult { let (lhs, n) = eval_number_rhs(ctx, name, args)?; let commit_oids = ctx .dag @@ -261,13 +301,13 @@ fn fn_nthancestor(ctx: &mut Context, name: &str, args: &[Expr]) -> EvalResult { } #[instrument] -fn fn_main(ctx: &mut Context, name: &str, args: &[Expr]) -> EvalResult { +fn fn_main(ctx: &mut Context, name: &str, args: &[Expr], _: &Option<&CommitSet>) -> EvalResult { eval0(ctx, name, args)?; Ok(ctx.dag.main_branch_commit.clone()) } #[instrument] -fn fn_public(ctx: &mut Context, name: &str, args: &[Expr]) -> EvalResult { +fn fn_public(ctx: &mut Context, name: &str, args: &[Expr], _: &Option<&CommitSet>) -> EvalResult { eval0(ctx, name, args)?; let public_commits = ctx .dag @@ -277,7 +317,7 @@ fn fn_public(ctx: &mut Context, name: &str, args: &[Expr]) -> EvalResult { } #[instrument] -fn fn_draft(ctx: &mut Context, name: &str, args: &[Expr]) -> EvalResult { +fn fn_draft(ctx: &mut Context, name: &str, args: &[Expr], _: &Option<&CommitSet>) -> EvalResult { eval0(ctx, name, args)?; let draft_commits = ctx .dag @@ -287,7 +327,7 @@ fn fn_draft(ctx: &mut Context, name: &str, args: &[Expr]) -> EvalResult { } #[instrument] -fn fn_stack(ctx: &mut Context, name: &str, args: &[Expr]) -> EvalResult { +fn fn_stack(ctx: &mut Context, name: &str, args: &[Expr], _: &Option<&CommitSet>) -> EvalResult { let arg = eval0_or_1(ctx, name, args)?.unwrap_or_else(|| ctx.dag.head_commit.clone()); ctx.dag .query_stack_commits(arg) @@ -296,24 +336,14 @@ fn fn_stack(ctx: &mut Context, name: &str, args: &[Expr]) -> EvalResult { type MatcherFn = dyn Fn(&Repo, &Commit) -> Result + Sync + Send; -/// Make a pattern matcher that operates on all visible commits. +/// Make a pattern matcher that operates on a specific set of commits, or all +/// visible commits if none is provided. fn make_pattern_matcher( ctx: &mut Context, name: &str, args: &[Expr], f: Box, -) -> Result { - make_pattern_matcher_for_set(ctx, name, args, f, None) -} - -/// Make a pattern matcher that operates only on the given set of commits. -#[instrument(skip(f))] -fn make_pattern_matcher_for_set( - ctx: &mut Context, - name: &str, - args: &[Expr], - f: Box, - commits_to_match: Option, + commits_to_match: &Option<&CommitSet>, ) -> Result { struct Matcher { expr: String, @@ -334,12 +364,18 @@ fn make_pattern_matcher_for_set( expr: Expr::FunctionCall(Cow::Borrowed(name), args.to_vec()).to_string(), f, }; - let matcher = make_pattern_matcher_set(ctx, ctx.repo, Box::new(matcher), commits_to_match)?; + let matcher = + make_pattern_matcher_set(ctx, ctx.repo, Box::new(matcher), commits_to_match.cloned())?; Ok(matcher) } #[instrument] -fn fn_message(ctx: &mut Context, name: &str, args: &[Expr]) -> EvalResult { +fn fn_message( + ctx: &mut Context, + name: &str, + args: &[Expr], + commits_to_match: &Option<&CommitSet>, +) -> EvalResult { let pattern = eval1_pattern(ctx, name, args)?; make_pattern_matcher( ctx, @@ -361,11 +397,17 @@ fn fn_message(ctx: &mut Context, name: &str, args: &[Expr]) -> EvalResult { }; Ok(pattern.matches_text(message)) }), + commits_to_match, ) } #[instrument] -fn fn_path_changed(ctx: &mut Context, name: &str, args: &[Expr]) -> EvalResult { +fn fn_path_changed( + ctx: &mut Context, + name: &str, + args: &[Expr], + commits_to_match: &Option<&CommitSet>, +) -> EvalResult { let pattern = eval1_pattern(ctx, name, args)?; make_pattern_matcher( ctx, @@ -391,11 +433,17 @@ fn fn_path_changed(ctx: &mut Context, name: &str, args: &[Expr]) -> EvalResult { }); Ok(result) }), + commits_to_match, ) } #[instrument] -fn fn_author_name(ctx: &mut Context, name: &str, args: &[Expr]) -> EvalResult { +fn fn_author_name( + ctx: &mut Context, + name: &str, + args: &[Expr], + commits_to_match: &Option<&CommitSet>, +) -> EvalResult { let pattern = eval1_pattern(ctx, name, args)?; make_pattern_matcher( ctx, @@ -407,11 +455,17 @@ fn fn_author_name(ctx: &mut Context, name: &str, args: &[Expr]) -> EvalResult { None => Ok(false), }, ), + commits_to_match, ) } #[instrument] -fn fn_author_email(ctx: &mut Context, name: &str, args: &[Expr]) -> EvalResult { +fn fn_author_email( + ctx: &mut Context, + name: &str, + args: &[Expr], + commits_to_match: &Option<&CommitSet>, +) -> EvalResult { let pattern = eval1_pattern(ctx, name, args)?; make_pattern_matcher( ctx, @@ -423,11 +477,17 @@ fn fn_author_email(ctx: &mut Context, name: &str, args: &[Expr]) -> EvalResult { None => Ok(false), }, ), + commits_to_match, ) } #[instrument] -fn fn_author_date(ctx: &mut Context, name: &str, args: &[Expr]) -> EvalResult { +fn fn_author_date( + ctx: &mut Context, + name: &str, + args: &[Expr], + commits_to_match: &Option<&CommitSet>, +) -> EvalResult { let pattern = eval1_pattern(ctx, name, args)?; make_pattern_matcher( ctx, @@ -437,11 +497,17 @@ fn fn_author_date(ctx: &mut Context, name: &str, args: &[Expr]) -> EvalResult { let time = commit.get_author().get_time(); Ok(pattern.matches_date(&time)) }), + commits_to_match, ) } #[instrument] -fn fn_committer_name(ctx: &mut Context, name: &str, args: &[Expr]) -> EvalResult { +fn fn_committer_name( + ctx: &mut Context, + name: &str, + args: &[Expr], + commits_to_match: &Option<&CommitSet>, +) -> EvalResult { let pattern = eval1_pattern(ctx, name, args)?; make_pattern_matcher( ctx, @@ -453,11 +519,17 @@ fn fn_committer_name(ctx: &mut Context, name: &str, args: &[Expr]) -> EvalResult None => Ok(false), }, ), + commits_to_match, ) } #[instrument] -fn fn_committer_email(ctx: &mut Context, name: &str, args: &[Expr]) -> EvalResult { +fn fn_committer_email( + ctx: &mut Context, + name: &str, + args: &[Expr], + commits_to_match: &Option<&CommitSet>, +) -> EvalResult { let pattern = eval1_pattern(ctx, name, args)?; make_pattern_matcher( ctx, @@ -469,11 +541,17 @@ fn fn_committer_email(ctx: &mut Context, name: &str, args: &[Expr]) -> EvalResul None => Ok(false), }, ), + commits_to_match, ) } #[instrument] -fn fn_committer_date(ctx: &mut Context, name: &str, args: &[Expr]) -> EvalResult { +fn fn_committer_date( + ctx: &mut Context, + name: &str, + args: &[Expr], + commits_to_match: &Option<&CommitSet>, +) -> EvalResult { let pattern = eval1_pattern(ctx, name, args)?; make_pattern_matcher( ctx, @@ -483,11 +561,12 @@ fn fn_committer_date(ctx: &mut Context, name: &str, args: &[Expr]) -> EvalResult let time = commit.get_committer().get_time(); Ok(pattern.matches_date(&time)) }), + commits_to_match, ) } #[instrument] -fn fn_exactly(ctx: &mut Context, name: &str, args: &[Expr]) -> EvalResult { +fn fn_exactly(ctx: &mut Context, name: &str, args: &[Expr], _: &Option<&CommitSet>) -> EvalResult { let (lhs, expected_len) = eval_number_rhs(ctx, name, args)?; let actual_len: usize = ctx.dag.set_count(&lhs)?; @@ -503,7 +582,7 @@ fn fn_exactly(ctx: &mut Context, name: &str, args: &[Expr]) -> EvalResult { } #[instrument] -fn fn_current(ctx: &mut Context, name: &str, args: &[Expr]) -> EvalResult { +fn fn_current(ctx: &mut Context, name: &str, args: &[Expr], _: &Option<&CommitSet>) -> EvalResult { let mut dag = ctx .dag .clear_obsolete_commits(ctx.repo) @@ -548,7 +627,7 @@ fn fn_current(ctx: &mut Context, name: &str, args: &[Expr]) -> EvalResult { } #[instrument] -fn fn_merges(ctx: &mut Context, name: &str, args: &[Expr]) -> EvalResult { +fn fn_merges(ctx: &mut Context, name: &str, args: &[Expr], _: &Option<&CommitSet>) -> EvalResult { eval0(ctx, name, args)?; // Use a "pattern matcher" that – instead of testing for a pattern – // examines the parent count of each commit to find merges. @@ -557,6 +636,7 @@ fn fn_merges(ctx: &mut Context, name: &str, args: &[Expr]) -> EvalResult { name, args, Box::new(move |_repo, commit| Ok(commit.get_parent_count() > 1)), + &None, ) } @@ -597,7 +677,12 @@ fn eval_test_command_pattern( } #[instrument] -fn fn_tests_passed(ctx: &mut Context, name: &str, args: &[Expr]) -> EvalResult { +fn fn_tests_passed( + ctx: &mut Context, + name: &str, + args: &[Expr], + _: &Option<&CommitSet>, +) -> EvalResult { let pattern = eval_test_command_pattern(ctx, name, args)?; make_pattern_matcher( ctx, @@ -620,11 +705,17 @@ fn fn_tests_passed(ctx: &mut Context, name: &str, args: &[Expr]) -> EvalResult { }); Ok(result) }), + &None, ) } #[instrument] -fn fn_tests_failed(ctx: &mut Context, name: &str, args: &[Expr]) -> EvalResult { +fn fn_tests_failed( + ctx: &mut Context, + name: &str, + args: &[Expr], + _: &Option<&CommitSet>, +) -> EvalResult { let pattern = eval_test_command_pattern(ctx, name, args)?; make_pattern_matcher( ctx, @@ -649,11 +740,17 @@ fn fn_tests_failed(ctx: &mut Context, name: &str, args: &[Expr]) -> EvalResult { }); Ok(result) }), + &None, ) } #[instrument] -fn fn_tests_fixable(ctx: &mut Context, name: &str, args: &[Expr]) -> EvalResult { +fn fn_tests_fixable( + ctx: &mut Context, + name: &str, + args: &[Expr], + _: &Option<&CommitSet>, +) -> EvalResult { let pattern = eval_test_command_pattern(ctx, name, args)?; make_pattern_matcher( ctx, @@ -683,5 +780,6 @@ fn fn_tests_fixable(ctx: &mut Context, name: &str, args: &[Expr]) -> EvalResult }); Ok(result) }), + &None, ) } diff --git a/git-branchless-revset/src/eval.rs b/git-branchless-revset/src/eval.rs index 7cd88c3c7..616698ee1 100644 --- a/git-branchless-revset/src/eval.rs +++ b/git-branchless-revset/src/eval.rs @@ -116,16 +116,16 @@ pub fn eval(effects: &Effects, repo: &Repo, dag: &mut Dag, expr: &Expr) -> EvalR repo, dag, }; - let commits = eval_inner(&mut ctx, expr)?; + let commits = eval_inner(&mut ctx, expr, &None)?; Ok(commits) } #[instrument] -fn eval_inner(ctx: &mut Context, expr: &Expr) -> EvalResult { +fn eval_inner(ctx: &mut Context, expr: &Expr, commitset_hint: &Option<&CommitSet>) -> EvalResult { match expr { Expr::Name(name) => eval_name(ctx, name), Expr::FunctionCall(name, args) => { - let result = eval_fn(ctx, name, args)?; + let result = eval_fn(ctx, name, args, commitset_hint)?; let result = ctx .dag .filter_visible_commits(result) @@ -176,9 +176,14 @@ pub(super) fn eval_name(ctx: &mut Context, name: &str) -> EvalResult { } #[instrument] -pub(super) fn eval_fn(ctx: &mut Context, name: &str, args: &[Expr]) -> EvalResult { +pub(super) fn eval_fn( + ctx: &mut Context, + name: &str, + args: &[Expr], + commitset_hint: &Option<&CommitSet>, +) -> EvalResult { if let Some(function) = FUNCTIONS.get(name) { - return function(ctx, name, args); + return function(ctx, name, args, commitset_hint); } let alias_key = format!("branchless.revsets.alias.{name}"); @@ -199,7 +204,7 @@ pub(super) fn eval_fn(ctx: &mut Context, name: &str, args: &[Expr]) -> EvalResul .map(|(i, arg)| (format!("${}", i + 1), arg.clone())) .collect(); let alias_expr = alias_expr.replace_names(&arg_map); - let commits = eval_inner(ctx, &alias_expr)?; + let commits = eval_inner(ctx, &alias_expr, commitset_hint)?; return Ok(commits); } @@ -235,7 +240,7 @@ pub(super) fn eval0_or_1( match args { [] => Ok(None), [expr] => { - let arg = eval_inner(ctx, expr)?; + let arg = eval_inner(ctx, expr, &None)?; Ok(Some(arg)) } args => Err(EvalError::ArityMismatch { @@ -250,7 +255,7 @@ pub(super) fn eval0_or_1( pub(super) fn eval1(ctx: &mut Context, function_name: &str, args: &[Expr]) -> EvalResult { match args { [arg] => { - let lhs = eval_inner(ctx, arg)?; + let lhs = eval_inner(ctx, arg, &None)?; Ok(lhs) } @@ -308,8 +313,8 @@ pub(super) fn eval2( ) -> Result<(CommitSet, CommitSet), EvalError> { match args { [lhs, rhs] => { - let lhs = eval_inner(ctx, lhs)?; - let rhs = eval_inner(ctx, rhs)?; + let lhs = eval_inner(ctx, lhs, &None)?; + let rhs = eval_inner(ctx, rhs, &Some(&lhs))?; Ok((lhs, rhs)) } @@ -329,7 +334,7 @@ pub(super) fn eval_number_rhs( ) -> Result<(CommitSet, usize), EvalError> { match args { [lhs, Expr::Name(name)] => { - let lhs = eval_inner(ctx, lhs)?; + let lhs = eval_inner(ctx, lhs, &None)?; let number: usize = { name.parse()? }; Ok((lhs, number)) } diff --git a/git-branchless-revset/src/lib.rs b/git-branchless-revset/src/lib.rs index 14efa735a..0284a93a5 100644 --- a/git-branchless-revset/src/lib.rs +++ b/git-branchless-revset/src/lib.rs @@ -8,7 +8,7 @@ clippy::clone_on_ref_ptr, clippy::dbg_macro )] -#![allow(clippy::too_many_arguments, clippy::blocks_in_if_conditions)] +#![allow(clippy::too_many_arguments, clippy::blocks_in_conditions)] mod ast; mod builtins; diff --git a/git-branchless-reword/src/lib.rs b/git-branchless-reword/src/lib.rs index ef53ad4d3..45419ce09 100644 --- a/git-branchless-reword/src/lib.rs +++ b/git-branchless-reword/src/lib.rs @@ -7,7 +7,7 @@ clippy::clone_on_ref_ptr, clippy::dbg_macro )] -#![allow(clippy::too_many_arguments, clippy::blocks_in_if_conditions)] +#![allow(clippy::too_many_arguments, clippy::blocks_in_conditions)] pub mod dialoguer_edit; @@ -292,6 +292,7 @@ pub fn reword( resolve_merge_conflicts: false, check_out_commit_options: CheckOutCommitOptions { additional_args: Default::default(), + force_detach: false, reset: false, render_smartlog: false, }, diff --git a/git-branchless-smartlog/src/lib.rs b/git-branchless-smartlog/src/lib.rs index e3b3bdc3f..43ccc2bd0 100644 --- a/git-branchless-smartlog/src/lib.rs +++ b/git-branchless-smartlog/src/lib.rs @@ -10,7 +10,7 @@ clippy::clone_on_ref_ptr, clippy::dbg_macro )] -#![allow(clippy::too_many_arguments, clippy::blocks_in_if_conditions)] +#![allow(clippy::too_many_arguments, clippy::blocks_in_conditions)] use std::cmp::Ordering; use std::fmt::Write; diff --git a/git-branchless-submit/src/lib.rs b/git-branchless-submit/src/lib.rs index 6762b3207..5c5a23df8 100644 --- a/git-branchless-submit/src/lib.rs +++ b/git-branchless-submit/src/lib.rs @@ -7,7 +7,7 @@ clippy::clone_on_ref_ptr, clippy::dbg_macro )] -#![allow(clippy::too_many_arguments, clippy::blocks_in_if_conditions)] +#![allow(clippy::too_many_arguments, clippy::blocks_in_conditions)] mod branch_forge; pub mod github; diff --git a/git-branchless-test/src/lib.rs b/git-branchless-test/src/lib.rs index add7cd898..d63088a51 100644 --- a/git-branchless-test/src/lib.rs +++ b/git-branchless-test/src/lib.rs @@ -9,7 +9,7 @@ clippy::clone_on_ref_ptr, clippy::dbg_macro )] -#![allow(clippy::too_many_arguments, clippy::blocks_in_if_conditions)] +#![allow(clippy::too_many_arguments, clippy::blocks_in_conditions)] mod worker; diff --git a/git-branchless-undo/src/lib.rs b/git-branchless-undo/src/lib.rs index 4dc39dcf2..5ac13f24d 100644 --- a/git-branchless-undo/src/lib.rs +++ b/git-branchless-undo/src/lib.rs @@ -10,7 +10,7 @@ clippy::clone_on_ref_ptr, clippy::dbg_macro )] -#![allow(clippy::too_many_arguments, clippy::blocks_in_if_conditions)] +#![allow(clippy::too_many_arguments, clippy::blocks_in_conditions)] pub mod tui; @@ -743,6 +743,7 @@ fn extract_checkout_target( target: CheckoutTarget::Oid(*new_oid), options: CheckOutCommitOptions { additional_args: vec!["--detach".into()], + force_detach: false, reset: false, render_smartlog: true, }, @@ -766,6 +767,7 @@ fn extract_checkout_target( } None => Default::default(), }, + force_detach: false, reset: false, render_smartlog: true, }, @@ -1077,6 +1079,7 @@ mod tests { additional_args: [ "--detach", ], + force_detach: false, reset: false, render_smartlog: true, }, diff --git a/git-branchless/Cargo.toml b/git-branchless/Cargo.toml index 830f42cf7..8ff42a1b2 100644 --- a/git-branchless/Cargo.toml +++ b/git-branchless/Cargo.toml @@ -11,7 +11,7 @@ license = "MIT OR Apache-2.0" name = "git-branchless" readme = "../README.md" repository = "https://github.com/arxanas/git-branchless" -rust-version = "1.74" +rust-version = "1.85" version = "0.10.0" # See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html @@ -64,9 +64,9 @@ man-pages = [] [package.metadata.release] pre-release-replacements = [ - { file = "../CHANGELOG.md", search = "Unreleased", replace = "{{version}}", min = 1 }, - { file = "../CHANGELOG.md", search = "ReleaseDate", replace = "{{date}}", min = 1 }, - { file = "../CHANGELOG.md", search = "", replace = "\n## [Unreleased] - ReleaseDate\n", exactly = 1 }, + { file = "../CHANGELOG.md", search = "Unreleased", replace = "{{version}}", min = 1 }, + { file = "../CHANGELOG.md", search = "ReleaseDate", replace = "{{date}}", min = 1 }, + { file = "../CHANGELOG.md", search = "", replace = "\n## [Unreleased] - ReleaseDate\n", exactly = 1 }, ] [[test]] @@ -111,6 +111,9 @@ name = "test_reword" [[test]] name = "test_snapshot" +[[test]] +name = "test_split" + [[test]] name = "test_sync" diff --git a/git-branchless/src/commands/amend.rs b/git-branchless/src/commands/amend.rs index f20fa1251..8e55e95f1 100644 --- a/git-branchless/src/commands/amend.rs +++ b/git-branchless/src/commands/amend.rs @@ -203,6 +203,7 @@ pub fn amend( Some(target), &CheckOutCommitOptions { additional_args: Default::default(), + force_detach: false, reset: true, render_smartlog: false, }, @@ -297,6 +298,7 @@ pub fn amend( resolve_merge_conflicts: move_options.resolve_merge_conflicts, check_out_commit_options: CheckOutCommitOptions { additional_args: Default::default(), + force_detach: false, reset: false, render_smartlog: false, }, diff --git a/git-branchless/src/commands/mod.rs b/git-branchless/src/commands/mod.rs index d0ff79eaa..d1fb585e1 100644 --- a/git-branchless/src/commands/mod.rs +++ b/git-branchless/src/commands/mod.rs @@ -6,6 +6,7 @@ mod hide; mod repair; mod restack; mod snapshot; +mod split; mod sync; mod wrap; @@ -179,6 +180,39 @@ fn command_main(ctx: CommandContext, opts: Opts) -> EyreExitOr<()> { } }, + Command::Split { + before, + detach, + discard, + files, + resolve_revset_options, + revset, + move_options, + } => { + let split_mode = match (before, detach, discard) { + (false, true, false) => split::SplitMode::DetachAfter, + (false, false, true) => split::SplitMode::Discard, + (false, false, false) => split::SplitMode::InsertAfter, + (true, false, false) => split::SplitMode::InsertBefore, + (true, true, false) + | (true, false, true) + | (false, true, true) + | (true, true, true) => { + unreachable!("clap should prevent this") + } + }; + + split::split( + &effects, + revset, + &resolve_revset_options, + files, + split_mode, + &move_options, + &git_run_info, + )? + } + Command::Submit(args) => git_branchless_submit::command_main(ctx, args)?, Command::Sync { diff --git a/git-branchless/src/commands/restack.rs b/git-branchless/src/commands/restack.rs index 0b6c72839..16231b2ea 100644 --- a/git-branchless/src/commands/restack.rs +++ b/git-branchless/src/commands/restack.rs @@ -328,6 +328,7 @@ pub fn restack( resolve_merge_conflicts, check_out_commit_options: CheckOutCommitOptions { additional_args: Default::default(), + force_detach: false, reset: false, render_smartlog: false, }, diff --git a/git-branchless/src/commands/split.rs b/git-branchless/src/commands/split.rs new file mode 100644 index 000000000..aff6045a2 --- /dev/null +++ b/git-branchless/src/commands/split.rs @@ -0,0 +1,595 @@ +//! Split commits, extracting changes from a single commit into separate commits. + +use eyre::Context; +use rayon::ThreadPoolBuilder; +use std::{ + fmt::Write, + path::{Path, PathBuf}, + time::{SystemTime, UNIX_EPOCH}, +}; + +use git_branchless_opts::{MoveOptions, ResolveRevsetOptions, Revset}; +use git_branchless_revset::resolve_commits; +use lib::{ + core::{ + check_out::{check_out_commit, CheckOutCommitOptions, CheckoutTarget}, + config::get_restack_preserve_timestamps, + dag::{CommitSet, Dag}, + effects::Effects, + eventlog::{Event, EventLogDb, EventReplayer}, + gc::mark_commit_reachable, + repo_ext::RepoExt, + rewrite::{ + execute_rebase_plan, move_branches, BuildRebasePlanOptions, ExecuteRebasePlanOptions, + ExecuteRebasePlanResult, MergeConflictRemediation, RebasePlanBuilder, + RebasePlanPermissions, RepoResource, + }, + }, + git::{ + make_empty_tree, summarize_diff_for_temporary_commit, CherryPickFastOptions, GitRunInfo, + MaybeZeroOid, NonZeroOid, Repo, ResolvedReferenceInfo, + }, + try_exit_code, + util::{ExitCode, EyreExitOr}, +}; +use tracing::instrument; + +#[derive(Debug, PartialEq)] +/// What should `split` do with the extracted changes? +pub enum SplitMode { + DetachAfter, + Discard, + InsertAfter, + InsertBefore, +} + +/// Split a commit and restack its descendants. +#[instrument] +pub fn split( + effects: &Effects, + revset: Revset, + resolve_revset_options: &ResolveRevsetOptions, + files_to_extract: Vec, + split_mode: SplitMode, + move_options: &MoveOptions, + git_run_info: &GitRunInfo, +) -> EyreExitOr<()> { + let repo = Repo::from_current_dir()?; + let references_snapshot = repo.get_references_snapshot()?; + let conn = repo.get_db_conn()?; + let event_log_db = EventLogDb::new(&conn)?; + let event_replayer = EventReplayer::from_event_log_db(effects, &repo, &event_log_db)?; + let event_cursor = event_replayer.make_default_cursor(); + let mut dag = Dag::open_and_sync( + effects, + &repo, + &event_replayer, + event_cursor, + &references_snapshot, + )?; + let now = SystemTime::now(); + let event_tx_id = event_log_db.make_transaction_id(now, "split")?; + let pool = ThreadPoolBuilder::new().build()?; + let repo_pool = RepoResource::new_pool(&repo)?; + + let MoveOptions { + force_rewrite_public_commits, + force_in_memory, + force_on_disk, + detect_duplicate_commits_via_patch_id, + resolve_merge_conflicts, + dump_rebase_constraints, + dump_rebase_plan, + } = *move_options; + + let target_oid: NonZeroOid = match resolve_commits( + effects, + &repo, + &mut dag, + &[revset.clone()], + resolve_revset_options, + ) { + Ok(commit_sets) => match dag.commit_set_to_vec(&commit_sets[0])?.as_slice() { + [only_commit_oid] => *only_commit_oid, + other => { + let Revset(expr) = revset; + writeln!( + effects.get_error_stream(), + "Expected revset to expand to exactly 1 commit (got {count}): {expr}", + count = other.len(), + )?; + return Ok(Err(ExitCode(1))); + } + }, + Err(err) => { + err.describe(effects)?; + return Ok(Err(ExitCode(1))); + } + }; + + let permissions = match RebasePlanPermissions::verify_rewrite_set( + &dag, + BuildRebasePlanOptions { + force_rewrite_public_commits, + dump_rebase_constraints, + dump_rebase_plan, + detect_duplicate_commits_via_patch_id, + }, + &vec![target_oid].into_iter().collect(), + )? { + Ok(permissions) => permissions, + Err(err) => { + err.describe(effects, &repo, &dag)?; + return Ok(Err(ExitCode(1))); + } + }; + + // + // a-t-b + // + // a-r-x-b (default) + // a-x-r-b (before) + // a-r-b (detach) + // \-x + // a-r-b (discard) + // + // default: x == t tree, x is t with changes removed + // before: r == t tree, e is a with changes added + // detach: (same as default, different rebase) + // discard: (same as default, w/o any rebase) + // + // below: + // a => parent + // t => target + // r => remainder + // x => extracted + + let target_commit = repo.find_commit_or_fail(target_oid)?; + let target_tree = target_commit.get_tree()?; + let parent_commits = target_commit.get_parents(); + let (parent_tree, mut remainder_tree) = match (&split_mode, parent_commits.as_slice()) { + // split the commit by removing the changes from the target, and then + // cherry picking the orignal target as the "extracted" commit + (SplitMode::InsertAfter, [only_parent]) + | (SplitMode::Discard, [only_parent]) + | (SplitMode::DetachAfter, [only_parent]) => { + (only_parent.get_tree()?, target_commit.get_tree()?) + } + + // split the commit by adding the changed to a copy of the parent tree, + // then rebasing the orignal target onto the extracted commit + (SplitMode::InsertBefore, [only_parent]) => { + (only_parent.get_tree()?, only_parent.get_tree()?) + } + + // no parent: use an empty tree for comparison + (SplitMode::InsertAfter, []) | (SplitMode::Discard, []) | (SplitMode::DetachAfter, []) => { + (make_empty_tree(&repo)?, target_commit.get_tree()?) + } + + // no parent: add extracted changes to an empty tree + (SplitMode::InsertBefore, []) => (make_empty_tree(&repo)?, make_empty_tree(&repo)?), + + (_, [..]) => { + writeln!( + effects.get_error_stream(), + "Cannot split merge commit {}.", + target_oid + )?; + return Ok(Err(ExitCode(1))); + } + }; + + let cwd = std::env::current_dir()?; + // tuple: (input_file, resolved_path) + let resolved_paths_to_extract: eyre::Result> = files_to_extract + .into_iter() + .map(|file| { + let path = Path::new(&file).to_path_buf(); + let working_copy_path = match repo.get_working_copy_path() { + Some(working_copy_path) => working_copy_path, + None => { + eyre::bail!("Aborting. Split is not supported in bare root repositories.",) + } + }; + + let path = if cwd != working_copy_path && path.exists() { + let mut repo_relative_path = match cwd.strip_prefix(working_copy_path) { + Ok(working_copy_path) => working_copy_path.to_path_buf(), + Err(_) => { + eyre::bail!( + "Error: current working directory is not in the working copy.\n\ + This may be a bug, please report it.", + ); + } + }; + repo_relative_path.push(path); + repo_relative_path + } else if let Some(stripped_filename) = file.strip_prefix(":/") { + // https://git-scm.com/docs/gitrevisions#Documentation/gitrevisions.txt-emltngtltpathgtemegem0READMEememREADMEem + Path::new(stripped_filename).to_path_buf() + } else { + path + }; + + Ok((file, path)) + }) + .collect(); + + let resolved_paths_to_extract = match resolved_paths_to_extract { + Ok(resolved_paths_to_extract) => resolved_paths_to_extract, + Err(err) => { + writeln!(effects.get_error_stream(), "{err}")?; + return Ok(Err(ExitCode(1))); + } + }; + + for (file, path) in resolved_paths_to_extract.iter() { + let path = path.as_path(); + + if let Ok(Some(false)) = target_commit.contains_touched_path(path) { + writeln!( + effects.get_error_stream(), + "Aborting: file '{filename}' was not changed in commit {oid}.", + filename = path.to_string_lossy(), + oid = target_commit.get_short_oid()? + )?; + return Ok(Err(ExitCode(1))); + } + + let parent_entry = match parent_tree.get_path(path) { + Ok(entry) => entry, + Err(err) => { + writeln!( + effects.get_error_stream(), + "uh oh error reading tree entry: {err}.", + )?; + return Ok(Err(ExitCode(1))); + } + }; + + let target_entry = target_tree.get_path(path)?; + let temp_tree_oid = match (parent_entry, target_entry, &split_mode) { + // added or modified & InsertBefore => add to extracted commit + (None, Some(commit_entry), SplitMode::InsertBefore) + | (Some(_), Some(commit_entry), SplitMode::InsertBefore) => { + remainder_tree.add_or_replace(&repo, path, &commit_entry)? + } + + // removed & InsertBefore => remove from remainder commit + (Some(_), None, SplitMode::InsertBefore) => remainder_tree.remove(&repo, path)?, + + // added => remove from remainder commit + (None, Some(_), SplitMode::InsertAfter) + | (None, Some(_), SplitMode::DetachAfter) + | (None, Some(_), SplitMode::Discard) => remainder_tree.remove(&repo, path)?, + + // deleted or modified => replace w/ parent content in split commit + (Some(parent_entry), _, _) => { + remainder_tree.add_or_replace(&repo, path, &parent_entry)? + } + + (None, _, _) => { + if path.exists() { + writeln!( + effects.get_error_stream(), + "Aborting: the file '{file}' could not be found in this repo.\nPerhaps it's not under version control?", + )?; + } else { + writeln!( + effects.get_error_stream(), + "Aborting: the file '{file}' does not exist.", + )?; + } + return Ok(Err(ExitCode(1))); + } + }; + + remainder_tree = repo + .find_tree(temp_tree_oid)? + .expect("should have been found"); + } + let message = { + let (old_tree, new_tree) = if let SplitMode::InsertBefore = &split_mode { + (&parent_tree, &remainder_tree) + } else { + (&remainder_tree, &target_tree) + }; + let diff = repo.get_diff_between_trees( + effects, + Some(old_tree), + new_tree, + 0, // we don't care about the context here + )?; + + summarize_diff_for_temporary_commit(&diff)? + }; + + // before => split commit is created on parent as "extracted", target is + // rebased onto split + // after => target is amended as "split", split is cherry picked onto split + // as "extracted" + + // FIXME terminology is wrong here: "remainder" is correct for `After` mode, + // but this is actually the "extracted" commit for `InsertBefore` mode + let remainder_commit_oid = if let SplitMode::InsertBefore = split_mode { + repo.create_commit( + None, + &target_commit.get_author(), + &target_commit.get_committer(), + format!("temp(split): {message}").as_str(), + &remainder_tree, + parent_commits.iter().collect(), + )? + } else { + target_commit.amend_commit(None, None, None, None, Some(&remainder_tree))? + }; + let remainder_commit = repo.find_commit_or_fail(remainder_commit_oid)?; + + if remainder_commit.is_empty() { + writeln!( + effects.get_error_stream(), + "Aborting: refusing to split all changes out of commit {oid}.", + oid = target_commit.get_short_oid()?, + )?; + return Ok(Err(ExitCode(1))); + }; + + event_log_db.add_events(vec![Event::RewriteEvent { + timestamp: now.duration_since(UNIX_EPOCH)?.as_secs_f64(), + event_tx_id, + old_commit_oid: MaybeZeroOid::NonZero(target_oid), + new_commit_oid: MaybeZeroOid::NonZero(remainder_commit_oid), + }])?; + + // FIXME terminology is also wrong here: "extracted" is correct for `After` + // and `Discard` modes, but the extracted commit is not actually None for + // `InsertBefore`; it's just handled in a different way + let extracted_commit_oid = match split_mode { + SplitMode::InsertBefore | SplitMode::Discard => None, + SplitMode::InsertAfter | SplitMode::DetachAfter => { + let extracted_tree = repo.cherry_pick_fast( + &target_commit, + &remainder_commit, + &CherryPickFastOptions { + reuse_parent_tree_if_possible: true, + }, + )?; + let extracted_commit_oid = repo.create_commit( + None, + &target_commit.get_author(), + &target_commit.get_committer(), + format!("temp(split): {message}").as_str(), + &extracted_tree, + if let SplitMode::InsertBefore = &split_mode { + parent_commits.iter().collect() + } else { + vec![&remainder_commit] + }, + )?; + + // see git-branchless/src/commands/amend.rs:172 + // TODO maybe this should happen after we've confirmed the rebase has succeeded? + mark_commit_reachable(&repo, extracted_commit_oid) + .wrap_err("Marking commit as reachable for GC purposes.")?; + + event_log_db.add_events(vec![Event::CommitEvent { + timestamp: now.duration_since(UNIX_EPOCH)?.as_secs_f64(), + event_tx_id, + commit_oid: extracted_commit_oid, + }])?; + + Some(extracted_commit_oid) + } + }; + + // push the new commits into the dag for the rebase planner + dag.sync_from_oids( + effects, + &repo, + CommitSet::empty(), + match extracted_commit_oid { + None => CommitSet::from(remainder_commit_oid), + Some(extracted_commit_oid) => vec![remainder_commit_oid, extracted_commit_oid] + .into_iter() + .collect(), + }, + )?; + + enum TargetState { + /// A checked out, detached HEAD + DetachedHead, + /// A checked out branch + CurrentBranch, + /// Any other non-checked out commit + Other, + } + + let head_info = repo.get_head_info()?; + let target_state = match head_info { + ResolvedReferenceInfo { + oid: Some(oid), + reference_name: Some(_), + } if oid == target_oid => TargetState::CurrentBranch, + ResolvedReferenceInfo { + oid: Some(oid), + reference_name: None, + } if oid == target_oid => TargetState::DetachedHead, + ResolvedReferenceInfo { + oid: _, + reference_name: _, + } => TargetState::Other, + }; + + #[derive(Debug)] + struct CleanUp { + checkout_target: Option, + rewritten_oids: Vec<(NonZeroOid, MaybeZeroOid)>, + rebase_force_detach: bool, + reset_index: bool, + } + + let cleanup = match (target_state, &split_mode, extracted_commit_oid) { + // branch @ target commit checked out: extend branch to include + // extracted commit; branch will stay checked out w/o any explicit + // checkout + (TargetState::CurrentBranch, SplitMode::InsertAfter, Some(extracted_commit_oid)) => { + CleanUp { + checkout_target: None, + rewritten_oids: vec![(target_oid, MaybeZeroOid::NonZero(extracted_commit_oid))], + rebase_force_detach: false, + reset_index: false, + } + } + + // same as above, but Discard; don't move branches, but do force reset + (TargetState::CurrentBranch, SplitMode::Discard, None) => CleanUp { + checkout_target: None, + rewritten_oids: vec![(target_oid, MaybeZeroOid::NonZero(remainder_commit_oid))], + rebase_force_detach: false, + reset_index: true, + }, + + // same as above, but InsertBefore; do not move branches + (TargetState::CurrentBranch, SplitMode::InsertBefore, _) => CleanUp { + checkout_target: None, + rewritten_oids: vec![], + rebase_force_detach: false, + reset_index: false, + }, + + // target checked out as detached HEAD, don't extend any branches, but + // explicitly check out the newly split commit + ( + TargetState::DetachedHead, + SplitMode::InsertAfter | SplitMode::Discard | SplitMode::DetachAfter, + _, + ) => CleanUp { + checkout_target: Some(CheckoutTarget::Oid(remainder_commit_oid)), + rewritten_oids: vec![(target_oid, MaybeZeroOid::NonZero(remainder_commit_oid))], + rebase_force_detach: false, + reset_index: false, + }, + + // same as above, but InsertBefore; do not move branches + (TargetState::DetachedHead, SplitMode::InsertBefore, _) => CleanUp { + checkout_target: None, + rewritten_oids: vec![], + rebase_force_detach: true, + reset_index: false, + }, + + // some other commit or branch was checked out, default behavior is fine + (TargetState::CurrentBranch | TargetState::Other, _, _) => CleanUp { + checkout_target: None, + rewritten_oids: vec![(target_oid, MaybeZeroOid::NonZero(remainder_commit_oid))], + rebase_force_detach: false, + reset_index: false, + }, + }; + + let CleanUp { + checkout_target, + rewritten_oids, + rebase_force_detach, + reset_index, + } = cleanup; + + move_branches( + effects, + git_run_info, + &repo, + event_tx_id, + &(rewritten_oids.into_iter().collect()), + )?; + + if checkout_target.is_some() { + try_exit_code!(check_out_commit( + effects, + git_run_info, + &repo, + &event_log_db, + event_tx_id, + checkout_target, + &CheckOutCommitOptions { + additional_args: Default::default(), + force_detach: true, + reset: false, + render_smartlog: false, + }, + )?); + } + + if reset_index { + let mut index = repo.get_index()?; + index.update_from_tree(&remainder_tree)?; + } + + let mut builder = RebasePlanBuilder::new(&dag, permissions); + if let SplitMode::InsertBefore = &split_mode { + builder.move_subtree(target_oid, vec![remainder_commit_oid])? + } else { + let children = dag.query_children(CommitSet::from(target_oid))?; + for child in dag.commit_set_to_vec(&children)? { + match (&split_mode, extracted_commit_oid) { + (_, None) | (SplitMode::DetachAfter, Some(_)) => { + builder.move_subtree(child, vec![remainder_commit_oid])? + } + (_, Some(extracted_commit_oid)) => { + builder.move_subtree(child, vec![extracted_commit_oid])? + } + } + } + } + let rebase_plan = builder.build(effects, &pool, &repo_pool)?; + + let result = match rebase_plan { + Ok(None) => { + writeln!(effects.get_output_stream(), "Nothing to restack.")?; + None + } + Ok(Some(rebase_plan)) => { + let options = ExecuteRebasePlanOptions { + now, + event_tx_id, + preserve_timestamps: get_restack_preserve_timestamps(&repo)?, + force_in_memory, + force_on_disk, + resolve_merge_conflicts, + check_out_commit_options: CheckOutCommitOptions { + additional_args: Default::default(), + force_detach: rebase_force_detach, + reset: false, + render_smartlog: false, + }, + }; + Some(execute_rebase_plan( + effects, + git_run_info, + &repo, + &event_log_db, + &rebase_plan, + &options, + )?) + } + Err(err) => { + err.describe(effects, &repo, &dag)?; + return Ok(Err(ExitCode(1))); + } + }; + + match result { + None | Some(ExecuteRebasePlanResult::Succeeded { rewritten_oids: _ }) => { + try_exit_code!(git_run_info + .run_direct_no_wrapping(Some(event_tx_id), &["branchless", "smartlog"])?); + Ok(Ok(())) + } + + Some(ExecuteRebasePlanResult::DeclinedToMerge { failed_merge_info }) => { + failed_merge_info.describe(effects, &repo, MergeConflictRemediation::Retry)?; + Ok(Err(ExitCode(1))) + } + + Some(ExecuteRebasePlanResult::Failed { exit_code }) => Ok(Err(exit_code)), + } +} diff --git a/git-branchless/src/commands/sync.rs b/git-branchless/src/commands/sync.rs index 5106c1841..b6276bbf7 100644 --- a/git-branchless/src/commands/sync.rs +++ b/git-branchless/src/commands/sync.rs @@ -94,6 +94,7 @@ pub fn sync( resolve_merge_conflicts, check_out_commit_options: CheckOutCommitOptions { additional_args: Default::default(), + force_detach: false, reset: false, render_smartlog: false, }, diff --git a/git-branchless/src/lib.rs b/git-branchless/src/lib.rs index ae1b74d08..7608cabc1 100644 --- a/git-branchless/src/lib.rs +++ b/git-branchless/src/lib.rs @@ -23,6 +23,6 @@ clippy::clone_on_ref_ptr, clippy::dbg_macro )] -#![allow(clippy::too_many_arguments, clippy::blocks_in_if_conditions)] +#![allow(clippy::too_many_arguments, clippy::blocks_in_conditions)] pub mod commands; diff --git a/git-branchless/tests/test_init.rs b/git-branchless/tests/test_init.rs index 82be1b2d1..950245e5b 100644 --- a/git-branchless/tests/test_init.rs +++ b/git-branchless/tests/test_init.rs @@ -737,6 +737,9 @@ fn test_install_man_pages() -> eyre::Result<()> { git\-branchless\-smartlog(1) `smartlog` command .TP + git\-branchless\-split(1) + Split commits + .TP git\-branchless\-submit(1) Push commits to a remote .TP diff --git a/git-branchless/tests/test_split.rs b/git-branchless/tests/test_split.rs new file mode 100644 index 000000000..f551c816c --- /dev/null +++ b/git-branchless/tests/test_split.rs @@ -0,0 +1,1399 @@ +use std::path::PathBuf; + +use lib::testing::{make_git, GitRunOptions}; + +#[test] +fn test_split_detached_head() -> eyre::Result<()> { + let git = make_git()?; + git.init_repo()?; + git.detach_head()?; + + git.write_file_txt("test1", "contents1")?; + git.write_file_txt("test2", "contents2")?; + git.write_file_txt("test3", "contents3")?; + git.run(&["add", "."])?; + git.run(&["commit", "-m", "first commit"])?; + + { + let (stdout, _stderr) = git.branchless("smartlog", &[])?; + insta::assert_snapshot!(stdout, @r###" + O f777ecc (master) create initial.txt + | + @ e48cdc5 first commit + "###); + } + + { + let (stdout, _stderr) = git.branchless("split", &["HEAD", "test2.txt"])?; + insta::assert_snapshot!(&stdout, @r###" + branchless: running command: checkout 2932db7d1099237d79cbd43e29707d70e545d471 + Nothing to restack. + O f777ecc (master) create initial.txt + | + @ 2932db7 first commit + | + o 01523cc temp(split): test2.txt (+1) + "###); + } + + { + git.branchless("next", &[])?; + + let (stdout, _stderr) = git.run(&["show", "--pretty=format:", "--stat", "HEAD~"])?; + insta::assert_snapshot!(&stdout, @" + test1.txt | 1 + + test3.txt | 1 + + 2 files changed, 2 insertions(+) + "); + + let (stdout, _stderr) = git.run(&["show", "--pretty=format:", "--stat", "HEAD"])?; + insta::assert_snapshot!(&stdout, @" + test2.txt | 1 + + 1 file changed, 1 insertion(+) + "); + } + + Ok(()) +} + +#[test] +fn test_split_added_file() -> eyre::Result<()> { + let git = make_git()?; + git.init_repo()?; + git.detach_head()?; + + git.commit_file("test1", 1)?; + + git.write_file_txt("test1", "updated contents")?; + git.write_file_txt("test2", "new contents")?; + git.run(&["add", "."])?; + git.run(&["commit", "-m", "first commit"])?; + + { + let (stdout, _stderr) = git.branchless("smartlog", &[])?; + insta::assert_snapshot!(stdout, @r###" + O f777ecc (master) create initial.txt + | + o 62fc20d create test1.txt + | + @ 0f6059d first commit + "###); + } + + { + let (stdout, _stderr) = git.branchless("split", &["HEAD", "test2.txt"])?; + insta::assert_snapshot!(&stdout, @r###" + branchless: running command: checkout 2f9e232b389b1bc8035f4e5bde79f262c0af020c + Nothing to restack. + O f777ecc (master) create initial.txt + | + o 62fc20d create test1.txt + | + @ 2f9e232 first commit + | + o c4b067e temp(split): test2.txt (+1) + "###); + } + + { + git.branchless("next", &[])?; + + let (stdout, _stderr) = git.run(&["show", "--pretty=format:", "--stat", "HEAD~"])?; + insta::assert_snapshot!(&stdout, @" + test1.txt | 2 +- + 1 file changed, 1 insertion(+), 1 deletion(-) + "); + + let (stdout, _stderr) = git.run(&["show", "--pretty=format:", "--stat", "HEAD"])?; + insta::assert_snapshot!(&stdout, @" + test2.txt | 1 + + 1 file changed, 1 insertion(+) + "); + } + + Ok(()) +} + +#[test] +fn test_split_modified_file() -> eyre::Result<()> { + let git = make_git()?; + git.init_repo()?; + git.detach_head()?; + + git.commit_file("test1", 1)?; + git.write_file_txt("test1", "updated contents")?; + git.write_file_txt("test2", "new contents")?; + git.run(&["add", "."])?; + git.run(&["commit", "-m", "first commit"])?; + + { + let (stdout, _stderr) = git.branchless("smartlog", &[])?; + insta::assert_snapshot!(stdout, @r###" + O f777ecc (master) create initial.txt + | + o 62fc20d create test1.txt + | + @ 0f6059d first commit + "###); + } + + { + let (stdout, _stderr) = git.branchless("split", &["HEAD", "test1.txt"])?; + insta::assert_snapshot!(&stdout, @r###" + branchless: running command: checkout 495b4c09b4cc1755847ba0fd42c903f9c7eecc00 + Nothing to restack. + O f777ecc (master) create initial.txt + | + o 62fc20d create test1.txt + | + @ 495b4c0 first commit + | + o 5375cb6 temp(split): test1.txt (+1/-1) + "###); + } + + { + git.branchless("next", &[])?; + + let (stdout, _stderr) = git.run(&["show", "--pretty=format:", "--stat", "HEAD~"])?; + insta::assert_snapshot!(&stdout, @" + test2.txt | 1 + + 1 file changed, 1 insertion(+) + "); + + let (stdout, _stderr) = git.run(&["show", "--pretty=format:", "--stat", "HEAD"])?; + insta::assert_snapshot!(&stdout, @" + test1.txt | 2 +- + 1 file changed, 1 insertion(+), 1 deletion(-) + "); + } + + Ok(()) +} + +#[test] +fn test_split_deleted_file() -> eyre::Result<()> { + let git = make_git()?; + git.init_repo()?; + git.detach_head()?; + + git.commit_file("test1", 1)?; + + git.delete_file("test1")?; + git.write_file_txt("test2", "new contents")?; + git.run(&["add", "."])?; + git.run(&["commit", "-m", "first commit"])?; + + { + let (stdout, _stderr) = git.branchless("smartlog", &[])?; + insta::assert_snapshot!(stdout, @r###" + O f777ecc (master) create initial.txt + | + o 62fc20d create test1.txt + | + @ 94e9c28 first commit + "###); + } + + { + let (stdout, _stderr) = git.run(&["show", "--pretty=format:", "--stat", "HEAD"])?; + insta::assert_snapshot!(&stdout, @" + test1.txt | 1 - + test2.txt | 1 + + 2 files changed, 1 insertion(+), 1 deletion(-) + "); + } + + { + let (stdout, _stderr) = git.branchless("split", &["HEAD", "test1.txt"])?; + insta::assert_snapshot!(&stdout, @r###" + branchless: running command: checkout 495b4c09b4cc1755847ba0fd42c903f9c7eecc00 + Nothing to restack. + O f777ecc (master) create initial.txt + | + o 62fc20d create test1.txt + | + @ 495b4c0 first commit + | + o de6e4df temp(split): test1.txt (-1) + "###); + } + + { + git.branchless("next", &[])?; + + let (stdout, _stderr) = git.run(&["show", "--pretty=format:", "--stat", "HEAD~"])?; + insta::assert_snapshot!(&stdout, @" + test2.txt | 1 + + 1 file changed, 1 insertion(+) + "); + + let (stdout, _stderr) = git.run(&["show", "--pretty=format:", "--stat", "HEAD"])?; + insta::assert_snapshot!(&stdout, @" + test1.txt | 1 - + 1 file changed, 1 deletion(-) + "); + } + + Ok(()) +} + +#[test] +fn test_split_multiple_files() -> eyre::Result<()> { + let git = make_git()?; + git.init_repo()?; + git.detach_head()?; + + git.write_file_txt("test1", "contents1")?; + git.write_file_txt("test2", "contents2")?; + git.write_file_txt("test3", "contents3")?; + git.run(&["add", "."])?; + git.run(&["commit", "-m", "first commit"])?; + + { + let (stdout, _stderr) = git.branchless("smartlog", &[])?; + insta::assert_snapshot!(stdout, @r###" + O f777ecc (master) create initial.txt + | + @ e48cdc5 first commit + "###); + } + + { + let (stdout, _stderr) = git.branchless("split", &["HEAD", "test2.txt", "test3.txt"])?; + insta::assert_snapshot!(&stdout, @r###" + branchless: running command: checkout 8e5c74b7a1f09fc7ee1754763c810e3f00fe9b05 + Nothing to restack. + O f777ecc (master) create initial.txt + | + @ 8e5c74b first commit + | + o 57020b0 temp(split): 2 files (+2) + "###); + } + + { + git.branchless("next", &[])?; + + let (stdout, _stderr) = git.run(&["show", "--pretty=format:", "--stat", "HEAD~"])?; + insta::assert_snapshot!(&stdout, @" + test1.txt | 1 + + 1 file changed, 1 insertion(+) + "); + + let (stdout, _stderr) = git.run(&["show", "--pretty=format:", "--stat", "HEAD"])?; + insta::assert_snapshot!(&stdout, @" + test2.txt | 1 + + test3.txt | 1 + + 2 files changed, 2 insertions(+) + "); + } + + Ok(()) +} + +#[test] +fn test_split_detached_branch() -> eyre::Result<()> { + let git = make_git()?; + git.init_repo()?; + git.detach_head()?; + + git.write_file_txt("test1", "contents1")?; + git.write_file_txt("test2", "contents2")?; + git.write_file_txt("test3", "contents3")?; + git.run(&["add", "."])?; + git.run(&["commit", "-m", "first commit"])?; + git.run(&["branch", "branch-name"])?; + + { + let (stdout, _stderr) = git.branchless("smartlog", &[])?; + insta::assert_snapshot!(stdout, @r###" + O f777ecc (master) create initial.txt + | + @ e48cdc5 (branch-name) first commit + "###); + } + + { + let (stdout, _stderr) = git.branchless("split", &["HEAD", "test2.txt"])?; + insta::assert_snapshot!(&stdout, @r###" + branchless: processing 1 update: branch branch-name + branchless: running command: checkout 2932db7d1099237d79cbd43e29707d70e545d471 + Nothing to restack. + O f777ecc (master) create initial.txt + | + @ 2932db7 (branch-name) first commit + | + o 01523cc temp(split): test2.txt (+1) + "###); + } + + Ok(()) +} + +#[test] +fn test_split_attached_branch() -> eyre::Result<()> { + let git = make_git()?; + git.init_repo()?; + git.detach_head()?; + + git.write_file_txt("test1", "contents1")?; + git.write_file_txt("test2", "contents2")?; + git.write_file_txt("test3", "contents3")?; + git.run(&["add", "."])?; + git.run(&["commit", "-m", "first commit"])?; + git.run(&["switch", "-c", "branch-name"])?; + + { + let (stdout, _stderr) = git.branchless("smartlog", &[])?; + insta::assert_snapshot!(stdout, @r###" + O f777ecc (master) create initial.txt + | + @ e48cdc5 (> branch-name) first commit + "###); + + let (stdout, _stderr) = git.run(&["status"])?; + insta::assert_snapshot!(&stdout, @" + On branch branch-name + nothing to commit, working tree clean + "); + } + + { + let (stdout, _stderr) = git.branchless("split", &["HEAD", "test2.txt"])?; + insta::assert_snapshot!(&stdout, @r###" + branchless: processing 1 update: branch branch-name + Nothing to restack. + O f777ecc (master) create initial.txt + | + o 2932db7 first commit + | + @ 01523cc (> branch-name) temp(split): test2.txt (+1) + "###); + + let (stdout, _stderr) = git.run(&["status", "--short"])?; + insta::assert_snapshot!(&stdout, @r#""#); + } + + Ok(()) +} + +#[test] +fn test_split_restacks_descendents() -> eyre::Result<()> { + let git = make_git()?; + git.init_repo()?; + git.detach_head()?; + + git.write_file_txt("test1", "contents1")?; + git.write_file_txt("test2", "contents2")?; + git.write_file_txt("test3", "contents3")?; + git.run(&["add", "."])?; + git.run(&["commit", "-m", "first commit"])?; + + git.commit_file("test3", 1)?; + + { + let (stdout, _stderr) = git.branchless("smartlog", &[])?; + insta::assert_snapshot!(stdout, @r###" + O f777ecc (master) create initial.txt + | + o e48cdc5 first commit + | + @ 3d220e0 create test3.txt + "###); + } + + { + let (stdout, _stderr) = git.branchless("split", &["HEAD~", "test2.txt"])?; + insta::assert_snapshot!(&stdout, @r###" + Attempting rebase in-memory... + [1/1] Committed as: a629a22 create test3.txt + branchless: processing 1 rewritten commit + branchless: running command: checkout a629a22974b9232523701e66e6e2bcdf8ffc8ad1 + In-memory rebase succeeded. + O f777ecc (master) create initial.txt + | + o 2932db7 first commit + | + o 01523cc temp(split): test2.txt (+1) + | + @ a629a22 create test3.txt + "###); + } + + { + let (stdout, _stderr) = git.run(&["show", "--pretty=format:", "--stat", "HEAD~2"])?; + insta::assert_snapshot!(&stdout, @" + test1.txt | 1 + + test3.txt | 1 + + 2 files changed, 2 insertions(+) + "); + + let (stdout, _stderr) = git.run(&["show", "--pretty=format:", "--stat", "HEAD~"])?; + insta::assert_snapshot!(&stdout, @" + test2.txt | 1 + + 1 file changed, 1 insertion(+) + "); + } + + Ok(()) +} + +#[test] +fn test_split_detach() -> eyre::Result<()> { + let git = make_git()?; + git.init_repo()?; + git.detach_head()?; + + git.write_file_txt("test1", "contents1")?; + git.write_file_txt("test2", "contents2")?; + git.write_file_txt("test3", "contents3")?; + git.run(&["add", "."])?; + git.run(&["commit", "-m", "first commit"])?; + + git.commit_file("test3", 1)?; + + { + let (stdout, _stderr) = git.branchless("smartlog", &[])?; + insta::assert_snapshot!(stdout, @r###" + O f777ecc (master) create initial.txt + | + o e48cdc5 first commit + | + @ 3d220e0 create test3.txt + "###); + } + + { + let (stdout, _stderr) = git.branchless("split", &["HEAD~", "test2.txt", "--detach"])?; + insta::assert_snapshot!(&stdout, @r###" + Attempting rebase in-memory... + [1/1] Committed as: f88fbe5 create test3.txt + branchless: processing 1 rewritten commit + branchless: running command: checkout f88fbe5901493ffe1c669cdb8aa5f056dc0bb605 + In-memory rebase succeeded. + O f777ecc (master) create initial.txt + | + o 2932db7 first commit + |\ + | o 01523cc temp(split): test2.txt (+1) + | + @ f88fbe5 create test3.txt + "###); + } + + { + let (stdout, _stderr) = git.run(&["show", "--pretty=format:", "--stat", "HEAD~"])?; + insta::assert_snapshot!(&stdout, @" + test1.txt | 1 + + test3.txt | 1 + + 2 files changed, 2 insertions(+) + "); + + let (split_commit, _stderr) = git.run(&["query", "--raw", "exactly(siblings(HEAD), 1)"])?; + let (stdout, _stderr) = + git.run(&["show", "--pretty=format:", "--stat", split_commit.trim()])?; + insta::assert_snapshot!(&stdout, @" + test2.txt | 1 + + 1 file changed, 1 insertion(+) + "); + } + + Ok(()) +} + +#[test] +fn test_split_discard() -> eyre::Result<()> { + let git = make_git()?; + git.init_repo()?; + git.detach_head()?; + + git.write_file_txt("test1", "contents1")?; + git.write_file_txt("test2", "contents2")?; + git.write_file_txt("test3", "contents3")?; + git.run(&["add", "."])?; + git.run(&["commit", "-m", "first commit"])?; + + git.write_file_txt("test3", "updated contents3")?; + git.write_file_txt("test4", "contents4")?; + git.write_file_txt("test5", "contents5")?; + git.run(&["add", "."])?; + git.run(&["commit", "-m", "second commit"])?; + + { + let (stdout, _stderr) = git.branchless("smartlog", &[])?; + insta::assert_snapshot!(stdout, @r###" + O f777ecc (master) create initial.txt + | + o e48cdc5 first commit + | + @ 8c3edf7 second commit + "###); + + let (stdout, _stderr) = git.run(&["show", "--pretty=format:", "--stat", "HEAD~"])?; + insta::assert_snapshot!(&stdout, @" + test1.txt | 1 + + test2.txt | 1 + + test3.txt | 1 + + 3 files changed, 3 insertions(+) + "); + + let (stdout, _stderr) = git.run(&["show", "--pretty=format:", "--stat", "HEAD"])?; + insta::assert_snapshot!(&stdout, @" + test3.txt | 2 +- + test4.txt | 1 + + test5.txt | 1 + + 3 files changed, 3 insertions(+), 1 deletion(-) + "); + } + + { + let (stdout, _stderr) = git.branchless("split", &["HEAD~", "test2.txt", "--discard"])?; + insta::assert_snapshot!(&stdout, @r###" + Attempting rebase in-memory... + [1/1] Committed as: 6e23d3d second commit + branchless: processing 1 rewritten commit + branchless: running command: checkout 6e23d3dfe1baeb366ebc31a61c32a19ca6a4ab63 + In-memory rebase succeeded. + O f777ecc (master) create initial.txt + | + o 2932db7 first commit + | + @ 6e23d3d second commit + "###); + + let (stdout, _stderr) = git.run(&["show", "--pretty=format:", "--stat", "HEAD~"])?; + insta::assert_snapshot!(&stdout, @" + test1.txt | 1 + + test3.txt | 1 + + 2 files changed, 2 insertions(+) + "); + + let (stdout, _stderr) = git.run(&["ls-files"])?; + insta::assert_snapshot!(&stdout, @" + initial.txt + test1.txt + test3.txt + test4.txt + test5.txt + "); + } + + { + let (stdout, _stderr) = + git.branchless("split", &["HEAD", "test3.txt", "test4.txt", "--discard"])?; + insta::assert_snapshot!(&stdout, @r###" + branchless: running command: checkout 6128a569e64c77d8a847293b81ae8c96357b751c + Nothing to restack. + O f777ecc (master) create initial.txt + | + o 2932db7 first commit + | + @ 6128a56 second commit + "###); + + let (stdout, _stderr) = git.run(&["show", "--pretty=format:", "--stat", "HEAD"])?; + insta::assert_snapshot!(&stdout, @" + test5.txt | 1 + + 1 file changed, 1 insertion(+) + "); + + let (stdout, _stderr) = git.run(&["ls-files"])?; + insta::assert_snapshot!(&stdout, @" + initial.txt + test1.txt + test3.txt + test5.txt + "); + + let (stdout, _stderr) = git.run(&["show", ":test3.txt"])?; + insta::assert_snapshot!(&stdout, @" + contents3 + "); + } + + Ok(()) +} + +#[test] +fn test_split_discard_bug_checked_out_branch() -> eyre::Result<()> { + let git = make_git()?; + git.init_repo()?; + git.detach_head()?; + + git.write_file_txt("test1", "contents1")?; + git.write_file_txt("test2", "contents2")?; + git.write_file_txt("test3", "contents3")?; + git.run(&["add", "."])?; + git.run(&["commit", "-m", "first commit"])?; + + git.write_file_txt("test3", "updated contents3")?; + git.write_file_txt("test4", "contents4")?; + git.write_file_txt("test5", "contents5")?; + git.run(&["add", "."])?; + git.run(&["commit", "-m", "second commit"])?; + git.run(&["switch", "--create", "my-branch"])?; + + { + // initial state: + // HEAD~ contains new test1-3 + // HEAD contains update test3 and new test4&5 + let (stdout, _stderr) = git.branchless("smartlog", &[])?; + insta::assert_snapshot!(stdout, @r###" + O f777ecc (master) create initial.txt + | + o e48cdc5 first commit + | + @ 8c3edf7 (> my-branch) second commit + "###); + + let (stdout, _stderr) = git.run(&["show", "--pretty=format:", "--stat", "HEAD~"])?; + insta::assert_snapshot!(&stdout, @" + test1.txt | 1 + + test2.txt | 1 + + test3.txt | 1 + + 3 files changed, 3 insertions(+) + "); + + let (stdout, _stderr) = git.run(&["show", "--pretty=format:", "--stat", "HEAD"])?; + insta::assert_snapshot!(&stdout, @" + test3.txt | 2 +- + test4.txt | 1 + + test5.txt | 1 + + 3 files changed, 3 insertions(+), 1 deletion(-) + "); + } + + { + // discard test2 from HEAD~: should be removed from commit and disk + + let (stdout, _stderr) = git.branchless("split", &["HEAD~", "test2.txt", "--discard"])?; + insta::assert_snapshot!(&stdout, @r###" + Attempting rebase in-memory... + [1/1] Committed as: 6e23d3d second commit + branchless: processing 1 update: branch my-branch + branchless: processing 1 rewritten commit + branchless: running command: checkout my-branch + In-memory rebase succeeded. + O f777ecc (master) create initial.txt + | + o 2932db7 first commit + | + @ 6e23d3d (> my-branch) second commit + "###); + + let (stdout, _stderr) = git.run(&["show", "--pretty=format:", "--stat", "HEAD~"])?; + insta::assert_snapshot!(&stdout, @" + test1.txt | 1 + + test3.txt | 1 + + 2 files changed, 2 insertions(+) + "); + + let (stdout, _stderr) = git.run(&["ls-files"])?; + insta::assert_snapshot!(&stdout, @" + initial.txt + test1.txt + test3.txt + test4.txt + test5.txt + "); + } + + { + // discard test3 and test4 from HEAD: both should be removed from commit + // but test3 should still exist on disk, with contents from HEAD~ + + let (stdout, _stderr) = + git.branchless("split", &["HEAD", "test3.txt", "test4.txt", "--discard"])?; + insta::assert_snapshot!(&stdout, @r###" + branchless: processing 1 update: branch my-branch + Nothing to restack. + O f777ecc (master) create initial.txt + | + o 2932db7 first commit + | + @ 6128a56 (> my-branch) second commit + "###); + + let (stdout, _stderr) = git.run(&["show", "--pretty=format:", "--stat", "HEAD"])?; + insta::assert_snapshot!(&stdout, @" + test5.txt | 1 + + 1 file changed, 1 insertion(+) + "); + + let (stdout, _stderr) = git.run(&["ls-files"])?; + insta::assert_snapshot!(&stdout, @" + initial.txt + test1.txt + test3.txt + test5.txt + "); + + let (stdout, _stderr) = git.run(&["show", ":test3.txt"])?; + insta::assert_snapshot!(&stdout, @" + contents3 + ") + } + + Ok(()) +} + +#[test] +fn test_split_insert_before_added_file() -> eyre::Result<()> { + let git = make_git()?; + git.init_repo()?; + git.detach_head()?; + + // new files + git.write_file_txt("test1", "contents1")?; + git.write_file_txt("test2", "contents2")?; + git.write_file_txt("test3", "contents3")?; + git.run(&["add", "."])?; + git.run(&["commit", "-m", "first commit"])?; + + // modified file + git.commit_file("test3", 1)?; + + { + let (stdout, _stderr) = git.branchless("smartlog", &[])?; + insta::assert_snapshot!(stdout, @r###" + O f777ecc (master) create initial.txt + | + o e48cdc5 first commit + | + @ 3d220e0 create test3.txt + "###); + + let (stdout, _stderr) = git.run(&["show", "--pretty=format:", "--stat", "HEAD~"])?; + insta::assert_snapshot!(&stdout, @" + test1.txt | 1 + + test2.txt | 1 + + test3.txt | 1 + + 3 files changed, 3 insertions(+) + "); + + let (stdout, _stderr) = git.run(&["show", "--pretty=format:", "--stat", "HEAD"])?; + insta::assert_snapshot!(&stdout, @" + test3.txt | 2 +- + 1 file changed, 1 insertion(+), 1 deletion(-) + "); + } + + { + let (stdout, _stderr) = git.branchless("split", &["HEAD~", "test2.txt", "--before"])?; + insta::assert_snapshot!(&stdout, @r###" + Attempting rebase in-memory... + [1/2] Committed as: 7014c04 first commit + [2/2] Committed as: 22bd240 create test3.txt + branchless: processing 2 rewritten commits + branchless: running command: checkout 22bd2405a4660938b88615fb2b1283bfa2a52f8e + In-memory rebase succeeded. + O f777ecc (master) create initial.txt + | + o d02e8c5 temp(split): test2.txt (+1) + | + o 7014c04 first commit + | + @ 22bd240 create test3.txt + "###); + + let (stdout, _stderr) = git.run(&["show", "--pretty=format:", "--stat", "HEAD~2"])?; + insta::assert_snapshot!(&stdout, @" + test2.txt | 1 + + 1 file changed, 1 insertion(+) + "); + + let (stdout, _stderr) = git.run(&["show", "--pretty=format:", "--stat", "HEAD~"])?; + insta::assert_snapshot!(&stdout, @" + test1.txt | 1 + + test3.txt | 1 + + 2 files changed, 2 insertions(+) + "); + } + + Ok(()) +} + +#[test] +fn test_split_insert_before_modified_file() -> eyre::Result<()> { + let git = make_git()?; + git.init_repo()?; + git.detach_head()?; + + // new files + git.write_file_txt("test1", "contents1")?; + git.write_file_txt("test2", "contents2")?; + git.write_file_txt("test3", "contents3")?; + git.run(&["add", "."])?; + git.run(&["commit", "-m", "first commit"])?; + + // modified files + git.write_file_txt("test2", "contents2 again")?; + git.write_file_txt("test3", "contents3 again")?; + git.run(&["add", "."])?; + git.run(&["commit", "-m", "second commit"])?; + + { + let (stdout, _stderr) = git.branchless("smartlog", &[])?; + insta::assert_snapshot!(stdout, @r###" + O f777ecc (master) create initial.txt + | + o e48cdc5 first commit + | + @ 7249f22 second commit + "###); + + let (stdout, _stderr) = git.run(&["show", "--pretty=format:", "--stat", "HEAD~"])?; + insta::assert_snapshot!(&stdout, @" + test1.txt | 1 + + test2.txt | 1 + + test3.txt | 1 + + 3 files changed, 3 insertions(+) + "); + + let (stdout, _stderr) = git.run(&["show", "--pretty=format:", "--stat", "HEAD"])?; + insta::assert_snapshot!(&stdout, @" + test2.txt | 2 +- + test3.txt | 2 +- + 2 files changed, 2 insertions(+), 2 deletions(-) + "); + } + + { + let (stdout, _stderr) = git.branchless("split", &["HEAD", "test2.txt", "--before"])?; + insta::assert_snapshot!(&stdout, @r###" + Attempting rebase in-memory... + [1/1] Committed as: 38fe8b7 second commit + branchless: processing 1 rewritten commit + branchless: running command: checkout 38fe8b76f889772efd0dd5cc1acb6ac02c85f9fb + In-memory rebase succeeded. + O f777ecc (master) create initial.txt + | + o e48cdc5 first commit + | + o 188b0a1 temp(split): test2.txt (+1/-1) + | + @ 38fe8b7 second commit + "###); + + let (stdout, _stderr) = git.run(&["show", "--pretty=format:", "--stat", "HEAD~"])?; + insta::assert_snapshot!(&stdout, @" + test2.txt | 2 +- + 1 file changed, 1 insertion(+), 1 deletion(-) + "); + + let (stdout, _stderr) = git.run(&["show", "--pretty=format:", "--stat", "HEAD"])?; + insta::assert_snapshot!(&stdout, @" + test3.txt | 2 +- + 1 file changed, 1 insertion(+), 1 deletion(-) + "); + } + + Ok(()) +} + +#[test] +fn test_split_insert_before_deleted_file() -> eyre::Result<()> { + let git = make_git()?; + git.init_repo()?; + git.detach_head()?; + + // new files + git.write_file_txt("test1", "contents1")?; + git.write_file_txt("test2", "contents2")?; + git.write_file_txt("test3", "contents3")?; + git.run(&["add", "."])?; + git.run(&["commit", "-m", "first commit"])?; + + // modified files + git.delete_file("test2")?; + git.write_file_txt("test3", "contents3 again")?; + git.run(&["add", "."])?; + git.run(&["commit", "-m", "second commit"])?; + + { + let (stdout, _stderr) = git.branchless("smartlog", &[])?; + insta::assert_snapshot!(stdout, @r###" + O f777ecc (master) create initial.txt + | + o e48cdc5 first commit + | + @ 98ebe2f second commit + "###); + + let (stdout, _stderr) = git.run(&["show", "--pretty=format:", "--stat", "HEAD~"])?; + insta::assert_snapshot!(&stdout, @" + test1.txt | 1 + + test2.txt | 1 + + test3.txt | 1 + + 3 files changed, 3 insertions(+) + "); + + let (stdout, _stderr) = git.run(&["show", "--pretty=format:", "--stat", "HEAD"])?; + insta::assert_snapshot!(&stdout, @" + test2.txt | 1 - + test3.txt | 2 +- + 2 files changed, 1 insertion(+), 2 deletions(-) + "); + } + + { + let (stdout, _stderr) = git.branchless("split", &["HEAD", "test2.txt", "--before"])?; + insta::assert_snapshot!(&stdout, @r###" + Attempting rebase in-memory... + [1/1] Committed as: f8502a2 second commit + branchless: processing 1 rewritten commit + branchless: running command: checkout f8502a26000b8f90597f6861d7f3c0330fdf4351 + In-memory rebase succeeded. + O f777ecc (master) create initial.txt + | + o e48cdc5 first commit + | + o e5b771d temp(split): test2.txt (-1) + | + @ f8502a2 second commit + "###); + + let (stdout, _stderr) = git.run(&["show", "--pretty=format:", "--stat", "HEAD~"])?; + insta::assert_snapshot!(&stdout, @" + test2.txt | 1 - + 1 file changed, 1 deletion(-) + "); + + let (stdout, _stderr) = git.run(&["show", "--pretty=format:", "--stat", "HEAD"])?; + insta::assert_snapshot!(&stdout, @" + test3.txt | 2 +- + 1 file changed, 1 insertion(+), 1 deletion(-) + "); + } + + Ok(()) +} + +#[test] +fn test_split_insert_before_attached_branch() -> eyre::Result<()> { + let git = make_git()?; + git.init_repo()?; + git.detach_head()?; + + git.write_file_txt("test1", "contents1")?; + git.write_file_txt("test2", "contents2")?; + git.run(&["add", "."])?; + git.run(&["commit", "-m", "first commit"])?; + git.run(&["switch", "-c", "branch-name"])?; + + { + let (stdout, _stderr) = git.branchless("smartlog", &[])?; + insta::assert_snapshot!(stdout, @r###" + O f777ecc (master) create initial.txt + | + @ 4d11d02 (> branch-name) first commit + "###); + + let (stdout, _stderr) = git.run(&["show", "--pretty=format:", "--stat", "HEAD"])?; + insta::assert_snapshot!(&stdout, @" + test1.txt | 1 + + test2.txt | 1 + + 2 files changed, 2 insertions(+) + "); + } + + { + let (stdout, _stderr) = git.branchless("split", &["HEAD", "test2.txt", "--before"])?; + insta::assert_snapshot!(&stdout, @r###" + Attempting rebase in-memory... + [1/1] Committed as: c678b65 first commit + branchless: processing 1 update: branch branch-name + branchless: processing 1 rewritten commit + branchless: running command: checkout branch-name + In-memory rebase succeeded. + O f777ecc (master) create initial.txt + | + o d02e8c5 temp(split): test2.txt (+1) + | + @ c678b65 (> branch-name) first commit + "###); + + let (stdout, _stderr) = git.run(&["show", "--pretty=format:", "--stat", "HEAD~"])?; + insta::assert_snapshot!(&stdout, @" + test2.txt | 1 + + 1 file changed, 1 insertion(+) + "); + + let (stdout, _stderr) = git.run(&["show", "--pretty=format:", "--stat", "HEAD"])?; + insta::assert_snapshot!(&stdout, @" + test1.txt | 1 + + 1 file changed, 1 insertion(+) + "); + } + + Ok(()) +} + +// TODO fn test_split_insert_before_detached_branch_not_checked_out() +// -> branch is moved to the extracted commit, but should be left on the remainder commit + +#[test] +fn test_split_insert_before_detached_branch() -> eyre::Result<()> { + let git = make_git()?; + git.init_repo()?; + git.detach_head()?; + + git.write_file_txt("test1", "contents1")?; + git.write_file_txt("test2", "contents2")?; + git.run(&["add", "."])?; + git.run(&["commit", "-m", "first commit"])?; + git.run(&["branch", "branch-name"])?; + + { + let (stdout, _stderr) = git.branchless("smartlog", &[])?; + insta::assert_snapshot!(stdout, @r###" + O f777ecc (master) create initial.txt + | + @ 4d11d02 (branch-name) first commit + "###); + + let (stdout, _stderr) = git.run(&["show", "--pretty=format:", "--stat", "HEAD"])?; + insta::assert_snapshot!(&stdout, @" + test1.txt | 1 + + test2.txt | 1 + + 2 files changed, 2 insertions(+) + "); + } + + { + let (stdout, _stderr) = git.branchless("split", &["HEAD", "test2.txt", "--before"])?; + insta::assert_snapshot!(&stdout, @r###" + Attempting rebase in-memory... + [1/1] Committed as: c678b65 first commit + branchless: processing 1 update: branch branch-name + branchless: processing 1 rewritten commit + branchless: running command: checkout c678b6529d8f33a6903e25f70327464bd77f1ca1 + In-memory rebase succeeded. + O f777ecc (master) create initial.txt + | + o d02e8c5 temp(split): test2.txt (+1) + | + @ c678b65 (branch-name) first commit + "###); + + let (stdout, _stderr) = git.run(&["show", "--pretty=format:", "--stat", "HEAD~"])?; + insta::assert_snapshot!(&stdout, @" + test2.txt | 1 + + 1 file changed, 1 insertion(+) + "); + + let (stdout, _stderr) = git.run(&["show", "--pretty=format:", "--stat", "HEAD"])?; + insta::assert_snapshot!(&stdout, @" + test1.txt | 1 + + 1 file changed, 1 insertion(+) + "); + } + + Ok(()) +} + +#[test] +fn test_split_undo_works() -> eyre::Result<()> { + let git = make_git()?; + git.init_repo()?; + git.detach_head()?; + + git.write_file_txt("test1", "contents1")?; + git.write_file_txt("test2", "contents2")?; + git.write_file_txt("test3", "contents3")?; + git.run(&["add", "."])?; + git.run(&["commit", "-m", "first commit"])?; + + git.commit_file("test3", 1)?; + + { + let (stdout, _stderr) = git.branchless("smartlog", &[])?; + insta::assert_snapshot!(stdout, @r###" + O f777ecc (master) create initial.txt + | + o e48cdc5 first commit + | + @ 3d220e0 create test3.txt + "###); + } + + { + let (stdout, _stderr) = git.branchless("split", &["HEAD~", "test2.txt"])?; + insta::assert_snapshot!(&stdout, @r###" + Attempting rebase in-memory... + [1/1] Committed as: a629a22 create test3.txt + branchless: processing 1 rewritten commit + branchless: running command: checkout a629a22974b9232523701e66e6e2bcdf8ffc8ad1 + In-memory rebase succeeded. + O f777ecc (master) create initial.txt + | + o 2932db7 first commit + | + o 01523cc temp(split): test2.txt (+1) + | + @ a629a22 create test3.txt + "###); + } + + { + let (stdout, _stderr) = git.run(&["show", "--pretty=format:", "--stat", "HEAD~2"])?; + insta::assert_snapshot!(&stdout, @" + test1.txt | 1 + + test3.txt | 1 + + 2 files changed, 2 insertions(+) + "); + + let (stdout, _stderr) = git.run(&["show", "--pretty=format:", "--stat", "HEAD~"])?; + insta::assert_snapshot!(&stdout, @" + test2.txt | 1 + + 1 file changed, 1 insertion(+) + "); + } + + { + let (_stdout, _stderr) = git.branchless("undo", &["--yes"])?; + + let (stdout, _stderr) = git.branchless("smartlog", &[])?; + insta::assert_snapshot!(stdout, @r###" + O f777ecc (master) create initial.txt + | + o e48cdc5 first commit + | + @ 3d220e0 create test3.txt + "###); + + let (stdout, _stderr) = git.run(&["show", "--pretty=format:", "--stat", "HEAD~"])?; + insta::assert_snapshot!(&stdout, @" + test1.txt | 1 + + test2.txt | 1 + + test3.txt | 1 + + 3 files changed, 3 insertions(+) + "); + } + + Ok(()) +} + +#[test] +fn test_split_supports_absolute_relative_and_repo_relative_paths() -> eyre::Result<()> { + let git = make_git()?; + git.init_repo()?; + git.detach_head()?; + + git.write_file_txt("test1", "root contents1")?; + git.write_file_txt("test2", "root contents2")?; + git.write_file_txt("subdir/test1", "subdir contents1")?; + git.write_file_txt("subdir/test3", "subdir contents3")?; + git.run(&["add", "."])?; + git.run(&["commit", "-m", "first commit"])?; + + { + let (stdout, _stderr) = git.branchless("smartlog", &[])?; + insta::assert_snapshot!(stdout, @r###" + O f777ecc (master) create initial.txt + | + @ 2998051 first commit + "###); + } + + { + // test3.txt only exists in subdir + + let (stdout, _stderr) = git.branchless_with_options( + "split", + &["HEAD", "test3.txt"], + &GitRunOptions { + subdir: Some(PathBuf::from("subdir")), + ..Default::default() + }, + )?; + insta::assert_snapshot!(&stdout, @r###" + branchless: running command: checkout d9d41a308e25a71884831c865c356da43cc5294e + Nothing to restack. + O f777ecc (master) create initial.txt + | + @ d9d41a3 first commit + | + o 98da165 temp(split): subdir/test3.txt (+1) + "###); + + let (stdout, _stderr) = git.run(&["show", "--pretty=format:", "--stat", "HEAD"])?; + insta::assert_snapshot!(&stdout, @" + subdir/test1.txt | 1 + + test1.txt | 1 + + test2.txt | 1 + + 3 files changed, 3 insertions(+) + "); + } + + { + // test1.txt exists in root and subdir; try to resolve relative to cwd + + git.branchless("undo", &["--yes"])?; + + let (stdout, _stderr) = git.branchless_with_options( + "split", + &["HEAD", "test1.txt"], + &GitRunOptions { + subdir: Some(PathBuf::from("subdir")), + ..Default::default() + }, + )?; + insta::assert_snapshot!(&stdout, @r###" + branchless: running command: checkout 0cb81546d386a2064603c05ce7dc9759591f5a93 + Nothing to restack. + O f777ecc (master) create initial.txt + | + @ 0cb8154 first commit + | + o 89564a0 temp(split): subdir/test1.txt (+1) + "###); + + let (stdout, _stderr) = git.run(&["show", "--pretty=format:", "--stat", "HEAD"])?; + insta::assert_snapshot!(&stdout, @" + subdir/test3.txt | 1 + + test1.txt | 1 + + test2.txt | 1 + + 3 files changed, 3 insertions(+) + "); + } + + { + // test2.txt only exists in root; resolve it relative to root + + git.branchless("undo", &["--yes"])?; + + let (stdout, _stderr) = git.branchless_with_options( + "split", + &["HEAD", "test2.txt"], + &GitRunOptions { + subdir: Some(PathBuf::from("subdir")), + ..Default::default() + }, + )?; + insta::assert_snapshot!(&stdout, @r###" + branchless: running command: checkout 912204674dfda3ab5fe089dddd1c9bf17b3c2965 + Nothing to restack. + O f777ecc (master) create initial.txt + | + @ 9122046 first commit + | + o c3d37e6 temp(split): test2.txt (+1) + "###); + + let (stdout, _stderr) = git.run(&["show", "--pretty=format:", "--stat", "HEAD"])?; + insta::assert_snapshot!(&stdout, @" + subdir/test1.txt | 1 + + subdir/test3.txt | 1 + + test1.txt | 1 + + 3 files changed, 3 insertions(+) + "); + } + + { + // test1.txt exists in root and subdir; support : to resolve relative to root + + git.branchless("undo", &["--yes"])?; + + let (stdout, _stderr) = git.branchless_with_options( + "split", + &["HEAD", ":/test1.txt"], + &GitRunOptions { + subdir: Some(PathBuf::from("subdir")), + ..Default::default() + }, + )?; + insta::assert_snapshot!(&stdout, @r###" + branchless: running command: checkout 6d0cd9b8fb1938e50250f30427a0d4865b351f2f + Nothing to restack. + O f777ecc (master) create initial.txt + | + @ 6d0cd9b first commit + | + o 9eeb11b temp(split): test1.txt (+1) + "###); + + let (stdout, _stderr) = git.run(&["show", "--pretty=format:", "--stat", "HEAD"])?; + insta::assert_snapshot!(&stdout, @" + subdir/test1.txt | 1 + + subdir/test3.txt | 1 + + test2.txt | 1 + + 3 files changed, 3 insertions(+) + "); + } + + Ok(()) +} + +#[test] +fn test_split_unchanged_file() -> eyre::Result<()> { + let git = make_git()?; + git.init_repo()?; + git.detach_head()?; + + git.write_file_txt("test1", "contents1")?; + git.run(&["add", "."])?; + git.run(&["commit", "-m", "first commit"])?; + + { + let (stdout, _stderr) = git.branchless("smartlog", &[])?; + insta::assert_snapshot!(stdout, @r###" + O f777ecc (master) create initial.txt + | + @ 8e5c74b first commit + "###); + } + + { + let (_stdout, stderr) = git.branchless_with_options( + "split", + &["HEAD", "initial.txt"], + &GitRunOptions { + expected_exit_code: 1, + ..Default::default() + }, + )?; + insta::assert_snapshot!(&stderr, @r###" + Aborting: file 'initial.txt' was not changed in commit 8e5c74b. + "###); + } + + Ok(()) +} + +#[test] +fn test_split_will_not_split_to_empty_commit() -> eyre::Result<()> { + let git = make_git()?; + git.init_repo()?; + git.detach_head()?; + + git.write_file_txt("test1", "contents1")?; + git.run(&["add", "."])?; + git.run(&["commit", "-m", "first commit"])?; + + { + let (stdout, _stderr) = git.branchless("smartlog", &[])?; + insta::assert_snapshot!(stdout, @r###" + O f777ecc (master) create initial.txt + | + @ 8e5c74b first commit + "###); + } + + { + let (_stdout, stderr) = git.branchless_with_options( + "split", + &["HEAD", "test1.txt"], + &GitRunOptions { + expected_exit_code: 1, + ..Default::default() + }, + )?; + insta::assert_snapshot!(&stderr, @r###" + Aborting: refusing to split all changes out of commit 8e5c74b. + "###); + } + + Ok(()) +} diff --git a/rust-toolchain.toml b/rust-toolchain.toml index 94ed87190..166e0c96d 100644 --- a/rust-toolchain.toml +++ b/rust-toolchain.toml @@ -1,4 +1,4 @@ [toolchain] # Current minimum-supported Rust version -channel = "1.74" +channel = "1.85" profile = "default" diff --git a/scm-bisect/src/lib.rs b/scm-bisect/src/lib.rs index 0eb56aa52..2e7df91cf 100644 --- a/scm-bisect/src/lib.rs +++ b/scm-bisect/src/lib.rs @@ -9,7 +9,7 @@ clippy::clone_on_ref_ptr, clippy::dbg_macro )] -#![allow(clippy::too_many_arguments, clippy::blocks_in_if_conditions)] +#![allow(clippy::too_many_arguments, clippy::blocks_in_conditions)] pub mod basic; pub mod search;