Skip to content

Commit d33849d

Browse files
feat(record): add untracked file detection
1 parent 7d7eb8e commit d33849d

File tree

5 files changed

+207
-2
lines changed

5 files changed

+207
-2
lines changed

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: 166 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,166 @@
1+
//! Utilities to fetch, confirm and save a list of untracked files, so we can
2+
//! prompt the user about them.
3+
4+
use console::{Key, Term};
5+
use eyre::Context;
6+
use std::io::Write as IoWrite;
7+
use std::time::SystemTime;
8+
use std::{collections::HashSet, fmt::Write};
9+
use tracing::instrument;
10+
11+
use crate::git::{GitRunInfo, Repo};
12+
13+
use super::{effects::Effects, eventlog::EventTransactionId, formatting::Pluralize};
14+
15+
/// TODO
16+
#[instrument]
17+
pub fn prompt_about_untracked_files(
18+
effects: &Effects,
19+
git_run_info: &GitRunInfo,
20+
repo: &Repo,
21+
event_tx_id: EventTransactionId,
22+
) -> eyre::Result<Vec<String>> {
23+
let conn = repo.get_db_conn()?;
24+
25+
let cached_files = get_cached_untracked_files(&conn)?;
26+
let real_files = get_real_untracked_files(repo, event_tx_id, git_run_info)?;
27+
let new_files: Vec<&String> = real_files.difference(&cached_files).collect();
28+
29+
let mut files_to_add = vec![];
30+
if !new_files.is_empty() {
31+
writeln!(
32+
effects.get_output_stream(),
33+
"Found {}:",
34+
Pluralize {
35+
determiner: None,
36+
amount: new_files.len(),
37+
unit: ("new untracked file", "new untracked files"),
38+
},
39+
)?;
40+
'outer: for file in new_files {
41+
write!(
42+
effects.get_output_stream(),
43+
" Add file '{file}'? [Yes/(N)o/nOne] "
44+
)?;
45+
std::io::stdout().flush()?;
46+
47+
let term = Term::stderr();
48+
'inner: loop {
49+
let key = term.read_key()?;
50+
match key {
51+
Key::Char('y') | Key::Char('Y') => {
52+
files_to_add.push(file.clone());
53+
writeln!(effects.get_output_stream(), "adding")?;
54+
}
55+
Key::Char('n') | Key::Char('N') | Key::Enter => {
56+
writeln!(effects.get_output_stream(), "not adding")?;
57+
}
58+
Key::Char('o') | Key::Char('O') => {
59+
writeln!(effects.get_output_stream(), "skipping remaining")?;
60+
break 'outer;
61+
}
62+
_ => continue 'inner,
63+
};
64+
continue 'outer;
65+
}
66+
}
67+
}
68+
69+
cache_untracked_files(&conn, real_files)?;
70+
71+
Ok(files_to_add)
72+
}
73+
74+
/// TODO
75+
#[instrument]
76+
fn get_real_untracked_files(
77+
repo: &Repo,
78+
event_tx_id: EventTransactionId,
79+
git_run_info: &GitRunInfo,
80+
) -> eyre::Result<HashSet<String>> {
81+
let args = vec!["ls-files", "--others", "--exclude-standard", "-z"];
82+
let files_str = git_run_info
83+
.run_silent(repo, Some(event_tx_id), &args, Default::default())
84+
.wrap_err("calling `git ls-files`")?
85+
.stdout;
86+
let files_str = String::from_utf8(files_str).wrap_err("Decoding stdout from Git subprocess")?;
87+
let files = files_str
88+
.trim()
89+
.split('\0')
90+
.filter_map(|s| {
91+
if s.is_empty() {
92+
None
93+
} else {
94+
Some(s.to_owned())
95+
}
96+
})
97+
.collect();
98+
Ok(files)
99+
}
100+
101+
/// TODO
102+
#[instrument]
103+
fn cache_untracked_files(conn: &rusqlite::Connection, files: HashSet<String>) -> eyre::Result<()> {
104+
{
105+
conn.execute("DROP TABLE IF EXISTS untracked_files", rusqlite::params![])
106+
.wrap_err("Removing `untracked_files` table")?;
107+
}
108+
109+
init_untracked_files_table(conn)?;
110+
111+
{
112+
let tx = conn.unchecked_transaction()?;
113+
114+
let timestamp = SystemTime::now()
115+
.duration_since(SystemTime::UNIX_EPOCH)
116+
.wrap_err("Calculating event transaction timestamp")?
117+
.as_secs_f64();
118+
for file in files {
119+
tx.execute(
120+
"
121+
INSERT INTO untracked_files
122+
(timestamp, file)
123+
VALUES
124+
(:timestamp, :file)
125+
",
126+
rusqlite::named_params! {
127+
":timestamp": timestamp,
128+
":file": file,
129+
},
130+
)?;
131+
}
132+
tx.commit()?;
133+
}
134+
135+
Ok(())
136+
}
137+
138+
/// Ensure the untracked_files table exists; creating it if it does not.
139+
#[instrument]
140+
fn init_untracked_files_table(conn: &rusqlite::Connection) -> eyre::Result<()> {
141+
conn.execute(
142+
"
143+
CREATE TABLE IF NOT EXISTS untracked_files (
144+
timestamp REAL NOT NULL,
145+
file TEXT NOT NULL
146+
)
147+
",
148+
rusqlite::params![],
149+
)
150+
.wrap_err("Creating `untracked_files` table")?;
151+
152+
Ok(())
153+
}
154+
155+
/// TODO
156+
#[instrument]
157+
pub fn get_cached_untracked_files(conn: &rusqlite::Connection) -> eyre::Result<HashSet<String>> {
158+
init_untracked_files_table(conn)?;
159+
160+
let mut stmt = conn.prepare("SELECT file FROM untracked_files")?;
161+
let paths = stmt
162+
.query_map(rusqlite::named_params![], |row| row.get("file"))?
163+
.filter_map(|p| p.ok())
164+
.collect();
165+
Ok(paths)
166+
}

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-record/src/lib.rs

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,7 @@ use lib::core::rewrite::{
3030
ExecuteRebasePlanResult, MergeConflictRemediation, RebasePlanBuilder, RebasePlanPermissions,
3131
RepoResource,
3232
};
33+
use lib::core::untracked_file_cache::prompt_about_untracked_files;
3334
use lib::git::{
3435
process_diff_for_record, summarize_diff_for_temporary_commit, update_index,
3536
CategorizedReferenceName, FileMode, GitRunInfo, MaybeZeroOid, NonZeroOid, Repo,
@@ -97,6 +98,7 @@ fn record(
9798
let working_copy_changes_type = snapshot.get_working_copy_changes_type()?;
9899
match working_copy_changes_type {
99100
WorkingCopyChangesType::None => {
101+
// FIXME look for new untracked files
100102
writeln!(
101103
effects.get_output_stream(),
102104
"There are no changes to tracked files in the working copy to commit."
@@ -158,6 +160,18 @@ fn record(
158160
)?);
159161
}
160162
} else {
163+
let files_to_add = prompt_about_untracked_files(effects, git_run_info, &repo, event_tx_id)?;
164+
if !files_to_add.is_empty() {
165+
let args = {
166+
let mut args = vec!["add".to_string()];
167+
// use repo-canonical paths even if adding in a repo subdir
168+
args.extend(files_to_add.iter().map(|p| format!(":/{p}")));
169+
args
170+
};
171+
// FIXME
172+
let _ = git_run_info.run_direct_no_wrapping(Some(event_tx_id), &args)?;
173+
}
174+
161175
let messages = if messages.is_empty() && stash {
162176
let diff_stats = {
163177
let (old_tree, new_tree) = match working_copy_changes_type {
@@ -193,6 +207,7 @@ fn record(
193207
} else {
194208
messages
195209
};
210+
196211
let args = {
197212
let mut args = vec!["commit"];
198213
args.extend(messages.iter().flat_map(|message| ["--message", message]));

git-branchless/src/commands/amend.rs

Lines changed: 14 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -26,7 +26,10 @@ use lib::core::rewrite::{
2626
execute_rebase_plan, move_branches, BuildRebasePlanOptions, ExecuteRebasePlanOptions,
2727
ExecuteRebasePlanResult, RebasePlanBuilder, RebasePlanPermissions, RepoResource,
2828
};
29-
use lib::git::{AmendFastOptions, GitRunInfo, MaybeZeroOid, Repo, ResolvedReferenceInfo};
29+
use lib::core::untracked_file_cache::prompt_about_untracked_files;
30+
use lib::git::{
31+
AmendFastOptions, GitRunInfo, MaybeZeroOid, Repo, ResolvedReferenceInfo, StatusEntry,
32+
};
3033
use lib::try_exit_code;
3134
use lib::util::{ExitCode, EyreExitOr};
3235
use rayon::ThreadPoolBuilder;
@@ -131,8 +134,17 @@ pub fn amend(
131134
.collect(),
132135
}
133136
} else {
137+
let untracked_entries =
138+
prompt_about_untracked_files(effects, git_run_info, &repo, event_tx_id)?
139+
.into_iter()
140+
.map(StatusEntry::new_untracked);
141+
134142
AmendFastOptions::FromWorkingCopy {
135-
status_entries: unstaged_entries.clone(),
143+
status_entries: unstaged_entries
144+
.iter()
145+
.cloned()
146+
.chain(untracked_entries)
147+
.collect_vec(),
136148
}
137149
};
138150
if opts.is_empty() {

0 commit comments

Comments
 (0)