Skip to content

Commit 967c4e8

Browse files
feat(record): add untracked file detection
1 parent 9f51e5f commit 967c4e8

File tree

11 files changed

+663
-15
lines changed

11 files changed

+663
-15
lines changed

Cargo.lock

Lines changed: 1 addition & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

git-branchless-lib/Cargo.toml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -48,6 +48,7 @@ async-trait = { workspace = true }
4848
bstr = { workspace = true }
4949
chashmap = { workspace = true }
5050
chrono = { workspace = true }
51+
clap = { workspace = true }
5152
color-eyre = { workspace = true }
5253
concolor = { workspace = true }
5354
console = { workspace = true }

git-branchless-lib/src/core/mod.rs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,3 +11,4 @@ pub mod node_descriptors;
1111
pub mod repo_ext;
1212
pub mod rewrite;
1313
pub mod task;
14+
pub mod untracked_file_cache;
Lines changed: 311 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,311 @@
1+
//! Utilities to fetch, confirm and save a list of untracked files, so we can
2+
//! prompt the user about them.
3+
4+
use clap::ValueEnum;
5+
use console::{Key, Term};
6+
use cursive::theme::BaseColor;
7+
use eyre::Context;
8+
use itertools::Itertools;
9+
use std::io::Write as IoWrite;
10+
use std::time::SystemTime;
11+
use std::{collections::HashSet, fmt::Write};
12+
use tracing::instrument;
13+
14+
use super::{effects::Effects, eventlog::EventTransactionId, formatting::Pluralize};
15+
use crate::core::formatting::StyledStringBuilder;
16+
use crate::git::{ConfigRead, GitRunInfo, Repo};
17+
use crate::util::{ExitCode, EyreExitOr};
18+
19+
/// How to handle untracked files when creating/amending commits.
20+
#[derive(Clone, Copy, Debug, ValueEnum)]
21+
pub enum UntrackedFileStrategy {
22+
/// Add all untracked files.
23+
Add,
24+
/// Prompt the user about how to handle each untracked file.
25+
Prompt,
26+
/// Skip all untracked files.
27+
Skip,
28+
}
29+
30+
/// TODO
31+
#[instrument]
32+
pub fn process_untracked_files(
33+
effects: &Effects,
34+
git_run_info: &GitRunInfo,
35+
repo: &Repo,
36+
event_tx_id: EventTransactionId,
37+
strategy: Option<UntrackedFileStrategy>,
38+
) -> EyreExitOr<Vec<String>> {
39+
let conn = repo.get_db_conn()?;
40+
41+
let strategy = match strategy {
42+
Some(strategy) => strategy,
43+
None => {
44+
let strategy_config_key = "branchless.record.untrackedFiles";
45+
let config = repo.get_readonly_config()?;
46+
let strategy: Option<String> = config.get(strategy_config_key)?;
47+
match strategy {
48+
None => UntrackedFileStrategy::Skip,
49+
Some(strategy) => match UntrackedFileStrategy::from_str(&strategy, true) {
50+
Ok(strategy) => strategy,
51+
Err(_) => {
52+
writeln!(
53+
effects.get_output_stream(),
54+
"Invalid value for config value {strategy_config_key}: {strategy}"
55+
)?;
56+
writeln!(
57+
effects.get_output_stream(),
58+
"Expected one of: {}",
59+
UntrackedFileStrategy::value_variants()
60+
.iter()
61+
.filter_map(|variant| variant.to_possible_value())
62+
.map(|value| value.get_name().to_owned())
63+
.join(", ")
64+
)?;
65+
return Ok(Err(ExitCode(1)));
66+
}
67+
},
68+
}
69+
}
70+
};
71+
72+
let cached_files = get_cached_untracked_files(&conn)?;
73+
let real_files = get_real_untracked_files(repo, event_tx_id, git_run_info)?;
74+
let new_files: Vec<String> = real_files.difference(&cached_files).cloned().collect();
75+
let previously_skipped_files: Vec<String> =
76+
real_files.intersection(&cached_files).cloned().collect();
77+
78+
cache_untracked_files(&conn, real_files)?;
79+
80+
if !previously_skipped_files.is_empty() {
81+
writeln!(
82+
effects.get_output_stream(),
83+
"Skipping {}: {}",
84+
Pluralize {
85+
determiner: None,
86+
amount: previously_skipped_files.len(),
87+
unit: ("previously skipped file", "previously skipped files"),
88+
},
89+
render_styled(effects, previously_skipped_files.join(", "),)
90+
)?;
91+
}
92+
93+
if new_files.is_empty() {
94+
return Ok(Ok(vec![]));
95+
}
96+
97+
let files_to_add = match strategy {
98+
UntrackedFileStrategy::Add => {
99+
writeln!(
100+
effects.get_output_stream(),
101+
"Including {}: {}",
102+
Pluralize {
103+
determiner: None,
104+
amount: new_files.len(),
105+
unit: ("new untracked file", "new untracked files"),
106+
},
107+
new_files.join(", ")
108+
)?;
109+
110+
new_files
111+
}
112+
113+
UntrackedFileStrategy::Skip => {
114+
writeln!(
115+
effects.get_output_stream(),
116+
"Skipping {}: {}",
117+
Pluralize {
118+
determiner: None,
119+
amount: new_files.len(),
120+
unit: ("new untracked file", "new untracked files"),
121+
},
122+
render_styled(effects, new_files.join(", "),)
123+
)?;
124+
// TODO "These files will always be skipped. To add them, use `git add`"
125+
// TODO make this a hint? configurable to be off?
126+
127+
vec![]
128+
}
129+
130+
UntrackedFileStrategy::Prompt => {
131+
let mut files_to_add = vec![];
132+
let mut skip_remaining = false;
133+
writeln!(
134+
effects.get_output_stream(),
135+
"Found {}:",
136+
Pluralize {
137+
determiner: None,
138+
amount: new_files.len(),
139+
unit: ("new untracked file", "new untracked files"),
140+
},
141+
)?;
142+
'file_loop: for file in new_files {
143+
if skip_remaining {
144+
writeln!(effects.get_output_stream(), " Skipping file '{file}'")?;
145+
continue 'file_loop;
146+
}
147+
148+
'prompt_loop: loop {
149+
write!(
150+
effects.get_output_stream(),
151+
" Include file '{file}'? {} ",
152+
render_styled(effects, "[Yes/(N)o/nOne/Help]".to_string())
153+
)?;
154+
std::io::stdout().flush()?;
155+
156+
let term = Term::stderr();
157+
'tty_input_loop: loop {
158+
let key = term.read_key()?;
159+
match key {
160+
Key::Char('y') | Key::Char('Y') => {
161+
files_to_add.push(file.clone());
162+
writeln!(
163+
effects.get_output_stream(),
164+
"{}",
165+
render_styled(effects, "adding".to_string())
166+
)?;
167+
}
168+
169+
Key::Char('n') | Key::Char('N') | Key::Enter => {
170+
writeln!(
171+
effects.get_output_stream(),
172+
"{}",
173+
render_styled(effects, "not adding".to_string())
174+
)?;
175+
}
176+
177+
Key::Char('o') | Key::Char('O') => {
178+
skip_remaining = true;
179+
writeln!(
180+
effects.get_output_stream(),
181+
"{}",
182+
render_styled(effects, "skipping remaining".to_string())
183+
)?;
184+
}
185+
186+
Key::Char('h') | Key::Char('H') | Key::Char('?') => {
187+
writeln!(
188+
effects.get_output_stream(),
189+
"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"
190+
)?;
191+
continue 'prompt_loop;
192+
}
193+
194+
_ => continue 'tty_input_loop,
195+
};
196+
continue 'file_loop;
197+
}
198+
}
199+
}
200+
201+
files_to_add
202+
}
203+
};
204+
205+
Ok(Ok(files_to_add))
206+
}
207+
208+
fn render_styled(effects: &Effects, string_to_render: String) -> String {
209+
effects
210+
.get_glyphs()
211+
.render(
212+
StyledStringBuilder::new()
213+
.append_styled(string_to_render, BaseColor::Black.light())
214+
.build(),
215+
)
216+
.expect("rendering styled string")
217+
}
218+
219+
/// TODO
220+
#[instrument]
221+
fn get_real_untracked_files(
222+
repo: &Repo,
223+
event_tx_id: EventTransactionId,
224+
git_run_info: &GitRunInfo,
225+
) -> eyre::Result<HashSet<String>> {
226+
let args = vec!["ls-files", "--others", "--exclude-standard", "-z"];
227+
let files_str = git_run_info
228+
.run_silent(repo, Some(event_tx_id), &args, Default::default())
229+
.wrap_err("calling `git ls-files`")?
230+
.stdout;
231+
let files_str = String::from_utf8(files_str).wrap_err("Decoding stdout from Git subprocess")?;
232+
let files = files_str
233+
.trim()
234+
.split('\0')
235+
.filter_map(|s| {
236+
if s.is_empty() {
237+
None
238+
} else {
239+
Some(s.to_owned())
240+
}
241+
})
242+
.collect();
243+
Ok(files)
244+
}
245+
246+
/// TODO
247+
#[instrument]
248+
fn cache_untracked_files(conn: &rusqlite::Connection, files: HashSet<String>) -> eyre::Result<()> {
249+
{
250+
conn.execute("DROP TABLE IF EXISTS untracked_files", rusqlite::params![])
251+
.wrap_err("Removing `untracked_files` table")?;
252+
}
253+
254+
init_untracked_files_table(conn)?;
255+
256+
{
257+
let tx = conn.unchecked_transaction()?;
258+
259+
let timestamp = SystemTime::now()
260+
.duration_since(SystemTime::UNIX_EPOCH)
261+
.wrap_err("Calculating event transaction timestamp")?
262+
.as_secs_f64();
263+
for file in files {
264+
tx.execute(
265+
"
266+
INSERT INTO untracked_files
267+
(timestamp, file)
268+
VALUES
269+
(:timestamp, :file)
270+
",
271+
rusqlite::named_params! {
272+
":timestamp": timestamp,
273+
":file": file,
274+
},
275+
)?;
276+
}
277+
tx.commit()?;
278+
}
279+
280+
Ok(())
281+
}
282+
283+
/// Ensure the untracked_files table exists; creating it if it does not.
284+
#[instrument]
285+
fn init_untracked_files_table(conn: &rusqlite::Connection) -> eyre::Result<()> {
286+
conn.execute(
287+
"
288+
CREATE TABLE IF NOT EXISTS untracked_files (
289+
timestamp REAL NOT NULL,
290+
file TEXT NOT NULL
291+
)
292+
",
293+
rusqlite::params![],
294+
)
295+
.wrap_err("Creating `untracked_files` table")?;
296+
297+
Ok(())
298+
}
299+
300+
/// TODO
301+
#[instrument]
302+
pub fn get_cached_untracked_files(conn: &rusqlite::Connection) -> eyre::Result<HashSet<String>> {
303+
init_untracked_files_table(conn)?;
304+
305+
let mut stmt = conn.prepare("SELECT file FROM untracked_files")?;
306+
let paths = stmt
307+
.query_map(rusqlite::named_params![], |row| row.get("file"))?
308+
.filter_map(|p| p.ok())
309+
.collect();
310+
Ok(paths)
311+
}

