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