use std::fs;
use std::io;
use std::path::{Path, PathBuf};
use ignore::gitignore::{Gitignore, GitignoreBuilder};
const SKIP_DIRS: &[&str] = &[
"target", "node_modules", ".gradle", "build", "dist", "out", ".next", ".nuxt", ".svelte-kit", ".cache", "__pycache__", ".pytest_cache", ".mypy_cache", ".ruff_cache", ".tox", ".idea", ".vscode", ];
fn should_skip_dir(name: &str) -> bool {
SKIP_DIRS.contains(&name)
}
fn build_gitignore(src_root: &Path, current_dir: &Path) -> Gitignore {
let mut builder = GitignoreBuilder::new(src_root);
let mut dir = src_root.to_path_buf();
let gitignore_path = dir.join(".gitignore");
if gitignore_path.is_file() {
let _ = builder.add(&gitignore_path);
}
if let Ok(relative) = current_dir.strip_prefix(src_root) {
for component in relative.components() {
dir.push(component);
let gitignore_path = dir.join(".gitignore");
if gitignore_path.is_file() {
let _ = builder.add(&gitignore_path);
}
}
}
builder.build().unwrap_or_else(|_| Gitignore::empty())
}
pub fn is_local_path(url: &str) -> bool {
if url.starts_with("https://")
|| url.starts_with("http://")
|| url.starts_with("git@")
|| url.starts_with("ssh://")
|| url.starts_with("git://")
{
return false;
}
let p = Path::new(url);
if !p.is_dir() {
return false;
}
if is_bare_git_repo(p) {
return false;
}
true
}
fn is_bare_git_repo(p: &Path) -> bool {
p.join("HEAD").is_file()
&& p.join("config").is_file()
&& p.join("objects").is_dir()
&& p.join("refs").is_dir()
}
pub fn sync_local_working_tree(src: &str, dst: &str) -> anyhow::Result<()> {
let src_path = PathBuf::from(src);
let dst_path = PathBuf::from(dst);
if !src_path.is_dir() {
anyhow::bail!("Source path is not a directory: {}", src);
}
let src_canon = fs::canonicalize(&src_path).unwrap_or_else(|_| src_path.clone());
let dst_canon = fs::canonicalize(&dst_path).unwrap_or_else(|_| dst_path.clone());
if src_canon == dst_canon {
anyhow::bail!(
"local sync source and destination resolve to the same path ({}); \
place the source repo outside the knot-server workspace, or \
register a URL whose basename does not collide with the \
workspace dir layout",
src_canon.display()
);
}
fs::read_dir(&src_path).map_err(|e| {
anyhow::Error::new(e).context(format!("Cannot read source directory: {}", src))
})?;
fs::create_dir_all(&dst_path)?;
let gitignore = build_gitignore(&src_path, &src_path);
copy_tree(&src_path, &dst_path, &src_path, &gitignore)?;
prune_tree(&src_path, &dst_path, &src_path, &gitignore)?;
Ok(())
}
fn copy_tree(src: &Path, dst: &Path, src_root: &Path, gitignore: &Gitignore) -> anyhow::Result<()> {
let entries = match fs::read_dir(src) {
Ok(it) => it,
Err(e) if e.kind() == io::ErrorKind::PermissionDenied => {
tracing::warn!(
"skipping unreadable directory during local sync: {} ({e})",
src.display()
);
return Ok(());
}
Err(e) => return Err(anyhow::Error::new(e).context(format!("read_dir {}", src.display()))),
};
for entry in entries {
let entry = match entry {
Ok(e) => e,
Err(e) => {
tracing::warn!("skipping unreadable entry under {}: {e}", src.display());
continue;
}
};
let file_type = match entry.file_type() {
Ok(t) => t,
Err(e) => {
tracing::warn!(
"skipping entry with unknown file type under {}: {e}",
src.display()
);
continue;
}
};
let name = entry.file_name();
let name_str = name.to_string_lossy();
if name_str == ".git" || name_str == ".knot" || name_str == ".knot.lock" {
continue;
}
if file_type.is_dir() && should_skip_dir(&name_str) {
continue;
}
let src_child = entry.path();
if let Ok(relative) = src_child.strip_prefix(src_root) {
let relative_str = relative.to_string_lossy();
let m = gitignore.matched_path_or_any_parents(relative, file_type.is_dir());
if m.is_ignore() {
tracing::debug!(
"skipping gitignored entry during local sync: {}",
relative_str
);
continue;
}
}
let dst_child = dst.join(&name);
if file_type.is_symlink() {
tracing::debug!(
"skipping symlink during local sync: {}",
src_child.display()
);
continue;
}
if file_type.is_dir() {
if let Err(e) = fs::read_dir(&src_child) {
if e.kind() == io::ErrorKind::PermissionDenied {
tracing::warn!(
"skipping unreadable directory during local sync: {} ({e})",
src_child.display()
);
} else {
tracing::warn!(
"skipping directory: cannot read {}: {e}",
src_child.display()
);
}
continue;
}
if let Err(e) = fs::create_dir_all(&dst_child) {
tracing::warn!(
"skipping directory: cannot create mirror {}: {e}",
dst_child.display()
);
continue;
}
if let Err(e) = make_writable(&dst_child) {
tracing::warn!(
"skipping subtree: cannot make mirror writable {}: {e}",
dst_child.display()
);
continue;
}
if let Err(e) = copy_tree(
&src_child,
&dst_child,
src_root,
&build_gitignore(src_root, &src_child),
) {
tracing::warn!("error syncing subtree {}: {e:#}", src_child.display());
}
} else if file_type.is_file() {
if let Some(parent) = dst_child.parent() {
let _ = fs::create_dir_all(parent);
}
if let Err(e) = make_writable(&dst_child) {
tracing::warn!("cannot make mirror writable {}: {e}", dst_child.display());
}
if let Err(e) = fs::copy(&src_child, &dst_child) {
tracing::warn!(
"skipping file: cannot copy {} -> {}: {e}",
src_child.display(),
dst_child.display()
);
}
} else {
tracing::debug!(
"skipping non-regular file during local sync: {}",
src_child.display()
);
}
}
Ok(())
}
fn make_writable(path: &Path) -> anyhow::Result<()> {
if !path.exists() {
return Ok(());
}
let metadata = fs::metadata(path)?;
let mut perms = metadata.permissions();
if perms.readonly() {
ensure_writable(&mut perms);
fs::set_permissions(path, perms)?;
}
Ok(())
}
#[cfg(unix)]
fn ensure_writable(perms: &mut fs::Permissions) {
use std::os::unix::fs::PermissionsExt;
perms.set_mode(perms.mode() | 0o200);
}
#[cfg(not(unix))]
fn ensure_writable(perms: &mut fs::Permissions) {
perms.set_readonly(false);
}
fn prune_tree(
src: &Path,
dst: &Path,
src_root: &Path,
gitignore: &Gitignore,
) -> anyhow::Result<()> {
if !dst.is_dir() {
return Ok(());
}
for entry in fs::read_dir(dst)? {
let entry = entry?;
let file_type = entry.file_type()?;
let name = entry.file_name();
let name_str = name.to_string_lossy();
if name_str.starts_with(".knot") {
continue;
}
if file_type.is_dir() && should_skip_dir(&name_str) {
fs::remove_dir_all(entry.path())?;
continue;
}
let dst_child = entry.path();
let src_child = src.join(&name);
if let Ok(relative) = src_child.strip_prefix(src_root) {
let m = gitignore.matched_path_or_any_parents(relative, file_type.is_dir());
if m.is_ignore() {
if file_type.is_dir() {
fs::remove_dir_all(&dst_child)?;
} else {
fs::remove_file(&dst_child)?;
}
continue;
}
}
if !src_child.exists() {
if file_type.is_dir() {
fs::remove_dir_all(&dst_child)?;
} else {
fs::remove_file(&dst_child)?;
}
continue;
}
if file_type.is_dir() && src_child.is_dir() {
let child_gitignore = build_gitignore(src_root, &src_child);
prune_tree(&src_child, &dst_child, src_root, &child_gitignore)?;
}
}
Ok(())
}
pub fn clear_stale_index_state(repo_path: &str) -> bool {
let state_path = Path::new(repo_path).join(".knot").join("index_state.json");
if !state_path.exists() {
return false;
}
let Ok(content) = fs::read_to_string(&state_path) else {
return false;
};
let Ok(value) = serde_json::from_str::<serde_json::Value>(&content) else {
return false;
};
let Some(obj) = value.as_object() else {
return false;
};
let is_stale = !obj.contains_key("version");
if is_stale {
let _ = fs::remove_file(&state_path);
true
} else {
false
}
}
#[cfg(test)]
mod tests {
use super::*;
use tempfile::TempDir;
#[test]
fn test_is_local_path_absolute() {
let dir = TempDir::new().unwrap();
let path = dir.path().to_string_lossy().to_string();
assert!(is_local_path(&path));
}
#[test]
fn test_is_local_path_bare_git_repo() {
let dir = TempDir::new().unwrap();
fs::write(dir.path().join("HEAD"), "ref: refs/heads/main\n").unwrap();
fs::write(dir.path().join("config"), "[core]\n\tbare = true\n").unwrap();
fs::create_dir(dir.path().join("objects")).unwrap();
fs::create_dir(dir.path().join("refs")).unwrap();
let path = dir.path().to_string_lossy().to_string();
assert!(
!is_local_path(&path),
"bare git repo must be cloned, not synced as a local working tree"
);
}
#[test]
fn test_is_local_path_working_tree_with_dot_git() {
let dir = TempDir::new().unwrap();
fs::create_dir(dir.path().join(".git")).unwrap();
let path = dir.path().to_string_lossy().to_string();
assert!(is_local_path(&path));
}
#[test]
fn test_is_local_path_partial_bare_structure() {
let dir = TempDir::new().unwrap();
fs::write(dir.path().join("HEAD"), "x").unwrap();
fs::create_dir(dir.path().join("objects")).unwrap();
let path = dir.path().to_string_lossy().to_string();
assert!(is_local_path(&path));
}
#[test]
fn test_is_local_path_remote_ssh() {
assert!(!is_local_path("git@github.com:org/repo.git"));
}
#[test]
fn test_is_local_path_remote_https() {
assert!(!is_local_path("https://github.com/org/repo.git"));
}
#[test]
fn test_is_local_path_nonexistent() {
assert!(!is_local_path("/nonexistent/path/xyz"));
}
#[test]
fn test_sync_copies_new_file() {
let src = TempDir::new().unwrap();
let dst = TempDir::new().unwrap();
let dst_path = dst.path().to_string_lossy().to_string();
sync_local_working_tree(src.path().to_str().unwrap(), &dst_path).unwrap();
fs::write(src.path().join("new.java"), "class New {}").unwrap();
sync_local_working_tree(src.path().to_str().unwrap(), &dst_path).unwrap();
assert!(dst.path().join("new.java").exists());
}
#[test]
fn test_sync_same_src_and_dst_fails_loudly() {
let dir = TempDir::new().unwrap();
fs::write(dir.path().join("important.java"), "class Important {}").unwrap();
let path = dir.path().to_string_lossy().to_string();
let result = sync_local_working_tree(&path, &path);
assert!(result.is_err(), "expected same-path sync to fail loudly");
let msg = result.unwrap_err().to_string();
assert!(
msg.contains("same path") || msg.contains("outside"),
"error message should explain the cause, got: {msg}"
);
assert_eq!(
fs::read_to_string(dir.path().join("important.java")).unwrap(),
"class Important {}"
);
}
#[test]
fn test_sync_overwrites_modified_file() {
let src = TempDir::new().unwrap();
let dst = TempDir::new().unwrap();
let dst_path = dst.path().to_string_lossy().to_string();
fs::write(src.path().join("a.java"), "class A {}").unwrap();
sync_local_working_tree(src.path().to_str().unwrap(), &dst_path).unwrap();
fs::write(src.path().join("a.java"), "class A { void newMethod() {} }").unwrap();
sync_local_working_tree(src.path().to_str().unwrap(), &dst_path).unwrap();
let content = fs::read_to_string(dst.path().join("a.java")).unwrap();
assert!(content.contains("newMethod"));
}
#[test]
fn test_sync_deletes_removed_file() {
let src = TempDir::new().unwrap();
let dst = TempDir::new().unwrap();
let dst_path = dst.path().to_string_lossy().to_string();
fs::write(src.path().join("keep.java"), "class Keep {}").unwrap();
fs::write(src.path().join("remove.java"), "class Remove {}").unwrap();
sync_local_working_tree(src.path().to_str().unwrap(), &dst_path).unwrap();
assert!(dst.path().join("remove.java").exists());
fs::remove_file(src.path().join("remove.java")).unwrap();
sync_local_working_tree(src.path().to_str().unwrap(), &dst_path).unwrap();
assert!(dst.path().join("keep.java").exists());
assert!(!dst.path().join("remove.java").exists());
}
#[test]
fn test_sync_skips_git_dir() {
let src = TempDir::new().unwrap();
let dst = TempDir::new().unwrap();
let dst_path = dst.path().to_string_lossy().to_string();
fs::create_dir_all(src.path().join(".git")).unwrap();
fs::write(src.path().join(".git").join("config"), "[core]").unwrap();
fs::write(src.path().join("file.java"), "class F {}").unwrap();
sync_local_working_tree(src.path().to_str().unwrap(), &dst_path).unwrap();
assert!(dst.path().join("file.java").exists());
assert!(!dst.path().join(".git").exists());
}
#[test]
fn test_sync_preserves_knot_artifacts() {
let src = TempDir::new().unwrap();
let dst = TempDir::new().unwrap();
let dst_path = dst.path().to_string_lossy().to_string();
fs::write(src.path().join("file.java"), "class F {}").unwrap();
sync_local_working_tree(src.path().to_str().unwrap(), &dst_path).unwrap();
fs::write(dst.path().join(".knot.lock"), "lock-content").unwrap();
fs::write(dst.path().join(".knot_state.json"), "{}").unwrap();
fs::remove_file(src.path().join("file.java")).unwrap();
sync_local_working_tree(src.path().to_str().unwrap(), &dst_path).unwrap();
assert!(dst.path().join(".knot.lock").exists());
assert!(dst.path().join(".knot_state.json").exists());
}
#[test]
fn test_sync_skips_target_dir() {
let src = TempDir::new().unwrap();
let dst = TempDir::new().unwrap();
let dst_path = dst.path().to_string_lossy().to_string();
fs::create_dir_all(src.path().join("target").join("debug")).unwrap();
fs::write(
src.path().join("target").join("debug").join("big_binary"),
"x".repeat(10_000).as_bytes(),
)
.unwrap();
fs::write(src.path().join("Cargo.toml"), "[package]\nname = \"x\"\n").unwrap();
sync_local_working_tree(src.path().to_str().unwrap(), &dst_path).unwrap();
assert!(dst.path().join("Cargo.toml").exists());
assert!(!dst.path().join("target").exists());
assert!(!dst.path().join("target").join("debug").exists());
}
#[test]
fn test_sync_skips_node_modules_dir() {
let src = TempDir::new().unwrap();
let dst = TempDir::new().unwrap();
let dst_path = dst.path().to_string_lossy().to_string();
fs::create_dir_all(src.path().join("node_modules").join("react")).unwrap();
fs::write(
src.path()
.join("node_modules")
.join("react")
.join("index.js"),
b"module.exports = {};",
)
.unwrap();
fs::write(src.path().join("package.json"), "{}\n").unwrap();
sync_local_working_tree(src.path().to_str().unwrap(), &dst_path).unwrap();
assert!(dst.path().join("package.json").exists());
assert!(!dst.path().join("node_modules").exists());
}
#[test]
fn test_sync_prunes_existing_artifact_dir() {
let src = TempDir::new().unwrap();
let dst = TempDir::new().unwrap();
let dst_path = dst.path().to_string_lossy().to_string();
fs::write(src.path().join("file.java"), "class F {}").unwrap();
fs::create_dir_all(dst.path().join("target")).unwrap();
fs::write(dst.path().join("target").join("junk.o"), b"junk").unwrap();
fs::create_dir_all(dst.path().join("node_modules").join("react")).unwrap();
fs::write(
dst.path()
.join("node_modules")
.join("react")
.join("index.js"),
b"old",
)
.unwrap();
fs::write(dst.path().join("file.java"), "class F {}").unwrap();
sync_local_working_tree(src.path().to_str().unwrap(), &dst_path).unwrap();
assert!(dst.path().join("file.java").exists());
assert!(!dst.path().join("target").exists());
assert!(!dst.path().join("node_modules").exists());
}
#[test]
fn test_sync_skips_nested_artifact_dir() {
let src = TempDir::new().unwrap();
let dst = TempDir::new().unwrap();
let dst_path = dst.path().to_string_lossy().to_string();
fs::create_dir_all(src.path().join("services").join("api").join("target")).unwrap();
fs::write(
src.path()
.join("services")
.join("api")
.join("target")
.join("artifact"),
b"x",
)
.unwrap();
fs::write(
src.path().join("services").join("api").join("main.rs"),
b"fn main() {}",
)
.unwrap();
sync_local_working_tree(src.path().to_str().unwrap(), &dst_path).unwrap();
assert!(
dst.path()
.join("services")
.join("api")
.join("main.rs")
.exists()
);
assert!(
!dst.path()
.join("services")
.join("api")
.join("target")
.exists()
);
}
#[test]
fn test_should_skip_dir_known_entries() {
assert!(should_skip_dir("target"));
assert!(should_skip_dir("node_modules"));
assert!(should_skip_dir(".gradle"));
assert!(should_skip_dir("build"));
assert!(should_skip_dir("dist"));
assert!(should_skip_dir("__pycache__"));
assert!(should_skip_dir(".idea"));
}
#[test]
fn test_should_skip_dir_legit_dirs() {
assert!(!should_skip_dir("src"));
assert!(!should_skip_dir("tests"));
assert!(!should_skip_dir("targeted")); assert!(!should_skip_dir("my-target"));
}
#[cfg(unix)]
mod readonly_tests {
use super::*;
use std::os::unix::fs::PermissionsExt;
#[test]
fn test_sync_copies_readonly_source_file() {
let src = TempDir::new().unwrap();
let dst = TempDir::new().unwrap();
let dst_path = dst.path().to_string_lossy().to_string();
let file = src.path().join("ro.txt");
fs::write(&file, "data").unwrap();
fs::set_permissions(&file, fs::Permissions::from_mode(0o444)).unwrap();
sync_local_working_tree(src.path().to_str().unwrap(), &dst_path).unwrap();
assert_eq!(
fs::read_to_string(dst.path().join("ro.txt")).unwrap(),
"data"
);
}
#[test]
fn test_sync_overwrites_readonly_destination_file() {
let src = TempDir::new().unwrap();
let dst = TempDir::new().unwrap();
let dst_path = dst.path().to_string_lossy().to_string();
fs::write(src.path().join("a.txt"), "v1").unwrap();
sync_local_working_tree(src.path().to_str().unwrap(), &dst_path).unwrap();
let dst_file = dst.path().join("a.txt");
fs::set_permissions(&dst_file, fs::Permissions::from_mode(0o444)).unwrap();
fs::write(src.path().join("a.txt"), "v2").unwrap();
sync_local_working_tree(src.path().to_str().unwrap(), &dst_path).unwrap();
assert_eq!(fs::read_to_string(&dst_file).unwrap(), "v2");
}
#[test]
fn test_sync_overwrites_into_readonly_destination_dir() {
let src = TempDir::new().unwrap();
let dst = TempDir::new().unwrap();
let dst_path = dst.path().to_string_lossy().to_string();
fs::create_dir_all(src.path().join("sub")).unwrap();
fs::write(src.path().join("sub").join("a.txt"), "v1").unwrap();
sync_local_working_tree(src.path().to_str().unwrap(), &dst_path).unwrap();
let dst_sub = dst.path().join("sub");
fs::set_permissions(&dst_sub, fs::Permissions::from_mode(0o555)).unwrap();
fs::write(src.path().join("sub").join("a.txt"), "v2").unwrap();
sync_local_working_tree(src.path().to_str().unwrap(), &dst_path).unwrap();
assert_eq!(fs::read_to_string(dst_sub.join("a.txt")).unwrap(), "v2");
}
#[test]
fn test_make_writable_missing_path_is_noop() {
let dir = TempDir::new().unwrap();
let missing = dir.path().join("nope.txt");
assert!(make_writable(&missing).is_ok());
}
#[test]
fn test_sync_skips_unreadable_source_subdir() {
let src = TempDir::new().unwrap();
let dst = TempDir::new().unwrap();
let dst_path = dst.path().to_string_lossy().to_string();
fs::create_dir_all(src.path().join("readable")).unwrap();
fs::write(src.path().join("readable").join("ok.txt"), "ok").unwrap();
fs::create_dir_all(src.path().join("locked")).unwrap();
fs::write(src.path().join("locked").join("secret.txt"), "secret").unwrap();
fs::set_permissions(src.path().join("locked"), fs::Permissions::from_mode(0o000))
.unwrap();
let result = sync_local_working_tree(src.path().to_str().unwrap(), &dst_path);
assert!(
result.is_ok(),
"sync must tolerate unreadable subdirs, got: {:?}",
result.err()
);
assert!(dst.path().join("readable").join("ok.txt").exists());
assert_eq!(
fs::read_to_string(dst.path().join("readable").join("ok.txt")).unwrap(),
"ok"
);
assert!(!dst.path().join("locked").exists());
let _ =
fs::set_permissions(src.path().join("locked"), fs::Permissions::from_mode(0o755));
}
#[test]
fn test_sync_skips_unreadable_source_file() {
let src = TempDir::new().unwrap();
let dst = TempDir::new().unwrap();
let dst_path = dst.path().to_string_lossy().to_string();
fs::write(src.path().join("ok.txt"), "ok").unwrap();
let locked = src.path().join("locked.txt");
fs::write(&locked, "secret").unwrap();
fs::set_permissions(&locked, fs::Permissions::from_mode(0o000)).unwrap();
let result = sync_local_working_tree(src.path().to_str().unwrap(), &dst_path);
assert!(
result.is_ok(),
"sync must tolerate unreadable files, got: {:?}",
result.err()
);
assert!(dst.path().join("ok.txt").exists());
assert!(!dst.path().join("locked.txt").exists());
let _ = fs::set_permissions(&locked, fs::Permissions::from_mode(0o644));
}
#[test]
fn test_sync_skips_symlinks() {
let src = TempDir::new().unwrap();
let dst = TempDir::new().unwrap();
let dst_path = dst.path().to_string_lossy().to_string();
fs::write(src.path().join("real.txt"), "real").unwrap();
std::os::unix::fs::symlink("real.txt", src.path().join("link_to_real")).unwrap();
std::os::unix::fs::symlink("/nonexistent/target", src.path().join("broken")).unwrap();
std::os::unix::fs::symlink("/etc/passwd", src.path().join("escape")).unwrap();
let result = sync_local_working_tree(src.path().to_str().unwrap(), &dst_path);
assert!(result.is_ok(), "got: {:?}", result.err());
assert!(dst.path().join("real.txt").exists());
assert!(!dst.path().join("link_to_real").exists());
assert!(!dst.path().join("broken").exists());
assert!(!dst.path().join("escape").exists());
}
}
#[cfg(unix)]
#[test]
fn test_sync_fails_on_unreadable_top_level_src() {
use std::os::unix::fs::PermissionsExt;
let src = TempDir::new().unwrap();
let dst = TempDir::new().unwrap();
let dst_path = dst.path().to_string_lossy().to_string();
fs::set_permissions(src.path(), fs::Permissions::from_mode(0o000)).unwrap();
let result = sync_local_working_tree(src.path().to_str().unwrap(), &dst_path);
assert!(result.is_err(), "expected top-level EACCES to fail loudly");
let _ = fs::set_permissions(src.path(), fs::Permissions::from_mode(0o755));
}
fn seed_state_file(repo: &Path, content: &str) {
let knot_dir = repo.join(".knot");
fs::create_dir_all(&knot_dir).unwrap();
fs::write(knot_dir.join("index_state.json"), content).unwrap();
}
#[test]
fn test_clear_stale_index_state_no_file() {
let dir = TempDir::new().unwrap();
assert!(!clear_stale_index_state(dir.path().to_str().unwrap()));
}
#[test]
fn test_clear_stale_index_state_missing_version() {
let dir = TempDir::new().unwrap();
seed_state_file(dir.path(), r#"{"file_hashes":{"a.java":"deadbeef"}}"#);
let removed = clear_stale_index_state(dir.path().to_str().unwrap());
assert!(removed);
assert!(!dir.path().join(".knot").join("index_state.json").exists());
}
#[test]
fn test_clear_stale_index_state_with_version() {
let dir = TempDir::new().unwrap();
seed_state_file(
dir.path(),
r#"{"version":3,"file_hashes":{"a.java":"deadbeef"}}"#,
);
let removed = clear_stale_index_state(dir.path().to_str().unwrap());
assert!(!removed);
assert!(dir.path().join(".knot").join("index_state.json").exists());
}
#[test]
fn test_clear_stale_index_state_corrupt() {
let dir = TempDir::new().unwrap();
seed_state_file(dir.path(), "this is not json {{{");
let removed = clear_stale_index_state(dir.path().to_str().unwrap());
assert!(!removed);
assert!(dir.path().join(".knot").join("index_state.json").exists());
}
#[test]
fn test_clear_stale_index_state_not_object() {
let dir = TempDir::new().unwrap();
seed_state_file(dir.path(), "[1, 2, 3]");
let removed = clear_stale_index_state(dir.path().to_str().unwrap());
assert!(!removed);
assert!(dir.path().join(".knot").join("index_state.json").exists());
}
#[test]
fn test_sync_preserves_mirror_knot_state_when_source_has_no_knot() {
let src = TempDir::new().unwrap();
let dst = TempDir::new().unwrap();
fs::write(src.path().join("main.rs"), b"fn main(){}").unwrap();
fs::create_dir_all(dst.path().join(".knot")).unwrap();
let mirror_state = r#"{"version":3,"file_hashes":{"main.rs":"deadbeef"}}"#;
fs::write(dst.path().join(".knot/index_state.json"), mirror_state).unwrap();
sync_local_working_tree(src.path().to_str().unwrap(), dst.path().to_str().unwrap())
.unwrap();
let final_state = fs::read_to_string(dst.path().join(".knot/index_state.json")).unwrap();
assert_eq!(final_state, mirror_state);
assert!(dst.path().join("main.rs").exists());
}
#[test]
fn test_sync_preserves_mirror_knot_state_when_source_has_legacy_knot() {
let src = TempDir::new().unwrap();
let dst = TempDir::new().unwrap();
fs::create_dir_all(src.path().join(".knot")).unwrap();
let legacy_state = r#"{"file_hashes":{"old.rs":"deadbeef"}}"#;
fs::write(src.path().join(".knot/index_state.json"), legacy_state).unwrap();
fs::write(src.path().join("main.rs"), b"fn main(){}").unwrap();
fs::create_dir_all(dst.path().join(".knot")).unwrap();
let mirror_state = r#"{"version":3,"file_hashes":{"main.rs":"abc123","lib.rs":"def456"}}"#;
fs::write(dst.path().join(".knot/index_state.json"), mirror_state).unwrap();
sync_local_working_tree(src.path().to_str().unwrap(), dst.path().to_str().unwrap())
.unwrap();
let final_state = fs::read_to_string(dst.path().join(".knot/index_state.json")).unwrap();
assert_eq!(
final_state, mirror_state,
"El state del mirror debe sobrevivir al sync sin importar lo que tenga el source"
);
}
#[test]
fn test_sync_skips_source_knot_lock() {
let src = TempDir::new().unwrap();
let dst = TempDir::new().unwrap();
fs::write(src.path().join(".knot.lock"), b"pid:1234").unwrap();
fs::write(src.path().join("file.rs"), b"fn main(){}").unwrap();
sync_local_working_tree(src.path().to_str().unwrap(), dst.path().to_str().unwrap())
.unwrap();
assert!(!dst.path().join(".knot.lock").exists());
assert!(dst.path().join("file.rs").exists());
}
#[test]
fn test_sync_skips_entire_source_knot_subtree() {
let src = TempDir::new().unwrap();
let dst = TempDir::new().unwrap();
fs::create_dir_all(src.path().join(".knot/fastembed_cache")).unwrap();
fs::write(
src.path().join(".knot/fastembed_cache/blob.bin"),
b"binary blob",
)
.unwrap();
fs::write(src.path().join("main.rs"), b"fn main(){}").unwrap();
sync_local_working_tree(src.path().to_str().unwrap(), dst.path().to_str().unwrap())
.unwrap();
assert!(
!dst.path().join(".knot").exists(),
"copy_tree no debe crear ningún `.knot/` en el mirror si el mirror no lo tenÃa"
);
assert!(dst.path().join("main.rs").exists());
}
#[test]
fn test_sync_skip_is_exact_match_not_prefix() {
let src = TempDir::new().unwrap();
let dst = TempDir::new().unwrap();
fs::write(src.path().join(".knotrc"), b"user config").unwrap();
fs::write(src.path().join("main.rs"), b"fn main(){}").unwrap();
sync_local_working_tree(src.path().to_str().unwrap(), dst.path().to_str().unwrap())
.unwrap();
assert!(
dst.path().join(".knotrc").exists(),
"El skip debe ser exacto: `.knot` y `.knot.lock`, NO un prefijo `.knot*`"
);
assert!(dst.path().join("main.rs").exists());
}
#[test]
fn test_sync_gitignore_skips_matching_files() {
let src = TempDir::new().unwrap();
let dst = TempDir::new().unwrap();
fs::write(src.path().join(".gitignore"), "*.log\n").unwrap();
fs::write(src.path().join("app.log"), b"log data").unwrap();
fs::write(src.path().join("main.rs"), b"fn main(){}").unwrap();
sync_local_working_tree(src.path().to_str().unwrap(), dst.path().to_str().unwrap())
.unwrap();
assert!(!dst.path().join("app.log").exists());
assert!(dst.path().join("main.rs").exists());
assert!(dst.path().join(".gitignore").exists());
}
#[test]
fn test_sync_gitignore_skips_matching_directories() {
let src = TempDir::new().unwrap();
let dst = TempDir::new().unwrap();
fs::write(src.path().join(".gitignore"), "logs/\n").unwrap();
fs::create_dir_all(src.path().join("logs")).unwrap();
fs::write(src.path().join("logs/app.log"), b"log data").unwrap();
fs::write(src.path().join("main.rs"), b"fn main(){}").unwrap();
sync_local_working_tree(src.path().to_str().unwrap(), dst.path().to_str().unwrap())
.unwrap();
assert!(!dst.path().join("logs").exists());
assert!(dst.path().join("main.rs").exists());
}
#[test]
fn test_sync_gitignore_negation_pattern() {
let src = TempDir::new().unwrap();
let dst = TempDir::new().unwrap();
fs::write(src.path().join(".gitignore"), "*.log\n!important.log\n").unwrap();
fs::write(src.path().join("app.log"), b"log data").unwrap();
fs::write(src.path().join("important.log"), b"important data").unwrap();
fs::write(src.path().join("main.rs"), b"fn main(){}").unwrap();
sync_local_working_tree(src.path().to_str().unwrap(), dst.path().to_str().unwrap())
.unwrap();
assert!(!dst.path().join("app.log").exists());
assert!(dst.path().join("important.log").exists());
assert!(dst.path().join("main.rs").exists());
}
#[test]
fn test_sync_gitignore_nested_gitignore_files() {
let src = TempDir::new().unwrap();
let dst = TempDir::new().unwrap();
fs::write(src.path().join(".gitignore"), "*.log\n").unwrap();
fs::create_dir_all(src.path().join("src")).unwrap();
fs::write(src.path().join("src/.gitignore"), "*.tmp\n").unwrap();
fs::write(src.path().join("src/main.rs"), b"fn main(){}").unwrap();
fs::write(src.path().join("src/temp.tmp"), b"temp data").unwrap();
fs::write(src.path().join("app.log"), b"log data").unwrap();
sync_local_working_tree(src.path().to_str().unwrap(), dst.path().to_str().unwrap())
.unwrap();
assert!(!dst.path().join("app.log").exists());
assert!(!dst.path().join("src/temp.tmp").exists());
assert!(dst.path().join("src/main.rs").exists());
}
#[test]
fn test_sync_gitignore_wildcard_pattern() {
let src = TempDir::new().unwrap();
let dst = TempDir::new().unwrap();
fs::write(src.path().join(".gitignore"), "*.pyc\n").unwrap();
fs::write(src.path().join("module.pyc"), b"compiled").unwrap();
fs::write(src.path().join("module.py"), b"source").unwrap();
sync_local_working_tree(src.path().to_str().unwrap(), dst.path().to_str().unwrap())
.unwrap();
assert!(!dst.path().join("module.pyc").exists());
assert!(dst.path().join("module.py").exists());
}
#[test]
fn test_sync_gitignore_prunes_previously_copied_ignored_files() {
let src = TempDir::new().unwrap();
let dst = TempDir::new().unwrap();
let dst_path = dst.path().to_string_lossy().to_string();
fs::write(src.path().join("app.log"), b"log data").unwrap();
fs::write(src.path().join("main.rs"), b"fn main(){}").unwrap();
sync_local_working_tree(src.path().to_str().unwrap(), &dst_path).unwrap();
assert!(dst.path().join("app.log").exists());
fs::write(src.path().join(".gitignore"), "*.log\n").unwrap();
sync_local_working_tree(src.path().to_str().unwrap(), &dst_path).unwrap();
assert!(!dst.path().join("app.log").exists());
assert!(dst.path().join("main.rs").exists());
}
#[test]
fn test_sync_no_gitignore_copies_everything() {
let src = TempDir::new().unwrap();
let dst = TempDir::new().unwrap();
fs::write(src.path().join("app.log"), b"log data").unwrap();
fs::write(src.path().join("main.rs"), b"fn main(){}").unwrap();
sync_local_working_tree(src.path().to_str().unwrap(), dst.path().to_str().unwrap())
.unwrap();
assert!(dst.path().join("app.log").exists());
assert!(dst.path().join("main.rs").exists());
}
}