Final Release of Version 1.0
This commit is contained in:
parent
434fe1721c
commit
425b746780
34 changed files with 3129 additions and 567 deletions
|
|
@ -1,6 +1,6 @@
|
|||
[package]
|
||||
name = "bread-sync"
|
||||
version = "0.1.0"
|
||||
version = "1.0.0"
|
||||
edition = "2021"
|
||||
|
||||
[dependencies]
|
||||
|
|
|
|||
|
|
@ -133,3 +133,125 @@ pub fn expand_path(path: &str) -> PathBuf {
|
|||
}
|
||||
PathBuf::from(path)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use tempfile::TempDir;
|
||||
|
||||
fn sample_config() -> SyncConfig {
|
||||
SyncConfig {
|
||||
remote: RemoteConfig {
|
||||
url: "git@github.com:user/repo.git".to_string(),
|
||||
branch: "main".to_string(),
|
||||
},
|
||||
machine: MachineConfig {
|
||||
name: "host".to_string(),
|
||||
tags: vec!["mobile".to_string()],
|
||||
},
|
||||
packages: PackagesConfig::default(),
|
||||
delegates: DelegatesConfig::default(),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn save_and_load_round_trip() {
|
||||
let tmp = TempDir::new().unwrap();
|
||||
let cfg = sample_config();
|
||||
cfg.save(tmp.path()).unwrap();
|
||||
|
||||
assert!(tmp.path().join("sync.toml").exists());
|
||||
|
||||
let loaded = SyncConfig::load(tmp.path()).unwrap();
|
||||
assert_eq!(loaded.remote.url, cfg.remote.url);
|
||||
assert_eq!(loaded.remote.branch, cfg.remote.branch);
|
||||
assert_eq!(loaded.machine.name, cfg.machine.name);
|
||||
assert_eq!(loaded.machine.tags, cfg.machine.tags);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn load_missing_config_returns_helpful_error() {
|
||||
let tmp = TempDir::new().unwrap();
|
||||
let err = SyncConfig::load(tmp.path()).unwrap_err();
|
||||
let msg = err.to_string();
|
||||
assert!(
|
||||
msg.contains("sync not initialized") || msg.contains("bread sync init"),
|
||||
"expected init hint, got: {msg}",
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn load_invalid_toml_returns_parse_error() {
|
||||
let tmp = TempDir::new().unwrap();
|
||||
std::fs::write(tmp.path().join("sync.toml"), "this is not [valid toml").unwrap();
|
||||
let err = SyncConfig::load(tmp.path()).unwrap_err();
|
||||
let msg = format!("{err:#}");
|
||||
assert!(msg.to_lowercase().contains("parse"), "got: {msg}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn packages_config_default_includes_all_managers() {
|
||||
let cfg = PackagesConfig::default();
|
||||
assert!(cfg.enabled);
|
||||
assert!(cfg.managers.contains(&"pacman".to_string()));
|
||||
assert!(cfg.managers.contains(&"pip".to_string()));
|
||||
assert!(cfg.managers.contains(&"npm".to_string()));
|
||||
assert!(cfg.managers.contains(&"cargo".to_string()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn remote_branch_defaults_to_main_when_omitted() {
|
||||
let raw = r#"
|
||||
[remote]
|
||||
url = "git@example.com:r.git"
|
||||
|
||||
[machine]
|
||||
name = "host"
|
||||
"#;
|
||||
let cfg: SyncConfig = toml::from_str(raw).unwrap();
|
||||
assert_eq!(cfg.remote.branch, "main");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn delegates_default_is_empty() {
|
||||
let cfg = DelegatesConfig::default();
|
||||
assert!(cfg.include.is_empty());
|
||||
assert!(cfg.exclude.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn local_repo_path_resolves_to_data_dir() {
|
||||
let path = SyncConfig::local_repo_path();
|
||||
// Must include the bread sync-repo segment at the end.
|
||||
let suffix = path.iter().rev().take(2).collect::<Vec<_>>();
|
||||
assert_eq!(
|
||||
suffix,
|
||||
vec![
|
||||
std::ffi::OsStr::new("sync-repo"),
|
||||
std::ffi::OsStr::new("bread")
|
||||
]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn expand_path_passes_through_absolute_paths() {
|
||||
assert_eq!(expand_path("/etc/bread"), PathBuf::from("/etc/bread"));
|
||||
assert_eq!(expand_path("relative/path"), PathBuf::from("relative/path"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn expand_path_expands_tilde_alone_to_home() {
|
||||
let home = dirs::home_dir().or_else(|| std::env::var("HOME").ok().map(PathBuf::from));
|
||||
if let Some(home) = home {
|
||||
assert_eq!(expand_path("~"), home);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn expand_path_expands_tilde_prefix() {
|
||||
let home = dirs::home_dir().or_else(|| std::env::var("HOME").ok().map(PathBuf::from));
|
||||
if let Some(home) = home {
|
||||
assert_eq!(expand_path("~/.config"), home.join(".config"));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -23,7 +23,11 @@ fn sync_dir_inner(src: &Path, dst: &Path, root: &Path, patterns: &[Pattern]) ->
|
|||
if dst.exists() {
|
||||
for entry in fs::read_dir(dst)? {
|
||||
let entry = entry?;
|
||||
let rel = entry.path().strip_prefix(dst).unwrap_or(&entry.path()).to_path_buf();
|
||||
let rel = entry
|
||||
.path()
|
||||
.strip_prefix(dst)
|
||||
.unwrap_or(&entry.path())
|
||||
.to_path_buf();
|
||||
let src_counterpart = src.join(&rel);
|
||||
if !src_counterpart.exists() {
|
||||
let p = entry.path();
|
||||
|
|
@ -107,3 +111,137 @@ pub fn resolve_include_paths(includes: &[String]) -> Vec<(String, PathBuf)> {
|
|||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use std::fs;
|
||||
use tempfile::TempDir;
|
||||
|
||||
#[test]
|
||||
fn sync_dir_copies_nested_tree() {
|
||||
let src = TempDir::new().unwrap();
|
||||
let dst = TempDir::new().unwrap();
|
||||
|
||||
fs::create_dir_all(src.path().join("a/b/c")).unwrap();
|
||||
fs::write(src.path().join("a/b/c/leaf.txt"), "hello").unwrap();
|
||||
fs::write(src.path().join("root.txt"), "root").unwrap();
|
||||
|
||||
sync_dir(src.path(), dst.path(), &[]).unwrap();
|
||||
|
||||
assert_eq!(
|
||||
fs::read_to_string(dst.path().join("a/b/c/leaf.txt")).unwrap(),
|
||||
"hello"
|
||||
);
|
||||
assert_eq!(
|
||||
fs::read_to_string(dst.path().join("root.txt")).unwrap(),
|
||||
"root"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sync_dir_overwrites_existing_files() {
|
||||
let src = TempDir::new().unwrap();
|
||||
let dst = TempDir::new().unwrap();
|
||||
fs::write(src.path().join("f"), "new").unwrap();
|
||||
fs::write(dst.path().join("f"), "old").unwrap();
|
||||
|
||||
sync_dir(src.path(), dst.path(), &[]).unwrap();
|
||||
assert_eq!(fs::read_to_string(dst.path().join("f")).unwrap(), "new");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sync_dir_removes_files_no_longer_in_src() {
|
||||
let src = TempDir::new().unwrap();
|
||||
let dst = TempDir::new().unwrap();
|
||||
fs::write(dst.path().join("orphan.txt"), "to remove").unwrap();
|
||||
fs::write(src.path().join("keeper.txt"), "stay").unwrap();
|
||||
|
||||
sync_dir(src.path(), dst.path(), &[]).unwrap();
|
||||
|
||||
assert!(!dst.path().join("orphan.txt").exists());
|
||||
assert!(dst.path().join("keeper.txt").exists());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sync_dir_removes_directories_no_longer_in_src() {
|
||||
let src = TempDir::new().unwrap();
|
||||
let dst = TempDir::new().unwrap();
|
||||
fs::create_dir_all(dst.path().join("ghost-dir")).unwrap();
|
||||
fs::write(dst.path().join("ghost-dir/x"), "").unwrap();
|
||||
|
||||
sync_dir(src.path(), dst.path(), &[]).unwrap();
|
||||
assert!(!dst.path().join("ghost-dir").exists());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sync_dir_exclude_filters_by_basename_pattern() {
|
||||
let src = TempDir::new().unwrap();
|
||||
let dst = TempDir::new().unwrap();
|
||||
fs::write(src.path().join("keep.lua"), "lua").unwrap();
|
||||
fs::write(src.path().join("trash.cache"), "").unwrap();
|
||||
|
||||
sync_dir(src.path(), dst.path(), &["**/*.cache".to_string()]).unwrap();
|
||||
assert!(dst.path().join("keep.lua").exists());
|
||||
assert!(!dst.path().join("trash.cache").exists());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sync_dir_exclude_filters_nested_directory_by_name() {
|
||||
let src = TempDir::new().unwrap();
|
||||
let dst = TempDir::new().unwrap();
|
||||
fs::create_dir_all(src.path().join(".git/objects")).unwrap();
|
||||
fs::write(src.path().join(".git/objects/abc"), "").unwrap();
|
||||
fs::write(src.path().join("init.lua"), "lua").unwrap();
|
||||
|
||||
sync_dir(src.path(), dst.path(), &["**/.git".to_string()]).unwrap();
|
||||
assert!(dst.path().join("init.lua").exists());
|
||||
assert!(!dst.path().join(".git").exists());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sync_dir_creates_destination_if_missing() {
|
||||
let src = TempDir::new().unwrap();
|
||||
let dst_parent = TempDir::new().unwrap();
|
||||
let dst = dst_parent.path().join("brand-new");
|
||||
fs::write(src.path().join("hi"), "hi").unwrap();
|
||||
|
||||
sync_dir(src.path(), &dst, &[]).unwrap();
|
||||
assert!(dst.join("hi").exists());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sync_dir_empty_src_clears_dst() {
|
||||
let src = TempDir::new().unwrap();
|
||||
let dst = TempDir::new().unwrap();
|
||||
fs::write(dst.path().join("a"), "").unwrap();
|
||||
fs::write(dst.path().join("b"), "").unwrap();
|
||||
|
||||
sync_dir(src.path(), dst.path(), &[]).unwrap();
|
||||
let remaining: Vec<_> = fs::read_dir(dst.path()).unwrap().collect();
|
||||
assert!(remaining.is_empty());
|
||||
}
|
||||
|
||||
// ─── resolve_include_paths ────────────────────────────────────────────
|
||||
|
||||
#[test]
|
||||
fn resolve_include_paths_uses_basename_as_key() {
|
||||
let includes = vec!["/etc/foo/bar".to_string(), "/var/lib/quux".to_string()];
|
||||
let resolved = resolve_include_paths(&includes);
|
||||
assert_eq!(resolved.len(), 2);
|
||||
assert_eq!(resolved[0].0, "bar");
|
||||
assert_eq!(resolved[0].1, PathBuf::from("/etc/foo/bar"));
|
||||
assert_eq!(resolved[1].0, "quux");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn resolve_include_paths_expands_tilde_in_source() {
|
||||
let home = dirs::home_dir().or_else(|| std::env::var("HOME").ok().map(PathBuf::from));
|
||||
if let Some(home) = home {
|
||||
let resolved = resolve_include_paths(&["~/Documents".to_string()]);
|
||||
assert_eq!(resolved.len(), 1);
|
||||
assert_eq!(resolved[0].1, home.join("Documents"));
|
||||
assert_eq!(resolved[0].0, "Documents");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -201,9 +201,7 @@ impl SyncRepo {
|
|||
let mut out = Vec::new();
|
||||
for entry in statuses.iter() {
|
||||
let s = entry.status();
|
||||
let ch = if s.contains(git2::Status::INDEX_NEW)
|
||||
|| s.contains(git2::Status::WT_NEW)
|
||||
{
|
||||
let ch = if s.contains(git2::Status::INDEX_NEW) || s.contains(git2::Status::WT_NEW) {
|
||||
'A'
|
||||
} else if s.contains(git2::Status::INDEX_DELETED)
|
||||
|| s.contains(git2::Status::WT_DELETED)
|
||||
|
|
|
|||
|
|
@ -77,3 +77,91 @@ pub fn hostname() -> String {
|
|||
.or_else(|_| std::env::var("HOST"))
|
||||
.unwrap_or_else(|_| "unknown".to_string())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use tempfile::TempDir;
|
||||
|
||||
#[test]
|
||||
fn write_creates_machines_dir_if_missing() {
|
||||
let tmp = TempDir::new().unwrap();
|
||||
let machines = tmp.path().join("does/not/exist/yet");
|
||||
let profile = MachineProfile::new("host".to_string(), vec![]);
|
||||
profile.write(&machines).unwrap();
|
||||
assert!(machines.join("host.toml").exists());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn write_overwrites_existing_profile() {
|
||||
let tmp = TempDir::new().unwrap();
|
||||
let p1 = MachineProfile::new("host".to_string(), vec!["a".to_string()]);
|
||||
p1.write(tmp.path()).unwrap();
|
||||
|
||||
let p2 = MachineProfile::new("host".to_string(), vec!["b".to_string(), "c".to_string()]);
|
||||
p2.write(tmp.path()).unwrap();
|
||||
|
||||
let loaded = MachineProfile::read(tmp.path(), "host").unwrap();
|
||||
assert_eq!(loaded.tags, vec!["b", "c"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn list_returns_empty_when_dir_missing() {
|
||||
let tmp = TempDir::new().unwrap();
|
||||
let missing = tmp.path().join("nope");
|
||||
assert!(MachineProfile::list(&missing).unwrap().is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn list_returns_sorted_profiles_only_for_toml_files() {
|
||||
let tmp = TempDir::new().unwrap();
|
||||
MachineProfile::new("zebra".to_string(), vec![])
|
||||
.write(tmp.path())
|
||||
.unwrap();
|
||||
MachineProfile::new("alpha".to_string(), vec![])
|
||||
.write(tmp.path())
|
||||
.unwrap();
|
||||
MachineProfile::new("middle".to_string(), vec![])
|
||||
.write(tmp.path())
|
||||
.unwrap();
|
||||
// Non-toml file should be ignored.
|
||||
std::fs::write(tmp.path().join("notes.txt"), "ignored").unwrap();
|
||||
|
||||
let list = MachineProfile::list(tmp.path()).unwrap();
|
||||
let names: Vec<&str> = list.iter().map(|m| m.name.as_str()).collect();
|
||||
assert_eq!(names, vec!["alpha", "middle", "zebra"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn list_skips_invalid_toml_files_without_failing() {
|
||||
let tmp = TempDir::new().unwrap();
|
||||
MachineProfile::new("valid".to_string(), vec![])
|
||||
.write(tmp.path())
|
||||
.unwrap();
|
||||
std::fs::write(tmp.path().join("garbage.toml"), "not valid [toml").unwrap();
|
||||
|
||||
let list = MachineProfile::list(tmp.path()).unwrap();
|
||||
assert_eq!(list.len(), 1);
|
||||
assert_eq!(list[0].name, "valid");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn read_returns_helpful_error_when_missing() {
|
||||
let tmp = TempDir::new().unwrap();
|
||||
let err = MachineProfile::read(tmp.path(), "ghost").unwrap_err();
|
||||
assert!(err.to_string().contains("failed to read"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn new_assigns_current_hostname_and_timestamp() {
|
||||
let p = MachineProfile::new("h".to_string(), vec![]);
|
||||
assert!(!p.hostname.is_empty());
|
||||
assert!(chrono::DateTime::parse_from_rfc3339(&p.last_sync).is_ok());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn hostname_returns_non_empty_string() {
|
||||
// Whether libc or env fallback fires, the result must be non-empty.
|
||||
assert!(!hostname().is_empty());
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -19,10 +19,7 @@ pub fn snapshot(manager: &str, dest: &Path) -> Result<bool> {
|
|||
};
|
||||
|
||||
let Some(content) = content else {
|
||||
eprintln!(
|
||||
"bread: package manager '{}' not found, skipping",
|
||||
manager
|
||||
);
|
||||
eprintln!("bread: package manager '{}' not found, skipping", manager);
|
||||
return Ok(false);
|
||||
};
|
||||
|
||||
|
|
@ -86,18 +83,15 @@ pub fn parse_cargo(content: &str) -> Vec<String> {
|
|||
content
|
||||
.lines()
|
||||
.filter(|l| !l.starts_with(' ') && !l.trim().is_empty())
|
||||
.map(|l| {
|
||||
l.split_whitespace()
|
||||
.next()
|
||||
.unwrap_or(l)
|
||||
.to_string()
|
||||
})
|
||||
.map(|l| l.split_whitespace().next().unwrap_or(l).to_string())
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn run_pacman() -> Result<Option<String>> {
|
||||
match Command::new("pacman").arg("-Qe").output() {
|
||||
Ok(out) if out.status.success() => Ok(Some(String::from_utf8_lossy(&out.stdout).to_string())),
|
||||
Ok(out) if out.status.success() => {
|
||||
Ok(Some(String::from_utf8_lossy(&out.stdout).to_string()))
|
||||
}
|
||||
Ok(_) => Ok(None),
|
||||
Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(None),
|
||||
Err(e) => Err(e.into()),
|
||||
|
|
@ -127,7 +121,9 @@ fn run_npm() -> Result<Option<String>> {
|
|||
.args(["list", "-g", "--depth=0", "--parseable"])
|
||||
.output()
|
||||
{
|
||||
Ok(out) if out.status.success() => Ok(Some(String::from_utf8_lossy(&out.stdout).to_string())),
|
||||
Ok(out) if out.status.success() => {
|
||||
Ok(Some(String::from_utf8_lossy(&out.stdout).to_string()))
|
||||
}
|
||||
Ok(_) => Ok(None),
|
||||
Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(None),
|
||||
Err(e) => Err(e.into()),
|
||||
|
|
@ -136,9 +132,114 @@ fn run_npm() -> Result<Option<String>> {
|
|||
|
||||
fn run_cargo() -> Result<Option<String>> {
|
||||
match Command::new("cargo").args(["install", "--list"]).output() {
|
||||
Ok(out) if out.status.success() => Ok(Some(String::from_utf8_lossy(&out.stdout).to_string())),
|
||||
Ok(out) if out.status.success() => {
|
||||
Ok(Some(String::from_utf8_lossy(&out.stdout).to_string()))
|
||||
}
|
||||
Ok(_) => Ok(None),
|
||||
Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(None),
|
||||
Err(e) => Err(e.into()),
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
// ─── parse_pacman ─────────────────────────────────────────────────────
|
||||
|
||||
#[test]
|
||||
fn pacman_parses_each_line_to_first_field() {
|
||||
let input = "firefox 128.0-1\ncurl 8.7.1-1\nrustup 1.27.1-1\n";
|
||||
assert_eq!(parse_pacman(input), vec!["firefox", "curl", "rustup"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pacman_skips_blank_lines() {
|
||||
let input = "firefox 1\n\n \ncurl 2\n";
|
||||
assert_eq!(parse_pacman(input), vec!["firefox", "curl"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pacman_handles_empty_input() {
|
||||
assert!(parse_pacman("").is_empty());
|
||||
assert!(parse_pacman("\n\n\n").is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pacman_handles_single_token_lines() {
|
||||
// A line with no version still yields the package name.
|
||||
assert_eq!(parse_pacman("firefox\n"), vec!["firefox"]);
|
||||
}
|
||||
|
||||
// ─── parse_pip ────────────────────────────────────────────────────────
|
||||
|
||||
#[test]
|
||||
fn pip_strips_eq_and_ge_specifiers() {
|
||||
let input = "requests==2.32.3\nnumpy==2.0.1\nblack>=24.0\n";
|
||||
assert_eq!(parse_pip(input), vec!["requests", "numpy", "black"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pip_skips_comments_and_blank_lines() {
|
||||
let input = "# editable install\n\nflake8==1.0\n# trailing\n";
|
||||
assert_eq!(parse_pip(input), vec!["flake8"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pip_handles_package_without_specifier() {
|
||||
assert_eq!(parse_pip("requests\nblack\n"), vec!["requests", "black"]);
|
||||
}
|
||||
|
||||
// ─── parse_npm ────────────────────────────────────────────────────────
|
||||
|
||||
#[test]
|
||||
fn npm_extracts_basename_from_paths() {
|
||||
let input = "/usr/lib/node_modules/npm\n/usr/lib/node_modules/typescript\n/usr/lib/node_modules/yarn\n";
|
||||
let pkgs = parse_npm(input);
|
||||
assert!(pkgs.contains(&"npm".to_string()));
|
||||
assert!(pkgs.contains(&"typescript".to_string()));
|
||||
assert!(pkgs.contains(&"yarn".to_string()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn npm_skips_root_node_modules_entry() {
|
||||
let input = "/usr/lib/node_modules\n/usr/lib/node_modules/typescript\n";
|
||||
assert_eq!(parse_npm(input), vec!["typescript"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn npm_handles_empty_input() {
|
||||
assert!(parse_npm("").is_empty());
|
||||
}
|
||||
|
||||
// ─── parse_cargo ──────────────────────────────────────────────────────
|
||||
|
||||
#[test]
|
||||
fn cargo_extracts_crate_names_from_install_list_output() {
|
||||
let input = "bottom v0.9.6:\n btm\nripgrep v14.0.3:\n rg\nbat v0.24.0:\n bat\n";
|
||||
assert_eq!(parse_cargo(input), vec!["bottom", "ripgrep", "bat"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cargo_skips_binary_lines() {
|
||||
// Indented lines are binaries inside a crate.
|
||||
let input = "alpha v1.0.0:\n bin1\n bin2\nbeta v2.0.0:\n bin3\n";
|
||||
assert_eq!(parse_cargo(input), vec!["alpha", "beta"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cargo_handles_empty_input() {
|
||||
assert!(parse_cargo("").is_empty());
|
||||
}
|
||||
|
||||
// ─── snapshot dispatch ────────────────────────────────────────────────
|
||||
|
||||
#[test]
|
||||
fn snapshot_unknown_manager_returns_false_without_writing() {
|
||||
let tmp = tempfile::TempDir::new().unwrap();
|
||||
let dest = tmp.path().join("out.txt");
|
||||
let wrote = snapshot("definitely-not-a-pkg-mgr", &dest).unwrap();
|
||||
assert!(!wrote);
|
||||
assert!(!dest.exists());
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -84,7 +84,8 @@ fn sync_push_creates_correct_directory_structure() {
|
|||
|
||||
// Init local sync repo
|
||||
let repo = SyncRepo::init(repo_tmp.path()).unwrap();
|
||||
repo.set_remote("origin", bare_tmp.path().to_str().unwrap()).unwrap();
|
||||
repo.set_remote("origin", bare_tmp.path().to_str().unwrap())
|
||||
.unwrap();
|
||||
|
||||
// Snapshot bread dir
|
||||
let bread_dest = repo_tmp.path().join("bread");
|
||||
|
|
@ -102,7 +103,11 @@ fn sync_push_creates_correct_directory_structure() {
|
|||
// Verify structure in local repo
|
||||
assert!(repo_tmp.path().join("bread").exists());
|
||||
assert!(repo_tmp.path().join("bread").join("init.lua").exists());
|
||||
assert!(repo_tmp.path().join("machines").join("testbox.toml").exists());
|
||||
assert!(repo_tmp
|
||||
.path()
|
||||
.join("machines")
|
||||
.join("testbox.toml")
|
||||
.exists());
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
|
@ -123,7 +128,8 @@ fn sync_push_snapshots_bread_config() {
|
|||
.unwrap();
|
||||
|
||||
let repo = SyncRepo::init(repo_tmp.path()).unwrap();
|
||||
repo.set_remote("origin", bare_tmp.path().to_str().unwrap()).unwrap();
|
||||
repo.set_remote("origin", bare_tmp.path().to_str().unwrap())
|
||||
.unwrap();
|
||||
|
||||
let bread_dest = repo_tmp.path().join("bread");
|
||||
delegates::sync_dir(bread_cfg_tmp.path(), &bread_dest, &[]).unwrap();
|
||||
|
|
@ -149,7 +155,8 @@ fn sync_pull_copies_files_from_repo() {
|
|||
|
||||
// Create a local repo, add some files, push to bare
|
||||
let repo = SyncRepo::init(local_tmp.path()).unwrap();
|
||||
repo.set_remote("origin", bare_tmp.path().to_str().unwrap()).unwrap();
|
||||
repo.set_remote("origin", bare_tmp.path().to_str().unwrap())
|
||||
.unwrap();
|
||||
|
||||
let bread_dest = local_tmp.path().join("bread");
|
||||
fs::create_dir_all(&bread_dest).unwrap();
|
||||
|
|
@ -160,7 +167,8 @@ fn sync_pull_copies_files_from_repo() {
|
|||
|
||||
// Now clone the bare repo and pull
|
||||
let clone_tmp = TempDir::new().unwrap();
|
||||
let cloned = SyncRepo::clone_from(bare_tmp.path().to_str().unwrap(), clone_tmp.path()).unwrap();
|
||||
let _cloned =
|
||||
SyncRepo::clone_from(bare_tmp.path().to_str().unwrap(), clone_tmp.path()).unwrap();
|
||||
|
||||
// Apply bread/ to apply_tmp
|
||||
let src = clone_tmp.path().join("bread");
|
||||
|
|
@ -255,3 +263,220 @@ fn push_with_no_changes_returns_none() {
|
|||
result
|
||||
);
|
||||
}
|
||||
|
||||
// ─── git.rs additional coverage ────────────────────────────────────────────
|
||||
|
||||
#[test]
|
||||
fn init_creates_repo_with_main_branch() {
|
||||
let tmp = TempDir::new().unwrap();
|
||||
let repo = SyncRepo::init(tmp.path()).unwrap();
|
||||
fs::write(tmp.path().join("x"), "").unwrap();
|
||||
repo.stage_all().unwrap();
|
||||
let oid = repo.commit("initial").unwrap();
|
||||
assert!(oid.is_some(), "first commit should succeed");
|
||||
|
||||
// Verify HEAD is on refs/heads/main.
|
||||
let head_ref = std::process::Command::new("git")
|
||||
.args(["-C", tmp.path().to_str().unwrap(), "symbolic-ref", "HEAD"])
|
||||
.output()
|
||||
.unwrap();
|
||||
let head_name = String::from_utf8_lossy(&head_ref.stdout);
|
||||
assert!(
|
||||
head_name.trim() == "refs/heads/main",
|
||||
"expected refs/heads/main, got {head_name}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn open_or_clone_opens_existing_repo() {
|
||||
let tmp = TempDir::new().unwrap();
|
||||
SyncRepo::init(tmp.path()).unwrap();
|
||||
|
||||
// Calling open_or_clone on an existing path must not attempt to clone.
|
||||
let again = SyncRepo::open_or_clone("/nonexistent-url-that-would-fail", tmp.path());
|
||||
assert!(again.is_ok());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn open_or_clone_clones_into_missing_path() {
|
||||
let bare = TempDir::new().unwrap();
|
||||
let bare_repo = make_bare_repo(bare.path());
|
||||
// Seed the bare repo with at least one commit so a clone is meaningful.
|
||||
let local = TempDir::new().unwrap();
|
||||
let repo = SyncRepo::init(local.path()).unwrap();
|
||||
fs::write(local.path().join("seed"), "x").unwrap();
|
||||
repo.commit("seed").unwrap();
|
||||
repo.set_remote("origin", bare.path().to_str().unwrap())
|
||||
.unwrap();
|
||||
repo.push("origin", "main").unwrap();
|
||||
drop(bare_repo);
|
||||
|
||||
let dest_parent = TempDir::new().unwrap();
|
||||
let dest = dest_parent.path().join("clone-target");
|
||||
let cloned = SyncRepo::open_or_clone(bare.path().to_str().unwrap(), &dest).unwrap();
|
||||
assert_eq!(cloned.path, dest);
|
||||
assert!(dest.join("seed").exists());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn local_changes_reports_new_modified_and_deleted() {
|
||||
let tmp = TempDir::new().unwrap();
|
||||
let repo = init_repo_with_commit(tmp.path());
|
||||
|
||||
fs::write(tmp.path().join("added.txt"), "new").unwrap();
|
||||
fs::write(tmp.path().join(".gitkeep"), "modified").unwrap();
|
||||
|
||||
let changes = repo.local_changes().unwrap();
|
||||
assert!(!changes.is_empty());
|
||||
let kinds: Vec<char> = changes.iter().map(|(c, _)| *c).collect();
|
||||
assert!(kinds.contains(&'A'));
|
||||
assert!(kinds.contains(&'M'));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn is_clean_after_commit() {
|
||||
let tmp = TempDir::new().unwrap();
|
||||
let repo = init_repo_with_commit(tmp.path());
|
||||
assert!(repo.is_clean().unwrap());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn working_diff_includes_modified_tracked_content() {
|
||||
let tmp = TempDir::new().unwrap();
|
||||
let repo = init_repo_with_commit(tmp.path());
|
||||
// Modify an already-tracked file so it appears in `git diff HEAD`.
|
||||
fs::write(tmp.path().join(".gitkeep"), "tracked change\n").unwrap();
|
||||
|
||||
let diff = repo.working_diff().unwrap();
|
||||
assert!(
|
||||
diff.contains("tracked change"),
|
||||
"diff did not include tracked change, diff was: {diff:?}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn working_diff_empty_when_only_untracked_files() {
|
||||
let tmp = TempDir::new().unwrap();
|
||||
let repo = init_repo_with_commit(tmp.path());
|
||||
fs::write(tmp.path().join("new-untracked.txt"), "hi").unwrap();
|
||||
|
||||
// working_diff uses diff_tree_to_workdir_with_index without INCLUDE_UNTRACKED,
|
||||
// so untracked files don't appear — local_changes is the right tool for that.
|
||||
let diff = repo.working_diff().unwrap();
|
||||
assert!(
|
||||
diff.is_empty() || !diff.contains("new-untracked"),
|
||||
"expected untracked file to be excluded, diff was: {diff:?}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn set_remote_overwrites_existing_remote() {
|
||||
let tmp = TempDir::new().unwrap();
|
||||
let repo = SyncRepo::init(tmp.path()).unwrap();
|
||||
repo.set_remote("origin", "https://example.com/a.git")
|
||||
.unwrap();
|
||||
// A second call must not error out — it should replace the previous URL.
|
||||
repo.set_remote("origin", "https://example.com/b.git")
|
||||
.unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn last_commit_time_returns_none_for_empty_repo() {
|
||||
let tmp = TempDir::new().unwrap();
|
||||
let repo = SyncRepo::init(tmp.path()).unwrap();
|
||||
assert!(repo.last_commit_time().is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn last_commit_time_present_after_commit() {
|
||||
let tmp = TempDir::new().unwrap();
|
||||
let repo = init_repo_with_commit(tmp.path());
|
||||
assert!(repo.last_commit_time().is_some());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn push_pull_round_trip_through_bare_remote() {
|
||||
let bare = TempDir::new().unwrap();
|
||||
make_bare_repo(bare.path());
|
||||
|
||||
// Push from author repo.
|
||||
let author = TempDir::new().unwrap();
|
||||
let r1 = SyncRepo::init(author.path()).unwrap();
|
||||
r1.set_remote("origin", bare.path().to_str().unwrap())
|
||||
.unwrap();
|
||||
fs::write(author.path().join("note.txt"), "v1").unwrap();
|
||||
r1.commit("v1").unwrap();
|
||||
r1.push("origin", "main").unwrap();
|
||||
|
||||
// Clone into reader repo and confirm contents.
|
||||
let reader_tmp = TempDir::new().unwrap();
|
||||
let r2 = SyncRepo::clone_from(bare.path().to_str().unwrap(), reader_tmp.path()).unwrap();
|
||||
assert_eq!(
|
||||
fs::read_to_string(reader_tmp.path().join("note.txt")).unwrap(),
|
||||
"v1"
|
||||
);
|
||||
|
||||
// Author writes a second version and pushes.
|
||||
fs::write(author.path().join("note.txt"), "v2").unwrap();
|
||||
r1.commit("v2").unwrap();
|
||||
r1.push("origin", "main").unwrap();
|
||||
|
||||
// Reader pulls and sees the new content.
|
||||
r2.pull("origin", "main").unwrap();
|
||||
assert_eq!(
|
||||
fs::read_to_string(reader_tmp.path().join("note.txt")).unwrap(),
|
||||
"v2"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pull_with_no_remote_changes_is_noop() {
|
||||
let bare = TempDir::new().unwrap();
|
||||
make_bare_repo(bare.path());
|
||||
|
||||
let local = TempDir::new().unwrap();
|
||||
let repo = SyncRepo::init(local.path()).unwrap();
|
||||
repo.set_remote("origin", bare.path().to_str().unwrap())
|
||||
.unwrap();
|
||||
fs::write(local.path().join("a"), "1").unwrap();
|
||||
repo.commit("c1").unwrap();
|
||||
repo.push("origin", "main").unwrap();
|
||||
|
||||
// Calling pull immediately after push must be up-to-date and succeed.
|
||||
repo.pull("origin", "main").unwrap();
|
||||
assert!(repo.is_clean().unwrap());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn remote_changes_returns_empty_when_remote_unknown() {
|
||||
let tmp = TempDir::new().unwrap();
|
||||
let repo = init_repo_with_commit(tmp.path());
|
||||
let changes = repo.remote_changes("origin", "main").unwrap();
|
||||
assert!(changes.is_empty());
|
||||
}
|
||||
|
||||
// ─── machine list ──────────────────────────────────────────────────────────
|
||||
|
||||
#[test]
|
||||
fn machine_list_returns_all_profiles_sorted() {
|
||||
let machines_tmp = TempDir::new().unwrap();
|
||||
for name in ["delta", "alpha", "charlie", "bravo"] {
|
||||
machine::MachineProfile::new(name.to_string(), vec![])
|
||||
.write(machines_tmp.path())
|
||||
.unwrap();
|
||||
}
|
||||
let list = machine::MachineProfile::list(machines_tmp.path()).unwrap();
|
||||
let names: Vec<&str> = list.iter().map(|m| m.name.as_str()).collect();
|
||||
assert_eq!(names, vec!["alpha", "bravo", "charlie", "delta"]);
|
||||
}
|
||||
|
||||
// ─── packages snapshot ─────────────────────────────────────────────────────
|
||||
|
||||
#[test]
|
||||
fn snapshot_writes_destination_when_manager_unknown_is_skipped() {
|
||||
let dest_tmp = TempDir::new().unwrap();
|
||||
let dest = dest_tmp.path().join("nested/dir/file.txt");
|
||||
let wrote = packages::snapshot("does-not-exist", &dest).unwrap();
|
||||
assert!(!wrote);
|
||||
assert!(!dest.exists());
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue