Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions git-branchless-lib/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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 }
Expand Down
1 change: 1 addition & 0 deletions git-branchless-lib/src/core/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -11,3 +11,4 @@ pub mod node_descriptors;
pub mod repo_ext;
pub mod rewrite;
pub mod task;
pub mod untracked_file_cache;
311 changes: 311 additions & 0 deletions git-branchless-lib/src/core/untracked_file_cache.rs
Original file line number Diff line number Diff line change
@@ -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<UntrackedFileStrategy>,
) -> EyreExitOr<Vec<String>> {
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<String> = 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<String> = real_files.difference(&cached_files).cloned().collect();
let previously_skipped_files: Vec<String> =
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/<enter>: 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<HashSet<String>> {
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<String>) -> 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<HashSet<String>> {
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)
}
11 changes: 11 additions & 0 deletions git-branchless-lib/src/git/status.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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<PathBuf> {
let mut result = vec![self.path.clone()];
Expand Down
13 changes: 13 additions & 0 deletions git-branchless-opts/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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<UntrackedFileStrategy>,
}

/// Display a nice graph of the commits you've recently worked on.
Expand Down Expand Up @@ -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<UntrackedFileStrategy>,
},

/// Gather information about recent operations to upload as part of a bug
Expand Down
Loading
Loading