diff --git a/git-branchless-init/src/lib.rs b/git-branchless-init/src/lib.rs index 5097e6895..75da12567 100644 --- a/git-branchless-init/src/lib.rs +++ b/git-branchless-init/src/lib.rs @@ -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-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/core/mod.rs b/git-branchless-lib/src/core/mod.rs index a4469f609..f23802e67 100644 --- a/git-branchless-lib/src/core/mod.rs +++ b/git-branchless-lib/src/core/mod.rs @@ -11,3 +11,4 @@ pub mod node_descriptors; pub mod repo_ext; pub mod rewrite; pub mod task; +pub mod untracked_file_cache; diff --git a/git-branchless-lib/src/core/untracked_file_cache.rs b/git-branchless-lib/src/core/untracked_file_cache.rs new file mode 100644 index 000000000..69e1432b7 --- /dev/null +++ b/git-branchless-lib/src/core/untracked_file_cache.rs @@ -0,0 +1,166 @@ +//! Utilities to fetch, confirm and save a list of untracked files, so we can +//! prompt the user about them. + +use console::{Key, Term}; +use eyre::Context; +use std::io::Write as IoWrite; +use std::time::SystemTime; +use std::{collections::HashSet, fmt::Write}; +use tracing::instrument; + +use crate::git::{GitRunInfo, Repo}; + +use super::{effects::Effects, eventlog::EventTransactionId, formatting::Pluralize}; + +/// TODO +#[instrument] +pub fn prompt_about_untracked_files( + effects: &Effects, + git_run_info: &GitRunInfo, + repo: &Repo, + event_tx_id: EventTransactionId, +) -> eyre::Result> { + let conn = repo.get_db_conn()?; + + let cached_files = get_cached_untracked_files(&conn)?; + let real_files = get_real_untracked_files(repo, event_tx_id, git_run_info)?; + let new_files: Vec<&String> = real_files.difference(&cached_files).collect(); + + let mut files_to_add = vec![]; + if !new_files.is_empty() { + writeln!( + effects.get_output_stream(), + "Found {}:", + Pluralize { + determiner: None, + amount: new_files.len(), + unit: ("new untracked file", "new untracked files"), + }, + )?; + 'outer: for file in new_files { + write!( + effects.get_output_stream(), + " Add file '{file}'? [Yes/(N)o/nOne] " + )?; + std::io::stdout().flush()?; + + let term = Term::stderr(); + 'inner: loop { + let key = term.read_key()?; + match key { + Key::Char('y') | Key::Char('Y') => { + files_to_add.push(file.clone()); + writeln!(effects.get_output_stream(), "adding")?; + } + Key::Char('n') | Key::Char('N') | Key::Enter => { + writeln!(effects.get_output_stream(), "not adding")?; + } + Key::Char('o') | Key::Char('O') => { + writeln!(effects.get_output_stream(), "skipping remaining")?; + break 'outer; + } + _ => continue 'inner, + }; + continue 'outer; + } + } + } + + cache_untracked_files(&conn, real_files)?; + + Ok(files_to_add) +} + +/// TODO +#[instrument] +fn get_real_untracked_files( + repo: &Repo, + event_tx_id: EventTransactionId, + git_run_info: &GitRunInfo, +) -> eyre::Result> { + let args = vec!["ls-files", "--others", "--exclude-standard", "-z"]; + let files_str = git_run_info + .run_silent(repo, Some(event_tx_id), &args, Default::default()) + .wrap_err("calling `git ls-files`")? + .stdout; + let files_str = String::from_utf8(files_str).wrap_err("Decoding stdout from Git subprocess")?; + let files = files_str + .trim() + .split('\0') + .filter_map(|s| { + if s.is_empty() { + None + } else { + Some(s.to_owned()) + } + }) + .collect(); + Ok(files) +} + +/// TODO +#[instrument] +fn cache_untracked_files(conn: &rusqlite::Connection, files: HashSet) -> eyre::Result<()> { + { + conn.execute("DROP TABLE IF EXISTS untracked_files", rusqlite::params![]) + .wrap_err("Removing `untracked_files` table")?; + } + + init_untracked_files_table(conn)?; + + { + let tx = conn.unchecked_transaction()?; + + let timestamp = SystemTime::now() + .duration_since(SystemTime::UNIX_EPOCH) + .wrap_err("Calculating event transaction timestamp")? + .as_secs_f64(); + for file in files { + tx.execute( + " + INSERT INTO untracked_files + (timestamp, file) + VALUES + (:timestamp, :file) + ", + rusqlite::named_params! { + ":timestamp": timestamp, + ":file": file, + }, + )?; + } + tx.commit()?; + } + + Ok(()) +} + +/// Ensure the untracked_files table exists; creating it if it does not. +#[instrument] +fn init_untracked_files_table(conn: &rusqlite::Connection) -> eyre::Result<()> { + conn.execute( + " + CREATE TABLE IF NOT EXISTS untracked_files ( + timestamp REAL NOT NULL, + file TEXT NOT NULL + ) + ", + rusqlite::params![], + ) + .wrap_err("Creating `untracked_files` table")?; + + Ok(()) +} + +/// TODO +#[instrument] +pub fn get_cached_untracked_files(conn: &rusqlite::Connection) -> eyre::Result> { + init_untracked_files_table(conn)?; + + let mut stmt = conn.prepare("SELECT file FROM untracked_files")?; + let paths = stmt + .query_map(rusqlite::named_params![], |row| row.get("file"))? + .filter_map(|p| p.ok()) + .collect(); + Ok(paths) +} 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..44a9f4694 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) { @@ -177,6 +191,17 @@ pub struct StatusEntry { } impl StatusEntry { + /// Create a status entry for a currently-untracked, to-be-added file. + pub fn new_untracked(filename: String) -> Self { + StatusEntry { + index_status: FileStatus::Untracked, + working_copy_status: FileStatus::Untracked, + working_copy_file_mode: FileMode::Blob, + path: PathBuf::from(filename), + orig_path: None, + } + } + /// Returns the paths associated with the status entry. pub fn paths(&self) -> Vec { let mut result = vec![self.path.clone()]; 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/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-navigation/src/lib.rs b/git-branchless-navigation/src/lib.rs index 729a3603d..08ebfb267 100644 --- a/git-branchless-navigation/src/lib.rs +++ b/git-branchless-navigation/src/lib.rs @@ -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..a3e31a25e 100644 --- a/git-branchless-opts/src/lib.rs +++ b/git-branchless-opts/src/lib.rs @@ -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..3bb89129c 100644 --- a/git-branchless-record/src/lib.rs +++ b/git-branchless-record/src/lib.rs @@ -30,10 +30,11 @@ use lib::core::rewrite::{ ExecuteRebasePlanResult, MergeConflictRemediation, RebasePlanBuilder, RebasePlanPermissions, RepoResource, }; +use lib::core::untracked_file_cache::prompt_about_untracked_files; 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}; @@ -97,6 +98,7 @@ fn record( let working_copy_changes_type = snapshot.get_working_copy_changes_type()?; match working_copy_changes_type { WorkingCopyChangesType::None => { + // FIXME look for new untracked files writeln!( effects.get_output_stream(), "There are no changes to tracked files in the working copy to commit." @@ -129,6 +131,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 +160,54 @@ fn record( )?); } } else { + let files_to_add = prompt_about_untracked_files(effects, git_run_info, &repo, event_tx_id)?; + if !files_to_add.is_empty() { + let args = { + let mut args = vec!["add".to_string()]; + // use repo-canonical paths even if adding in a repo subdir + args.extend(files_to_add.iter().map(|p| format!(":/{p}"))); + args + }; + // FIXME + let _ = git_run_info.run_direct_no_wrapping(Some(event_tx_id), &args)?; + } + + 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 +278,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..c3dc10284 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 4f8603e] stash: 1 file changed, 1 insertion(+), 1 deletion(-) + 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 4f8603e stash: 1 file changed, 1 insertion(+), 1 deletion(-) + "###); + } + + 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..65435019b 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,7 @@ 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 branch_commits = make_pattern_matcher( ctx, name, args, @@ -217,14 +243,22 @@ fn fn_branches(ctx: &mut Context, name: &str, args: &[Expr]) -> EvalResult { Ok(result) }), - Some(ctx.dag.branch_commits.clone()), + commitset_hint.map_or_else( + || Some(ctx.dag.branch_commits.clone()), + |hint| Some(hint.intersection(&ctx.dag.branch_commits)), + ), )?; 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 +277,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 +300,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 +316,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 +326,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,23 +335,13 @@ 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 all visible commits if a specific +/// set of commits is not 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, ) -> Result { struct Matcher { @@ -339,7 +368,12 @@ fn make_pattern_matcher_for_set( } #[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 +395,17 @@ fn fn_message(ctx: &mut Context, name: &str, args: &[Expr]) -> EvalResult { }; Ok(pattern.matches_text(message)) }), + commits_to_match.cloned(), ) } #[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 +431,17 @@ fn fn_path_changed(ctx: &mut Context, name: &str, args: &[Expr]) -> EvalResult { }); Ok(result) }), + commits_to_match.cloned(), ) } #[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 +453,17 @@ fn fn_author_name(ctx: &mut Context, name: &str, args: &[Expr]) -> EvalResult { None => Ok(false), }, ), + commits_to_match.cloned(), ) } #[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 +475,17 @@ fn fn_author_email(ctx: &mut Context, name: &str, args: &[Expr]) -> EvalResult { None => Ok(false), }, ), + commits_to_match.cloned(), ) } #[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 +495,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.cloned(), ) } #[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 +517,17 @@ fn fn_committer_name(ctx: &mut Context, name: &str, args: &[Expr]) -> EvalResult None => Ok(false), }, ), + commits_to_match.cloned(), ) } #[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 +539,17 @@ fn fn_committer_email(ctx: &mut Context, name: &str, args: &[Expr]) -> EvalResul None => Ok(false), }, ), + commits_to_match.cloned(), ) } #[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 +559,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.cloned(), ) } #[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 +580,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 +625,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 +634,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 +675,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 +703,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 +738,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 +778,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/grammar.lalrpop b/git-branchless-revset/src/grammar.lalrpop index 1803d480d..40c0d8698 100644 --- a/git-branchless-revset/src/grammar.lalrpop +++ b/git-branchless-revset/src/grammar.lalrpop @@ -46,6 +46,8 @@ Expr4: Expr<'input> = { "~" => Expr::FunctionCall(Cow::Borrowed("ancestors.nth"), vec![lhs, Expr::Name(Cow::Borrowed("1"))]), "~" => Expr::FunctionCall(Cow::Borrowed("ancestors.nth"), vec![lhs, Expr::Name(rhs)]), + "!" => Expr::FunctionCall(Cow::Borrowed("sole"), vec![Expr::FunctionCall(Cow::Borrowed("children"), vec![lhs])]), + } diff --git a/git-branchless-revset/src/parser.rs b/git-branchless-revset/src/parser.rs index 86b65f0bd..8945140e7 100644 --- a/git-branchless-revset/src/parser.rs +++ b/git-branchless-revset/src/parser.rs @@ -667,4 +667,73 @@ mod tests { Ok(()) } + + #[test] + fn test_revset_child_operator() -> eyre::Result<()> { + insta::assert_debug_snapshot!(parse("foo!"), @r###" + Ok( + FunctionCall( + "sole", + [ + FunctionCall( + "children", + [ + Name( + "foo", + ), + ], + ), + ], + ), + ) + "###); + + insta::assert_debug_snapshot!(parse("@! + @!!"), @r###" + Ok( + FunctionCall( + "union", + [ + FunctionCall( + "sole", + [ + FunctionCall( + "children", + [ + Name( + "@", + ), + ], + ), + ], + ), + FunctionCall( + "sole", + [ + FunctionCall( + "children", + [ + FunctionCall( + "sole", + [ + FunctionCall( + "children", + [ + Name( + "@", + ), + ], + ), + ], + ), + ], + ), + ], + ), + ], + ), + ) + "###); + + Ok(()) + } } diff --git a/git-branchless-reword/src/lib.rs b/git-branchless-reword/src/lib.rs index ef53ad4d3..15326b1b4 100644 --- a/git-branchless-reword/src/lib.rs +++ b/git-branchless-reword/src/lib.rs @@ -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-undo/src/lib.rs b/git-branchless-undo/src/lib.rs index 4dc39dcf2..a86fb01d4 100644 --- a/git-branchless-undo/src/lib.rs +++ b/git-branchless-undo/src/lib.rs @@ -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..2d978f2c8 100644 --- a/git-branchless/Cargo.toml +++ b/git-branchless/Cargo.toml @@ -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..5e1ed3b2d 100644 --- a/git-branchless/src/commands/amend.rs +++ b/git-branchless/src/commands/amend.rs @@ -26,7 +26,10 @@ use lib::core::rewrite::{ execute_rebase_plan, move_branches, BuildRebasePlanOptions, ExecuteRebasePlanOptions, ExecuteRebasePlanResult, RebasePlanBuilder, RebasePlanPermissions, RepoResource, }; -use lib::git::{AmendFastOptions, GitRunInfo, MaybeZeroOid, Repo, ResolvedReferenceInfo}; +use lib::core::untracked_file_cache::prompt_about_untracked_files; +use lib::git::{ + AmendFastOptions, GitRunInfo, MaybeZeroOid, Repo, ResolvedReferenceInfo, StatusEntry, +}; use lib::try_exit_code; use lib::util::{ExitCode, EyreExitOr}; use rayon::ThreadPoolBuilder; @@ -131,8 +134,17 @@ pub fn amend( .collect(), } } else { + let untracked_entries = + prompt_about_untracked_files(effects, git_run_info, &repo, event_tx_id)? + .into_iter() + .map(StatusEntry::new_untracked); + AmendFastOptions::FromWorkingCopy { - status_entries: unstaged_entries.clone(), + status_entries: unstaged_entries + .iter() + .cloned() + .chain(untracked_entries) + .collect_vec(), } }; if opts.is_empty() { @@ -203,6 +215,7 @@ pub fn amend( Some(target), &CheckOutCommitOptions { additional_args: Default::default(), + force_detach: false, reset: true, render_smartlog: false, }, @@ -297,6 +310,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..decdaa1fa --- /dev/null +++ b/git-branchless/src/commands/split.rs @@ -0,0 +1,593 @@ +//! 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", but + // this is the "extracted" commit for InsertBefore + 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 wrong here: extracted is correct for After and + // Discard modes, but the extracted commit is not 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/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..a99a29407 --- /dev/null +++ b/git-branchless/tests/test_split.rs @@ -0,0 +1,1396 @@ +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"])?; + + { + 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(-) + "); + } + + { + 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 + "); + } + + { + 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(()) +} + +#[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(()) +}