Skip to content

Commit 4d2409d

Browse files
feat(record): add untracked file detection
1 parent 79cd02d commit 4d2409d

File tree

11 files changed

+633
-13
lines changed

11 files changed

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

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)