Skip to content

Commit ba6f87b

Browse files
feat(record): add untracked file detection
1 parent d5d0935 commit ba6f87b

File tree

12 files changed

+720
-15
lines changed

12 files changed

+720
-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/config.rs

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -250,6 +250,10 @@ pub const RESTACK_WARN_ABANDONED_CONFIG_KEY: &str = "branchless.restack.warnAban
250250
/// Possible hint types.
251251
#[derive(Clone, Debug)]
252252
pub enum Hint {
253+
/// Suggest running `git add` on skipped, untracked files, which are never
254+
/// automatically reconsidered for tracking.
255+
AddSkippedFiles,
256+
253257
/// Suggest running `git test clean` in order to clean cached test results.
254258
CleanCachedTestResults,
255259

@@ -269,6 +273,7 @@ pub enum Hint {
269273
impl Hint {
270274
fn get_config_key(&self) -> &'static str {
271275
match self {
276+
Hint::AddSkippedFiles => "branchless.hint.addSkippedFiles",
272277
Hint::CleanCachedTestResults => "branchless.hint.cleanCachedTestResults",
273278
Hint::MoveImplicitHeadArgument => "branchless.hint.moveImplicitHeadArgument",
274279
Hint::RestackWarnAbandoned => "branchless.hint.restackWarnAbandoned",

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: 349 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,349 @@
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::config::{Hint, get_hint_enabled, get_hint_string, print_hint_suppression_notice};
16+
use crate::core::formatting::StyledStringBuilder;
17+
use crate::git::{ConfigRead, GitRunInfo, Repo};
18+
use crate::util::{ExitCode, EyreExitOr};
19+
20+
/// How to handle untracked files when creating/amending commits.
21+
#[derive(Clone, Copy, Debug, ValueEnum)]
22+
pub enum UntrackedFileStrategy {
23+
/// Add all untracked files.
24+
Add,
25+
/// Prompt the user about how to handle each untracked file.
26+
Prompt,
27+
/// Skip all untracked files.
28+
Skip,
29+
/// Disable all untracked file checking and processing.
30+
Disable,
31+
}
32+
33+
/// Process untracked files according to the given or configured strategy. Returns a list of
34+
/// files in the current repo that should be added to the commit being processed by amend
35+
/// or record.
36+
///
37+
/// Note: may block while prompting for input, if such prompts are requested by the strategy.
38+
#[instrument]
39+
pub fn process_untracked_files(
40+
effects: &Effects,
41+
git_run_info: &GitRunInfo,
42+
repo: &Repo,
43+
event_tx_id: EventTransactionId,
44+
strategy: Option<UntrackedFileStrategy>,
45+
) -> EyreExitOr<Vec<String>> {
46+
let conn = repo.get_db_conn()?;
47+
48+
let strategy = match strategy {
49+
Some(strategy) => strategy,
50+
None => {
51+
let strategy_config_key = "branchless.record.untrackedFiles";
52+
let config = repo.get_readonly_config()?;
53+
let strategy: Option<String> = config.get(strategy_config_key)?;
54+
match strategy {
55+
None => UntrackedFileStrategy::Skip,
56+
Some(strategy) => match UntrackedFileStrategy::from_str(&strategy, true) {
57+
Ok(strategy) => strategy,
58+
Err(_) => {
59+
writeln!(
60+
effects.get_output_stream(),
61+
"Invalid value for config value {strategy_config_key}: {strategy}"
62+
)?;
63+
writeln!(
64+
effects.get_output_stream(),
65+
"Expected one of: {}",
66+
UntrackedFileStrategy::value_variants()
67+
.iter()
68+
.filter_map(|variant| variant.to_possible_value())
69+
.map(|value| value.get_name().to_owned())
70+
.join(", ")
71+
)?;
72+
return Ok(Err(ExitCode(1)));
73+
}
74+
},
75+
}
76+
}
77+
};
78+
79+
if let UntrackedFileStrategy::Disable = strategy {
80+
// earliest possible return to avoid hitting disk, db, etc
81+
return Ok(Ok(Vec::new()));
82+
}
83+
84+
let cached_files = get_cached_untracked_files(&conn)?;
85+
let real_files = get_real_untracked_files(repo, event_tx_id, git_run_info)?;
86+
let new_files: Vec<String> = real_files.difference(&cached_files).cloned().collect();
87+
let previously_skipped_files: Vec<String> =
88+
real_files.intersection(&cached_files).cloned().collect();
89+
90+
cache_untracked_files(&conn, real_files)?;
91+
92+
if !previously_skipped_files.is_empty() {
93+
writeln!(
94+
effects.get_output_stream(),
95+
"Skipping {}: {}",
96+
Pluralize {
97+
determiner: None,
98+
amount: previously_skipped_files.len(),
99+
unit: ("previously skipped file", "previously skipped files"),
100+
},
101+
render_styled(effects, previously_skipped_files.join(", "),)
102+
)?;
103+
}
104+
105+
if new_files.is_empty() {
106+
return Ok(Ok(Vec::new()));
107+
}
108+
109+
let files_to_add = match strategy {
110+
UntrackedFileStrategy::Disable => unreachable!(),
111+
112+
UntrackedFileStrategy::Add => {
113+
writeln!(
114+
effects.get_output_stream(),
115+
"Including {}: {}",
116+
Pluralize {
117+
determiner: None,
118+
amount: new_files.len(),
119+
unit: ("new untracked file", "new untracked files"),
120+
},
121+
new_files.join(", ")
122+
)?;
123+
124+
new_files
125+
}
126+
127+
UntrackedFileStrategy::Skip => {
128+
writeln!(
129+
effects.get_output_stream(),
130+
"Skipping {}: {}",
131+
Pluralize {
132+
determiner: None,
133+
amount: new_files.len(),
134+
unit: ("new untracked file", "new untracked files"),
135+
},
136+
render_styled(effects, new_files.join(", "),)
137+
)?;
138+
139+
if get_hint_enabled(repo, Hint::AddSkippedFiles)? {
140+
writeln!(
141+
effects.get_output_stream(),
142+
"{}: {} will remain skipped and will not be automatically reconsidered",
143+
effects.get_glyphs().render(get_hint_string())?,
144+
if new_files.len() == 1 {
145+
"this file"
146+
} else {
147+
"these files"
148+
},
149+
)?;
150+
writeln!(
151+
effects.get_output_stream(),
152+
"{}: to add {} yourself: git add",
153+
effects.get_glyphs().render(get_hint_string())?,
154+
if new_files.len() == 1 { "it" } else { "them" },
155+
)?;
156+
print_hint_suppression_notice(effects, Hint::AddSkippedFiles)?;
157+
}
158+
159+
Vec::new()
160+
}
161+
162+
UntrackedFileStrategy::Prompt => {
163+
let mut files_to_add = Vec::new();
164+
let mut skip_remaining = false;
165+
writeln!(
166+
effects.get_output_stream(),
167+
"Found {}:",
168+
Pluralize {
169+
determiner: None,
170+
amount: new_files.len(),
171+
unit: ("new untracked file", "new untracked files"),
172+
},
173+
)?;
174+
'file_loop: for file in new_files {
175+
if skip_remaining {
176+
writeln!(effects.get_output_stream(), " Skipping file '{file}'")?;
177+
continue 'file_loop;
178+
}
179+
180+
'prompt_loop: loop {
181+
write!(
182+
effects.get_output_stream(),
183+
" Include file '{file}'? {} ",
184+
render_styled(effects, "[Yes/(N)o/nOne/Help]".to_string())
185+
)?;
186+
std::io::stdout().flush()?;
187+
188+
let term = Term::stderr();
189+
'tty_input_loop: loop {
190+
let key = term.read_key()?;
191+
match key {
192+
Key::Char('y') | Key::Char('Y') => {
193+
files_to_add.push(file.clone());
194+
writeln!(
195+
effects.get_output_stream(),
196+
"{}",
197+
render_styled(effects, "adding".to_string())
198+
)?;
199+
}
200+
201+
Key::Char('n') | Key::Char('N') | Key::Enter => {
202+
writeln!(
203+
effects.get_output_stream(),
204+
"{}",
205+
render_styled(effects, "not adding".to_string())
206+
)?;
207+
}
208+
209+
Key::Char('o') | Key::Char('O') => {
210+
skip_remaining = true;
211+
writeln!(
212+
effects.get_output_stream(),
213+
"{}",
214+
render_styled(effects, "skipping remaining".to_string())
215+
)?;
216+
}
217+
218+
Key::Char('h') | Key::Char('H') | Key::Char('?') => {
219+
writeln!(
220+
effects.get_output_stream(),
221+
"help\n\n\
222+
- y/Y: include the file\n\
223+
- n/N/<enter>: skip the file\n\
224+
- o/O: skip the file and all subsequent files\n\
225+
- h/H/?: show this help message\n\
226+
"
227+
)?;
228+
continue 'prompt_loop;
229+
}
230+
231+
_ => continue 'tty_input_loop,
232+
};
233+
continue 'file_loop;
234+
}
235+
}
236+
}
237+
238+
files_to_add
239+
}
240+
};
241+
242+
Ok(Ok(files_to_add))
243+
}
244+
245+
fn render_styled(effects: &Effects, string_to_render: String) -> String {
246+
effects
247+
.get_glyphs()
248+
.render(
249+
StyledStringBuilder::new()
250+
.append_styled(string_to_render, BaseColor::Black.light())
251+
.build(),
252+
)
253+
.expect("rendering styled string")
254+
}
255+
256+
/// Get a list of all untracked files that currently exist on disk.
257+
#[instrument]
258+
fn get_real_untracked_files(
259+
repo: &Repo,
260+
event_tx_id: EventTransactionId,
261+
git_run_info: &GitRunInfo,
262+
) -> eyre::Result<HashSet<String>> {
263+
let args = vec!["ls-files", "--others", "--exclude-standard", "-z"];
264+
let files_str = git_run_info
265+
.run_silent(repo, Some(event_tx_id), &args, Default::default())
266+
.wrap_err("calling `git ls-files`")?
267+
.stdout;
268+
let files_str = String::from_utf8(files_str).wrap_err("Decoding stdout from Git subprocess")?;
269+
let files = files_str
270+
.trim()
271+
.split('\0')
272+
.filter_map(|s| {
273+
if s.is_empty() {
274+
None
275+
} else {
276+
Some(s.to_owned())
277+
}
278+
})
279+
.collect();
280+
Ok(files)
281+
}
282+
283+
/// Persist a snapshot of existent, untracked files in the database.
284+
#[instrument]
285+
fn cache_untracked_files(conn: &rusqlite::Connection, files: HashSet<String>) -> eyre::Result<()> {
286+
{
287+
conn.execute("DROP TABLE IF EXISTS untracked_files", rusqlite::params![])
288+
.wrap_err("Removing `untracked_files` table")?;
289+
}
290+
291+
init_untracked_files_table(conn)?;
292+
293+
{
294+
let tx = conn.unchecked_transaction()?;
295+
296+
let timestamp = SystemTime::now()
297+
.duration_since(SystemTime::UNIX_EPOCH)
298+
.wrap_err("Calculating event transaction timestamp")?
299+
.as_secs_f64();
300+
for file in files {
301+
tx.execute(
302+
"
303+
INSERT INTO untracked_files
304+
(timestamp, file)
305+
VALUES
306+
(:timestamp, :file)
307+
",
308+
rusqlite::named_params! {
309+
":timestamp": timestamp,
310+
":file": file,
311+
},
312+
)?;
313+
}
314+
tx.commit()?;
315+
}
316+
317+
Ok(())
318+
}
319+
320+
/// Ensure the untracked_files table exists; creating it if it does not.
321+
#[instrument]
322+
fn init_untracked_files_table(conn: &rusqlite::Connection) -> eyre::Result<()> {
323+
conn.execute(
324+
"
325+
CREATE TABLE IF NOT EXISTS untracked_files (
326+
timestamp REAL NOT NULL,
327+
file TEXT NOT NULL
328+
)
329+
",
330+
rusqlite::params![],
331+
)
332+
.wrap_err("Creating `untracked_files` table")?;
333+
334+
Ok(())
335+
}
336+
337+
/// Get a list of all untracked files that we have cached in the database. This should be
338+
/// the list of all untracked files that existed on disk when we last checked.
339+
#[instrument]
340+
pub fn get_cached_untracked_files(conn: &rusqlite::Connection) -> eyre::Result<HashSet<String>> {
341+
init_untracked_files_table(conn)?;
342+
343+
let mut stmt = conn.prepare("SELECT file FROM untracked_files")?;
344+
let paths = stmt
345+
.query_map(rusqlite::named_params![], |row| row.get("file"))?
346+
.filter_map(|p| p.ok())
347+
.collect();
348+
Ok(paths)
349+
}

0 commit comments

Comments
 (0)