use std::fs;
use std::path::{Path, PathBuf};
use tracing::{info, warn};
pub const MAX_SYNC_FILE_BYTES: u64 = 5 * 1024 * 1024;
pub fn sync_untracked_files(source: &Path, dest_worktree: &Path, patterns: &[String]) {
if patterns.is_empty() {
return;
}
let mut candidates: Vec<(PathBuf, String)> = Vec::new();
let mut seen = std::collections::HashSet::new();
for pattern in patterns {
for (abs, rel) in collect_matches(source, pattern) {
if seen.insert(rel.clone()) {
candidates.push((abs, rel));
}
}
}
let mut copied: Vec<String> = Vec::new();
for (abs_path, rel_path) in candidates {
let size = match fs::metadata(&abs_path) {
Ok(m) => m.len(),
Err(e) => {
warn!(
file = %abs_path.display(),
"untracked_sync: could not stat candidate file (non-fatal, skipping): {e}"
);
continue;
}
};
if size > MAX_SYNC_FILE_BYTES {
warn!(
file = %abs_path.display(),
size,
cap = MAX_SYNC_FILE_BYTES,
"untracked_sync: file exceeds size cap, skipping"
);
continue;
}
let dest_path = dest_worktree.join(&rel_path);
if let Some(parent) = dest_path.parent()
&& let Err(e) = fs::create_dir_all(parent)
{
warn!(
dir = %parent.display(),
"untracked_sync: could not create parent dir (non-fatal, skipping file): {e}"
);
continue;
}
match fs::copy(&abs_path, &dest_path) {
Ok(_) => {
info!(
from = %abs_path.display(),
to = %dest_path.display(),
"untracked_sync: copied allowlisted untracked file into session worktree"
);
copied.push(rel_path);
}
Err(e) => {
warn!(
from = %abs_path.display(),
to = %dest_path.display(),
"untracked_sync: copy failed (non-fatal, skipping): {e}"
);
}
}
}
if !copied.is_empty()
&& let Err(e) = append_to_git_exclude(dest_worktree, &copied)
{
warn!(
worktree = %dest_worktree.display(),
"untracked_sync: failed to append copied files to git exclude (non-fatal, \
copied files may show as untracked in `git status`): {e}"
);
}
}
fn collect_matches(source: &Path, pattern: &str) -> Vec<(PathBuf, String)> {
let (dir, dir_prefix, leaf) = match pattern.rsplit_once('/') {
Some((dir_part, leaf)) => {
if is_unsafe_dir_component(dir_part) {
warn!(
pattern,
"untracked_sync: rejecting pattern with an absolute or `..`-escaping \
directory component"
);
return Vec::new();
}
(source.join(dir_part), format!("{dir_part}/"), leaf)
}
None => (source.to_path_buf(), String::new(), pattern),
};
let read_dir = match fs::read_dir(&dir) {
Ok(rd) => rd,
Err(e) => {
warn!(
dir = %dir.display(),
"untracked_sync: could not read directory for pattern {pattern:?} (non-fatal): {e}"
);
return Vec::new();
}
};
read_dir
.flatten()
.filter_map(|entry| {
let path = entry.path();
if !path.is_file() {
return None;
}
let name = path.file_name()?.to_str()?;
if glob_match(leaf, name) {
Some((path.clone(), format!("{dir_prefix}{name}")))
} else {
None
}
})
.collect()
}
fn is_unsafe_dir_component(dir_part: &str) -> bool {
dir_part.starts_with('/') || dir_part.split('/').any(|seg| seg == ".." || seg.is_empty())
}
fn glob_match(pattern: &str, name: &str) -> bool {
let p: Vec<char> = pattern.chars().collect();
let n: Vec<char> = name.chars().collect();
glob_match_from(&p, &n)
}
fn glob_match_from(p: &[char], n: &[char]) -> bool {
match p.split_first() {
None => n.is_empty(),
Some((&'*', rest)) => (0..=n.len()).any(|i| glob_match_from(rest, &n[i..])),
Some((c, rest)) => match n.split_first() {
Some((nc, nrest)) if nc == c => glob_match_from(rest, nrest),
_ => false,
},
}
}
fn append_to_git_exclude(dest_worktree: &Path, relative_paths: &[String]) -> Result<(), String> {
let out = std::process::Command::new("git")
.arg("-C")
.arg(dest_worktree)
.args(["rev-parse", "--git-path", "info/exclude"])
.output()
.map_err(|e| format!("untracked_sync: git rev-parse failed to spawn: {e}"))?;
if !out.status.success() {
let stderr = String::from_utf8_lossy(&out.stderr);
return Err(format!(
"untracked_sync: git rev-parse --git-path info/exclude failed ({}): {stderr}",
out.status
));
}
let raw_path = String::from_utf8_lossy(&out.stdout).trim().to_string();
if raw_path.is_empty() {
return Err("untracked_sync: git rev-parse returned an empty path".to_string());
}
let exclude_path = {
let p = PathBuf::from(&raw_path);
if p.is_absolute() {
p
} else {
dest_worktree.join(p)
}
};
if let Some(parent) = exclude_path.parent() {
fs::create_dir_all(parent)
.map_err(|e| format!("untracked_sync: could not create {}: {e}", parent.display()))?;
}
let existing = fs::read_to_string(&exclude_path).unwrap_or_default();
let mut to_append = String::new();
for rel in relative_paths {
if !existing.lines().any(|line| line.trim() == rel.as_str()) {
to_append.push_str(rel);
to_append.push('\n');
}
}
if to_append.is_empty() {
return Ok(());
}
let entry = if existing.is_empty() || existing.ends_with('\n') {
to_append
} else {
format!("\n{to_append}")
};
use std::io::Write as _;
let mut file = fs::OpenOptions::new()
.create(true)
.append(true)
.open(&exclude_path)
.map_err(|e| {
format!(
"untracked_sync: could not open {}: {e}",
exclude_path.display()
)
})?;
file.write_all(entry.as_bytes()).map_err(|e| {
format!(
"untracked_sync: could not write {}: {e}",
exclude_path.display()
)
})?;
Ok(())
}
#[cfg(test)]
mod tests;