use crate::transaction_git::{GitContext, GitRequest, run_git};
use crate::worktree_copy_prune::Prune;
use crate::worktree_copy_error::{WorktreeCopyError, raw_failure};
use crate::json_record::quote;
use std::cmp::Ordering;
use std::path::{Path, PathBuf};
const DIRECTORY_SUFFIX: char = '/';
pub const STAGE_PREFIX: &str = ".cli-git-worktree-copy-";
pub fn repository_path_within(candidate: &str, parent: &str) -> bool {
return candidate == parent || candidate.starts_with(format!("{parent}/").as_str());
}
pub fn assert_safe_repository_path(repository_path: &str) -> Result<(), WorktreeCopyError> {
let unsafe_path: bool = repository_path.is_empty()
|| repository_path.starts_with('/')
|| Path::new(repository_path).is_absolute()
|| repository_path
.split('/')
.any(|component| return component.is_empty() || component == "." || component == "..");
if unsafe_path {
return Err(WorktreeCopyError(format!(
"cli-git: Git returned unsafe ignored path {}.",
quote(repository_path)
)));
}
return Ok(());
}
pub fn filesystem_path(root: &Path, repository_path: &str) -> Result<PathBuf, WorktreeCopyError> {
assert_safe_repository_path(repository_path)?;
let mut path: PathBuf = root.to_path_buf();
for component in repository_path.split('/') {
path.push(component);
}
return Ok(path);
}
pub fn js_order(left: &str, right: &str) -> Ordering {
return left.encode_utf16().cmp(right.encode_utf16());
}
fn collapse_ignored_roots(paths: &[String]) -> Vec<String> {
let mut candidates: Vec<String> = paths.to_vec();
candidates.sort_by(|left, right| return js_order(left, right));
candidates.dedup();
let mut roots: Vec<String> = Vec::new();
for candidate in candidates {
let owned: bool = roots
.iter()
.any(|root| return repository_path_within(candidate.as_str(), root.as_str()));
if !owned {
roots.push(candidate);
}
}
return roots;
}
fn is_ignored_special_path(
context: &GitContext,
source_root: &Path,
repository_path: &str,
) -> Result<bool, WorktreeCopyError> {
let request: GitRequest = GitRequest {
cwd: source_root.to_path_buf(),
arguments: vec![
"check-ignore".into(),
"--no-index".into(),
"--".into(),
repository_path.into(),
],
without_prefix: true,
..GitRequest::default()
};
let output = run_git(context, &request).map_err(|error| return raw_failure(&error))?;
match output.code {
Some(0) => return Ok(true),
Some(1) => return Ok(false),
_ => {
return Err(raw_failure(&String::from_utf8_lossy(
output.stderr.as_slice(),
)));
}
}
}
fn is_excluded(candidate: &Path, excluded_roots: &[PathBuf]) -> bool {
return excluded_roots
.iter()
.any(|root| return candidate.starts_with(root.as_path()));
}
fn special_entries(
context: &GitContext,
source_root: &Path,
selected_roots: &[String],
excluded_roots: &[PathBuf],
) -> Result<Vec<String>, WorktreeCopyError> {
let mut pending: Vec<String> = vec![String::from(".")];
let mut special_paths: Vec<String> = Vec::new();
while let Some(current) = pending.pop() {
let directory_path: PathBuf = if current == "." {
source_root.to_path_buf()
} else {
filesystem_path(source_root, current.as_str())?
};
let listing = std::fs::read_dir(directory_path.as_path())
.map_err(|error| return raw_failure(&error))?;
for listed in listing {
let entry: std::fs::DirEntry = listed.map_err(|error| return raw_failure(&error))?;
let name: String = entry.file_name().to_string_lossy().into_owned();
let repository_path: String = if current == "." {
name
} else {
format!("{current}/{name}")
};
if repository_path == ".git" {
continue;
}
let source_path: PathBuf = filesystem_path(source_root, repository_path.as_str())?;
if is_excluded(source_path.as_path(), excluded_roots) {
continue;
}
let selected_ancestor: bool = selected_roots
.iter()
.any(|root| return repository_path_within(repository_path.as_str(), root.as_str()));
if selected_ancestor {
continue;
}
let file_type: std::fs::FileType = entry
.file_type()
.map_err(|error| return raw_failure(&error))?;
if file_type.is_dir() {
pending.push(repository_path);
} else if !file_type.is_file() && !file_type.is_symlink() {
special_paths.push(repository_path);
}
}
}
let mut ignored: Vec<String> = Vec::new();
for repository_path in special_paths {
if is_ignored_special_path(context, source_root, repository_path.as_str())? {
ignored.push(repository_path);
}
}
return Ok(ignored);
}
pub fn read_ignored_roots(
context: &GitContext,
source_root: &Path,
excluded_roots: &[PathBuf],
) -> Result<Vec<String>, WorktreeCopyError> {
let request: GitRequest = GitRequest {
cwd: source_root.to_path_buf(),
arguments: vec![
"ls-files".into(),
"--others".into(),
"--ignored".into(),
"--exclude-standard".into(),
"--directory".into(),
"-z".into(),
],
without_prefix: true,
..GitRequest::default()
};
let output = run_git(context, &request).map_err(|error| return raw_failure(&error))?;
if output.code != Some(0) {
return Err(raw_failure(&String::from_utf8_lossy(
output.stderr.as_slice(),
)));
}
let mut paths: Vec<String> = Vec::new();
for field in output.stdout.split(|byte| return *byte == 0) {
if field.is_empty() {
continue;
}
let text: String = String::from_utf8_lossy(field).into_owned();
let path: String = match text.strip_suffix(DIRECTORY_SUFFIX) {
Some(stripped) => String::from(stripped),
None => text,
};
assert_safe_repository_path(path.as_str())?;
paths.push(path);
}
let selected: Vec<String> = paths.clone();
let special: Vec<String> =
special_entries(context, source_root, selected.as_slice(), excluded_roots)?;
let mut all: Vec<String> = paths;
all.extend(special);
return Ok(collapse_ignored_roots(all.as_slice()));
}
#[derive(Clone, Debug, Eq, PartialEq)]
pub enum CopySelection {
Ignored,
MainCheckout,
Paths(Vec<String>),
}
pub fn read_named_ignored_roots(
context: &GitContext,
source_root: &Path,
paths: &[String],
) -> Result<Vec<String>, WorktreeCopyError> {
let mut selected: Vec<String> = Vec::new();
for path in paths {
assert_safe_repository_path(path.as_str())?;
let request: GitRequest = GitRequest {
cwd: source_root.to_path_buf(),
arguments: vec![
"ls-files".into(),
"--others".into(),
"--ignored".into(),
"--exclude-standard".into(),
"-z".into(),
"--".into(),
format!(":(top,literal){path}").into(),
],
without_prefix: true,
..GitRequest::default()
};
let output = run_git(context, &request).map_err(|error| return raw_failure(&error))?;
if output.code != Some(0) {
return Err(raw_failure(&String::from_utf8_lossy(
output.stderr.as_slice(),
)));
}
let listed: bool = output
.stdout
.split(|byte| return *byte == 0)
.any(|field| return field == path.as_bytes());
if listed {
selected.push(path.clone());
}
}
return Ok(collapse_ignored_roots(selected.as_slice()));
}
pub fn select_roots(
context: &GitContext,
source_root: &Path,
excluded_roots: &[PathBuf],
selection: &CopySelection,
) -> Result<Vec<String>, WorktreeCopyError> {
match selection {
CopySelection::Ignored | CopySelection::MainCheckout => {
return read_ignored_roots(context, source_root, excluded_roots);
}
CopySelection::Paths(paths) => {
return read_named_ignored_roots(context, source_root, paths.as_slice());
}
}
}
impl CopySelection {
pub fn prune(&self) -> Prune {
return match self {
CopySelection::MainCheckout => Prune::main_checkout(),
CopySelection::Ignored | CopySelection::Paths(_) => Prune::Never,
};
}
}
#[cfg(test)]
#[path = "worktree_copy_selection_tests.rs"]
mod tests;