use std::fs;
use std::io;
use std::path::{Path, PathBuf};
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)
}
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)?;
copy_tree(&src_path, &dst_path)?;
prune_tree(&src_path, &dst_path)?;
Ok(())
}
fn copy_tree(src: &Path, dst: &Path) -> 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" {
continue;
}
if file_type.is_dir() && should_skip_dir(&name_str) {
continue;
}
let src_child = entry.path();
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) {
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) -> 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 !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() {
prune_tree(&src_child, &dst_child)?;
}
}
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());
}
}