git-branchless-lib/src/git/status.rs

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -191,6 +191,17 @@ pub struct StatusEntry {
191191
}
192192

193193
impl StatusEntry {
194+
/// Create a status entry for a currently-untracked, to-be-added file.
195+
pub fn new_untracked(filename: String) -> Self {
196+
StatusEntry {
197+
index_status: FileStatus::Untracked,
198+
working_copy_status: FileStatus::Untracked,
199+
working_copy_file_mode: FileMode::Blob,
200+
path: PathBuf::from(filename),
201+
orig_path: None,
202+
}
203+
}
204+
194205
/// Returns the paths associated with the status entry.
195206
pub fn paths(&self) -> Vec<PathBuf> {
196207
let mut result = vec![self.path.clone()];

git-branchless-opts/src/lib.rs

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@ use std::path::{Path, PathBuf};
1818
use std::str::FromStr;
1919

2020
use clap::{Args, Command as ClapCommand, CommandFactory, Parser, ValueEnum};
21+
use lib::core::untracked_file_cache::UntrackedFileStrategy;
2122
use lib::git::NonZeroOid;
2223

2324
/// A revset expression. Can be a commit hash, branch name, or one of the
@@ -329,6 +330,10 @@ pub struct RecordArgs {
329330
/// After making the new commit, switch back to the previous commit.
330331
#[clap(action, short = 's', long = "stash", conflicts_with_all(&["create", "detach"]))]
331332
pub stash: bool,
333+
334+
/// How should newly encountered, untracked files be handled?
335+
#[clap(value_parser, long = "untracked", conflicts_with_all(&["interactive"]))]
336+
pub untracked_file_strategy: Option<UntrackedFileStrategy>,
332337
}
333338

334339
/// Display a nice graph of the commits you've recently worked on.
@@ -448,6 +453,10 @@ pub enum Command {
448453
/// formatting or refactoring changes.
449454
#[clap(long)]
450455
reparent: bool,
456+
457+
/// How should newly encountered, untracked files be handled?
458+
#[clap(action, long = "untracked")]
459+
untracked_file_strategy: Option<UntrackedFileStrategy>,
451460
},
452461

453462
/// Gather information about recent operations to upload as part of a bug

0 commit comments

Comments
 (0)