diff --git a/Cargo.lock b/Cargo.lock index a71c47baf..260f1b83d 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1477,6 +1477,7 @@ dependencies = [ "cc", "chashmap", "chrono", + "clap 4.5.46", "color-eyre", "concolor", "console", diff --git a/git-branchless-lib/Cargo.toml b/git-branchless-lib/Cargo.toml index 968e5be4f..49a1a78d1 100644 --- a/git-branchless-lib/Cargo.toml +++ b/git-branchless-lib/Cargo.toml @@ -48,6 +48,7 @@ async-trait = { workspace = true } bstr = { workspace = true } chashmap = { workspace = true } chrono = { workspace = true } +clap = { workspace = true } color-eyre = { workspace = true } concolor = { workspace = true } console = { workspace = true } 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..5e66c742f --- /dev/null +++ b/git-branchless-lib/src/core/untracked_file_cache.rs @@ -0,0 +1,311 @@ +//! Utilities to fetch, confirm and save a list of untracked files, so we can +//! prompt the user about them. + +use clap::ValueEnum; +use console::{Key, Term}; +use cursive::theme::BaseColor; +use eyre::Context; +use itertools::Itertools; +use std::io::Write as IoWrite; +use std::time::SystemTime; +use std::{collections::HashSet, fmt::Write}; +use tracing::instrument; + +use super::{effects::Effects, eventlog::EventTransactionId, formatting::Pluralize}; +use crate::core::formatting::StyledStringBuilder; +use crate::git::{ConfigRead, GitRunInfo, Repo}; +use crate::util::{ExitCode, EyreExitOr}; + +/// How to handle untracked files when creating/amending commits. +#[derive(Clone, Copy, Debug, ValueEnum)] +pub enum UntrackedFileStrategy { + /// Add all untracked files. + Add, + /// Prompt the user about how to handle each untracked file. + Prompt, + /// Skip all untracked files. + Skip, +} + +/// TODO +#[instrument] +pub fn process_untracked_files( + effects: &Effects, + git_run_info: &GitRunInfo, + repo: &Repo, + event_tx_id: EventTransactionId, + strategy: Option, +) -> EyreExitOr> { + let conn = repo.get_db_conn()?; + + let strategy = match strategy { + Some(strategy) => strategy, + None => { + let strategy_config_key = "branchless.record.untrackedFiles"; + let config = repo.get_readonly_config()?; + let strategy: Option = config.get(strategy_config_key)?; + match strategy { + None => UntrackedFileStrategy::Skip, + Some(strategy) => match UntrackedFileStrategy::from_str(&strategy, true) { + Ok(strategy) => strategy, + Err(_) => { + writeln!( + effects.get_output_stream(), + "Invalid value for config value {strategy_config_key}: {strategy}" + )?; + writeln!( + effects.get_output_stream(), + "Expected one of: {}", + UntrackedFileStrategy::value_variants() + .iter() + .filter_map(|variant| variant.to_possible_value()) + .map(|value| value.get_name().to_owned()) + .join(", ") + )?; + return Ok(Err(ExitCode(1))); + } + }, + } + } + }; + + 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 = real_files.difference(&cached_files).cloned().collect(); + let previously_skipped_files: Vec = + real_files.intersection(&cached_files).cloned().collect(); + + cache_untracked_files(&conn, real_files)?; + + if !previously_skipped_files.is_empty() { + writeln!( + effects.get_output_stream(), + "Skipping {}: {}", + Pluralize { + determiner: None, + amount: previously_skipped_files.len(), + unit: ("previously skipped file", "previously skipped files"), + }, + render_styled(effects, previously_skipped_files.join(", "),) + )?; + } + + if new_files.is_empty() { + return Ok(Ok(vec![])); + } + + let files_to_add = match strategy { + UntrackedFileStrategy::Add => { + writeln!( + effects.get_output_stream(), + "Including {}: {}", + Pluralize { + determiner: None, + amount: new_files.len(), + unit: ("new untracked file", "new untracked files"), + }, + new_files.join(", ") + )?; + + new_files + } + + UntrackedFileStrategy::Skip => { + writeln!( + effects.get_output_stream(), + "Skipping {}: {}", + Pluralize { + determiner: None, + amount: new_files.len(), + unit: ("new untracked file", "new untracked files"), + }, + render_styled(effects, new_files.join(", "),) + )?; + // TODO "These files will always be skipped. To add them, use `git add`" + // TODO make this a hint? configurable to be off? + + vec![] + } + + UntrackedFileStrategy::Prompt => { + let mut files_to_add = vec![]; + let mut skip_remaining = false; + writeln!( + effects.get_output_stream(), + "Found {}:", + Pluralize { + determiner: None, + amount: new_files.len(), + unit: ("new untracked file", "new untracked files"), + }, + )?; + 'file_loop: for file in new_files { + if skip_remaining { + writeln!(effects.get_output_stream(), " Skipping file '{file}'")?; + continue 'file_loop; + } + + 'prompt_loop: loop { + write!( + effects.get_output_stream(), + " Include file '{file}'? {} ", + render_styled(effects, "[Yes/(N)o/nOne/Help]".to_string()) + )?; + std::io::stdout().flush()?; + + let term = Term::stderr(); + 'tty_input_loop: 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(), + "{}", + render_styled(effects, "adding".to_string()) + )?; + } + + Key::Char('n') | Key::Char('N') | Key::Enter => { + writeln!( + effects.get_output_stream(), + "{}", + render_styled(effects, "not adding".to_string()) + )?; + } + + Key::Char('o') | Key::Char('O') => { + skip_remaining = true; + writeln!( + effects.get_output_stream(), + "{}", + render_styled(effects, "skipping remaining".to_string()) + )?; + } + + Key::Char('h') | Key::Char('H') | Key::Char('?') => { + writeln!( + effects.get_output_stream(), + "help\n\n - y/Y: include the file\n - n/N/: skip the file\n - o/O: skip the file and all subsequent files\n - h/H/?: show this help message\n" + )?; + continue 'prompt_loop; + } + + _ => continue 'tty_input_loop, + }; + continue 'file_loop; + } + } + } + + files_to_add + } + }; + + Ok(Ok(files_to_add)) +} + +fn render_styled(effects: &Effects, string_to_render: String) -> String { + effects + .get_glyphs() + .render( + StyledStringBuilder::new() + .append_styled(string_to_render, BaseColor::Black.light()) + .build(), + ) + .expect("rendering styled string") +} + +/// 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/status.rs b/git-branchless-lib/src/git/status.rs index 193736d51..44a9f4694 100644 --- a/git-branchless-lib/src/git/status.rs +++ b/git-branchless-lib/src/git/status.rs @@ -191,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-opts/src/lib.rs b/git-branchless-opts/src/lib.rs index 8c39220e8..21dbe455e 100644 --- a/git-branchless-opts/src/lib.rs +++ b/git-branchless-opts/src/lib.rs @@ -18,6 +18,7 @@ use std::path::{Path, PathBuf}; use std::str::FromStr; use clap::{Args, Command as ClapCommand, CommandFactory, Parser, ValueEnum}; +use lib::core::untracked_file_cache::UntrackedFileStrategy; use lib::git::NonZeroOid; /// A revset expression. Can be a commit hash, branch name, or one of the @@ -329,6 +330,14 @@ pub struct RecordArgs { /// After making the new commit, switch back to the previous commit. #[clap(action, short = 's', long = "stash", conflicts_with_all(&["create", "detach"]))] pub stash: bool, + + /// Allow creating an empty commit. + #[clap(action, long = "allow-empty")] + pub allow_empty: bool, + + /// How should newly encountered, untracked files be handled? + #[clap(value_parser, long = "untracked", conflicts_with_all(&["interactive"]))] + pub untracked_file_strategy: Option, } /// Display a nice graph of the commits you've recently worked on. @@ -448,6 +457,10 @@ pub enum Command { /// formatting or refactoring changes. #[clap(long)] reparent: bool, + + /// How should newly encountered, untracked files be handled? + #[clap(action, long = "untracked")] + untracked_file_strategy: Option, }, /// Gather information about recent operations to upload as part of a bug diff --git a/git-branchless-record/src/lib.rs b/git-branchless-record/src/lib.rs index 04c5cbdae..2e4d38b45 100644 --- a/git-branchless-record/src/lib.rs +++ b/git-branchless-record/src/lib.rs @@ -30,6 +30,7 @@ use lib::core::rewrite::{ ExecuteRebasePlanResult, MergeConflictRemediation, RebasePlanBuilder, RebasePlanPermissions, RepoResource, }; +use lib::core::untracked_file_cache::{process_untracked_files, UntrackedFileStrategy}; use lib::git::{ process_diff_for_record, summarize_diff_for_temporary_commit, update_index, CategorizedReferenceName, FileMode, GitRunInfo, MaybeZeroOid, NonZeroOid, Repo, @@ -58,6 +59,8 @@ pub fn command_main(ctx: CommandContext, args: RecordArgs) -> EyreExitOr<()> { detach, insert, stash, + allow_empty, + untracked_file_strategy, } = args; record( &effects, @@ -68,6 +71,8 @@ pub fn command_main(ctx: CommandContext, args: RecordArgs) -> EyreExitOr<()> { detach, insert, stash, + allow_empty, + untracked_file_strategy, ) } @@ -81,6 +86,8 @@ fn record( detach: bool, insert: bool, stash: bool, + allow_empty: bool, + untracked_file_strategy: Option, ) -> EyreExitOr<()> { let now = SystemTime::now(); let repo = Repo::from_dir(&git_run_info.working_directory)?; @@ -88,22 +95,50 @@ fn record( let event_log_db = EventLogDb::new(&conn)?; let event_tx_id = event_log_db.make_transaction_id(now, "record")?; - let (snapshot, working_copy_changes_type) = { + let (snapshot, working_copy_changes_type, files_to_add) = { let head_info = repo.get_head_info()?; let index = repo.get_index()?; let (snapshot, _status) = repo.get_status(effects, git_run_info, &index, &head_info, Some(event_tx_id))?; let working_copy_changes_type = snapshot.get_working_copy_changes_type()?; - match working_copy_changes_type { + let files_to_add = match working_copy_changes_type { WorkingCopyChangesType::None => { - writeln!( - effects.get_output_stream(), - "There are no changes to tracked files in the working copy to commit." - )?; - return Ok(Ok(())); + let files_to_add = if interactive { + Vec::new() + } else { + try_exit_code!(process_untracked_files( + effects, + git_run_info, + &repo, + event_tx_id, + untracked_file_strategy, + )?) + }; + + if files_to_add.is_empty() && !allow_empty { + writeln!( + effects.get_output_stream(), + "There are no changes to tracked files in the working copy to commit." + )?; + return Ok(Ok(())); + } else { + files_to_add + } + } + WorkingCopyChangesType::Unstaged | WorkingCopyChangesType::Staged if interactive => { + Vec::new() + } + WorkingCopyChangesType::Staged => Vec::new(), + WorkingCopyChangesType::Unstaged => { + try_exit_code!(process_untracked_files( + effects, + git_run_info, + &repo, + event_tx_id, + untracked_file_strategy, + )?) } - WorkingCopyChangesType::Unstaged | WorkingCopyChangesType::Staged => {} WorkingCopyChangesType::Conflicts => { writeln!( effects.get_output_stream(), @@ -115,8 +150,9 @@ fn record( )?; return Ok(Err(ExitCode(1))); } - } - (snapshot, working_copy_changes_type) + }; + + (snapshot, working_copy_changes_type, files_to_add) }; if let Some(branch_name) = branch_name { @@ -158,18 +194,40 @@ fn record( )?); } } else { + 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 + }; + // call `git add` for the untracked files to be commited + // TODO look into instead specifying these are arguments via the following call to `git commit` + // note that the docs state: "listing files as arguments to the + // commit command ..., in which case the commit will ignore changes + // staged in the index, and instead record the current content of + // the listed files (which must already be known to Git);" + // So, how would "ignore changes in the index" affect us, and also + // "which must already be known to Git"?? + let _ = git_run_info.run_direct_no_wrapping(Some(event_tx_id), &args)?; + } + let messages = if messages.is_empty() && stash { get_default_stash_message(&repo, effects, &snapshot, &working_copy_changes_type) .map(|message| vec![message])? } else { messages }; + let args = { let mut args = vec!["commit"]; args.extend(messages.iter().flat_map(|message| ["--message", message])); if working_copy_changes_type == WorkingCopyChangesType::Unstaged { args.push("--all"); } + if allow_empty { + args.push("--allow-empty"); + } args }; try_exit_code!(git_run_info.run_direct_no_wrapping(Some(event_tx_id), &args)?); diff --git a/git-branchless-record/tests/test_record.rs b/git-branchless-record/tests/test_record.rs index a005cd30b..df98f58c2 100644 --- a/git-branchless-record/tests/test_record.rs +++ b/git-branchless-record/tests/test_record.rs @@ -171,6 +171,129 @@ fn test_record_staged_changes() -> eyre::Result<()> { Ok(()) } +#[test] +fn test_record_with_new_untracked_files() -> 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")?; + git.write_file_txt("test2", "test2 contents")?; + + // index + add + // index + skip + // index + prompt + // working copy + add + // working copy + skip + // working copy + prompt + + { + let (stdout, _stderr) = git.branchless("record", &["-m", "foo", "--untracked", "add"])?; + insta::assert_snapshot!(stdout, @r###" + Including 1 new untracked file: test2.txt + [master 0ec2280] foo + 2 files changed, 2 insertions(+), 1 deletion(-) + create mode 100644 test2.txt + "###); + + let (stdout, _stderr) = git.run(&["show", "--pretty=format:", "--stat", "HEAD"])?; + insta::assert_snapshot!(&stdout, @" + test1.txt | 2 +- + test2.txt | 1 + + 2 files changed, 2 insertions(+), 1 deletion(-) + "); + } + + { + git.write_file_txt("test2", "updated contents")?; + git.write_file_txt("test3", "test3 contents")?; + + let (stdout, _stderr) = git.branchless("record", &["-m", "foo", "--untracked", "skip"])?; + insta::assert_snapshot!(stdout, @r###" + Skipping 1 new untracked file: test3.txt + [master 3fb652e] foo + 1 file changed, 1 insertion(+), 1 deletion(-) + "###); + + let (stdout, _stderr) = git.run(&["show", "--pretty=format:", "--stat", "HEAD"])?; + insta::assert_snapshot!(&stdout, @" + test2.txt | 2 +- + 1 file changed, 1 insertion(+), 1 deletion(-) + "); + } + + { + git.write_file_txt("test2", "more updated contents")?; + // test3.txt skipped because we've already seen it + git.write_file_txt("test4", "test4 contents")?; + + run_in_pty( + &git, + "record", + &["-m", "foo", "--untracked", "prompt"], + &[PtyAction::WaitUntilContains("test4"), PtyAction::Write("y")], + )?; + + let (stdout, _stderr) = git.run(&["show", "--pretty=format:", "--stat", "HEAD"])?; + insta::assert_snapshot!(&stdout, @" + test2.txt | 2 +- + test4.txt | 1 + + 2 files changed, 2 insertions(+), 1 deletion(-) + "); + } + + { + // add test5 w/o any other changes + + git.write_file_txt("test5", "test5 contents")?; + + let (stdout, _stderr) = git.branchless("record", &["-m", "foo", "--untracked", "add"])?; + insta::assert_snapshot!(stdout, @r###" + Skipping 1 previously skipped file: test3.txt + Including 1 new untracked file: test5.txt + [master cd3e2aa] foo + 1 file changed, 1 insertion(+) + create mode 100644 test5.txt + "###); + + let (stdout, _stderr) = git.run(&["show", "--pretty=format:", "--stat", "HEAD"])?; + insta::assert_snapshot!(&stdout, @" + test5.txt | 1 + + 1 file changed, 1 insertion(+) + "); + } + + { + // update test5 and add test6, then add test 6 to the index + + git.write_file_txt("test5", "test5 updated contents")?; + git.write_file_txt("test6", "test6 contents")?; + git.run(&["add", "test6.txt"])?; + + // TODO should we still report the "previously skipped" files if some + // changes are staged? + let (stdout, _stderr) = git.branchless("record", &["-m", "foo", "--untracked", "add"])?; + insta::assert_snapshot!(stdout, @r###" + [master 3db03ce] foo + 1 file changed, 1 insertion(+) + create mode 100644 test6.txt + "###); + + let (stdout, _stderr) = git.run(&["show", "--pretty=format:", "--stat", "HEAD"])?; + insta::assert_snapshot!(&stdout, @" + test6.txt | 1 + + 1 file changed, 1 insertion(+) + "); + } + + Ok(()) +} + #[test] fn test_record_staged_changes_interactive() -> eyre::Result<()> { let git = make_git()?; @@ -222,6 +345,36 @@ fn test_record_staged_changes_interactive() -> eyre::Result<()> { Ok(()) } +#[test] +fn test_record_allow_empty() -> eyre::Result<()> { + let git = make_git()?; + + if !git.supports_reference_transactions()? { + return Ok(()); + } + git.init_repo()?; + + { + let (stdout, _stderr) = git.branchless("record", &["-m", "foo", "--allow-empty"])?; + insta::assert_snapshot!(stdout, @r###" + [master 315b284] foo + "###); + } + + { + let (stdout, _stderr) = git.run(&["show"])?; + insta::assert_snapshot!(stdout, @r###" + commit 315b28467eb85c7ba0c94fac192744c043648a30 + Author: Testy McTestface + Date: Thu Oct 29 12:34:56 2020 +0000 + + foo + "###); + } + + Ok(()) +} + #[test] fn test_record_detach() -> eyre::Result<()> { let git = make_git()?; 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 139500942..88cbb7ba4 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/src/commands/amend.rs b/git-branchless/src/commands/amend.rs index 7691a7822..47560c315 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::{process_untracked_files, UntrackedFileStrategy}; +use lib::git::{ + AmendFastOptions, GitRunInfo, MaybeZeroOid, Repo, ResolvedReferenceInfo, StatusEntry, +}; use lib::try_exit_code; use lib::util::{ExitCode, EyreExitOr}; use rayon::ThreadPoolBuilder; @@ -40,6 +43,7 @@ pub fn amend( resolve_revset_options: &ResolveRevsetOptions, move_options: &MoveOptions, reparent: bool, + untracked_file_strategy: Option, ) -> EyreExitOr<()> { let now = SystemTime::now(); let timestamp = now.duration_since(SystemTime::UNIX_EPOCH)?.as_secs_f64(); @@ -131,8 +135,22 @@ pub fn amend( .collect(), } } else { + let untracked_entries = try_exit_code!(process_untracked_files( + effects, + git_run_info, + &repo, + event_tx_id, + untracked_file_strategy, + )?) + .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() { diff --git a/git-branchless/src/commands/mod.rs b/git-branchless/src/commands/mod.rs index eeb442a94..b87e813cc 100644 --- a/git-branchless/src/commands/mod.rs +++ b/git-branchless/src/commands/mod.rs @@ -35,12 +35,14 @@ fn command_main(ctx: CommandContext, opts: Opts) -> EyreExitOr<()> { Command::Amend { move_options, reparent, + untracked_file_strategy, } => amend::amend( &effects, &git_run_info, &ResolveRevsetOptions::default(), &move_options, reparent, + untracked_file_strategy, )?, Command::BugReport => bug_report::bug_report(&effects, &git_run_info)?, diff --git a/git-branchless/tests/test_amend.rs b/git-branchless/tests/test_amend.rs index 5acc7be6b..7418d93e5 100644 --- a/git-branchless/tests/test_amend.rs +++ b/git-branchless/tests/test_amend.rs @@ -1,4 +1,8 @@ -use lib::testing::{make_git, remove_rebase_lines, trim_lines, GitRunOptions}; +use lib::testing::{ + make_git, + pty::{run_in_pty, PtyAction}, + remove_rebase_lines, trim_lines, GitRunOptions, +}; #[test] fn test_amend_with_children() -> eyre::Result<()> { @@ -387,8 +391,10 @@ fn test_amend_head() -> eyre::Result<()> { git.write_file_txt("newfile", "some new file")?; { let (stdout, _stderr) = git.branchless("amend", &[])?; - insta::assert_snapshot!(stdout, @"There are no uncommitted or staged changes. Nothing to amend. -"); + insta::assert_snapshot!(stdout, @r###" + Skipping 1 new untracked file: newfile.txt + There are no uncommitted or staged changes. Nothing to amend. + "###); } git.run(&["add", "."])?; @@ -445,6 +451,126 @@ fn test_amend_head_with_file_with_space() -> eyre::Result<()> { Ok(()) } +#[test] +fn test_amend_with_new_untracked_files() -> eyre::Result<()> { + let git = make_git()?; + + if !git.supports_committer_date_is_author_date()? { + return Ok(()); + } + + git.init_repo()?; + git.detach_head()?; + git.commit_file("test1", 1)?; + + { + // initial state: only test1 + + let (stdout, _stderr) = git.run(&["show", "--pretty=format:", "--stat", "HEAD"])?; + insta::assert_snapshot!(&stdout, @" + test1.txt | 1 + + 1 file changed, 1 insertion(+) + "); + } + + // index + add => do nothing + // index + skip => do nothing + // index + prompt => do nothing + // working copy + add + // working copy + skip + // working copy + prompt + + { + // update test1, add test2 w/ "add" + + git.write_file_txt("test1", "updated contents")?; + git.write_file_txt("test2", "test2 contents")?; + + let (stdout, _stderr) = git.branchless("amend", &["--untracked", "add"])?; + insta::assert_snapshot!(stdout, @r###" + Including 1 new untracked file: test2.txt + branchless: running command: reset aa6c59fcac3c4969f5df0a29cc68227a63365d77 + Amended with 2 uncommitted changes. + "###); + + 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(+) + "); + } + + { + // update test2, add test3 w/ "skip" + + git.write_file_txt("test2", "updated contents")?; + git.write_file_txt("test3", "test3 contents")?; + + let (stdout, _stderr) = git.branchless("amend", &["--untracked", "skip"])?; + insta::assert_snapshot!(stdout, @r###" + Skipping 1 new untracked file: test3.txt + branchless: running command: reset d6dcb58abcbc9c12f90ee2c851e396298c5a0398 + Amended with 1 uncommitted change. + "###); + + 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(+) + "); + } + + { + // update test2, add test4 w/ "prompt" (and still skip test3) + + git.write_file_txt("test2", "more updated contents")?; + // test3.txt skipped because we've already seen it + git.write_file_txt("test4", "test4 contents")?; + + run_in_pty( + &git, + "amend", + &["--untracked", "prompt"], + &[PtyAction::WaitUntilContains("test4"), PtyAction::Write("y")], + )?; + + let (stdout, _stderr) = git.run(&["show", "--pretty=format:", "--stat", "HEAD"])?; + insta::assert_snapshot!(&stdout, @" + test1.txt | 1 + + test2.txt | 1 + + test4.txt | 1 + + 3 files changed, 3 insertions(+) + "); + } + + { + // add test5 w/o any other changes + + git.write_file_txt("test5", "test5 contents")?; + + let (stdout, _stderr) = git.branchless("amend", &["--untracked", "add"])?; + insta::assert_snapshot!(stdout, @r###" + Skipping 1 previously skipped file: test3.txt + Including 1 new untracked file: test5.txt + branchless: running command: reset 5b8428fe65cafd33dd518e91b5025316e82f7302 + Amended with 1 uncommitted change. + "###); + + let (stdout, _stderr) = git.run(&["show", "--pretty=format:", "--stat", "HEAD"])?; + insta::assert_snapshot!(&stdout, @" + test1.txt | 1 + + test2.txt | 1 + + test4.txt | 1 + + test5.txt | 1 + + 4 files changed, 4 insertions(+) + "); + } + + Ok(()) +} + #[test] #[cfg(unix)] fn test_amend_executable() -> eyre::Result<()> {