Skip to main content

branchless/core/
untracked_file_cache.rs

1//! Utilities to fetch, confirm and save a list of untracked files, so we can
2//! prompt the user about them.
3
4use 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/// How to handle untracked files when creating/amending commits.
21#[derive(Clone, Copy, Debug, ValueEnum)]
22pub enum UntrackedFileStrategy {
23    /// Add all untracked files.
24    Add,
25    /// Disable all untracked file checking and processing.
26    Disable,
27    /// Prompt the user about how to handle each untracked file.
28    Prompt,
29    /// Skip all untracked files.
30    Skip,
31}
32
33/// Process untracked files according to the given or configured strategy.
34/// Returns a list of files in the current repo that should be added to the
35/// commit being processed by amend or record.
36///
37/// Note: may block while prompting for input, if such prompts are requested by
38/// the strategy.
39#[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        // earliest possible return to avoid hitting disk, db, etc
82        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/// Get a list of all untracked files that currently exist on disk.
262#[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/// Get a list of all untracked files that we have cached in the database. This
289/// should be the list of all untracked files that existed on disk when we last
290/// checked.
291#[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/// Persist a snapshot of existent, untracked files in the database.
304#[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/// Ensure the untracked_files table exists; creating it if it does not.
341#[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}