use crate::config::{Dependency, DevKitConfig};
use crate::fetch::{DependencyFetcher, FetchError, FetchOutcome, GitFetcher};
use crate::lock::{lock_dependencies_resolved, read_lock, write_lock, DependencyLock, LockFile};
use std::path::{Path, PathBuf};
use std::process::Command;
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum UpdateStatus {
Unchanged,
Updated,
Pinned,
Constraint,
Error,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct UpdateChange {
pub name: String,
pub source: String,
pub status: UpdateStatus,
pub old_commit: String,
pub new_commit: String,
pub detail: String,
pub resolved_tag: Option<String>,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct UpdateReport {
pub checked: usize,
pub updated: usize,
pub unchanged: usize,
pub changes: Vec<UpdateChange>,
}
#[derive(Debug)]
pub enum SyncError {
InvalidManifest(String),
MissingLock,
GitUnavailable,
Git {
args: String,
status: Option<i32>,
stderr: String,
},
UnknownReference(String),
NetworkFailure(String),
DetachedBranch(String),
Io {
context: String,
source: std::io::Error,
},
}
impl std::fmt::Display for SyncError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
SyncError::InvalidManifest(m) => write!(f, "invalid manifest: {}", m),
SyncError::MissingLock => {
write!(f, "no sdkt.lock found; run `sdkt package fetch` first")
}
SyncError::GitUnavailable => {
write!(
f,
"the `git` executable is required but was not found on PATH"
)
}
SyncError::Git {
args,
status,
stderr,
} => write!(
f,
"git {} failed (exit {:?}): {}",
args,
status,
stderr.trim()
),
SyncError::UnknownReference(r) => write!(f, "unknown git reference: {}", r),
SyncError::NetworkFailure(m) => {
write!(f, "network failure contacting git remote: {}", m)
}
SyncError::DetachedBranch(n) => write!(
f,
"dependency '{}' cache is detached (not on its declared branch)",
n
),
SyncError::Io { context, source } => write!(f, "I/O error {}: {}", context, source),
}
}
}
impl std::error::Error for SyncError {}
impl From<FetchError> for SyncError {
fn from(e: FetchError) -> Self {
match e {
FetchError::GitUnavailable => SyncError::GitUnavailable,
FetchError::Git {
args,
status,
stderr,
} => {
if stderr.contains("Could not resolve host")
|| stderr.contains("timed out")
|| stderr.contains("Connection refused")
|| stderr.contains("unable to access")
{
SyncError::NetworkFailure(format!("git {} failed: {}", args, stderr.trim()))
} else {
SyncError::Git {
args,
status,
stderr,
}
}
}
other => SyncError::Git {
args: String::new(),
status: None,
stderr: other.to_string(),
},
}
}
}
fn cache_root(base: &Path) -> PathBuf {
base.join(".sdkt-cache")
}
fn git_checkout(base: &Path, url: &str, reference: &str) -> PathBuf {
let key = crate::fetch::git_cache_key(&crate::config::Dependency {
git: Some(url.to_string()),
tag: if reference.starts_with("tag:") {
Some(reference.trim_start_matches("tag:").to_string())
} else {
None
},
branch: if reference.starts_with("branch:") {
Some(reference.trim_start_matches("branch:").to_string())
} else {
None
},
rev: if reference.starts_with("rev:") {
Some(reference.trim_start_matches("rev:").to_string())
} else {
None
},
..Default::default()
});
cache_root(base).join("git").join(&key)
}
fn resolve_available_commit(
url: &str,
tag: &Option<String>,
branch: &Option<String>,
rev: &Option<String>,
) -> Result<String, SyncError> {
if let Some(r) = rev {
return Ok(r.clone());
}
let reference = tag
.as_ref()
.map(|t| format!("refs/tags/{}", t))
.or_else(|| branch.as_ref().map(|b| format!("refs/heads/{}", b)))
.unwrap_or_default();
if reference.is_empty() {
return Err(SyncError::UnknownReference(
"dependency declares a git source without tag/branch/rev".to_string(),
));
}
let probe = Command::new(crate::fetch::git_bin())
.arg("--version")
.output();
if probe.is_err() || !probe.unwrap().status.success() {
return Err(SyncError::GitUnavailable);
}
let out = Command::new(crate::fetch::git_bin())
.args(["ls-remote", url, &reference])
.output();
match out {
Ok(o) if o.status.success() => {
let stdout = String::from_utf8_lossy(&o.stdout);
let line = stdout.lines().next().unwrap_or("").trim().to_string();
if line.is_empty() {
return Err(SyncError::UnknownReference(reference));
}
let sha = line.split('\t').next().unwrap_or("").trim().to_string();
if sha.is_empty() {
return Err(SyncError::UnknownReference(reference));
}
Ok(sha)
}
Ok(o) => {
let stderr = String::from_utf8_lossy(&o.stderr).to_string();
if stderr.contains("Could not resolve host")
|| stderr.contains("timed out")
|| stderr.contains("Connection refused")
|| stderr.contains("unable to access")
{
Err(SyncError::NetworkFailure(stderr))
} else {
Err(SyncError::Git {
args: format!("ls-remote {} {}", url, reference),
status: o.status.code(),
stderr,
})
}
}
Err(_) => Err(SyncError::GitUnavailable),
}
}
fn list_remote_tags(url: &str) -> Result<Vec<(String, String)>, SyncError> {
let probe = Command::new(crate::fetch::git_bin())
.arg("--version")
.output();
if probe.is_err() || !probe.unwrap().status.success() {
return Err(SyncError::GitUnavailable);
}
let out = Command::new(crate::fetch::git_bin())
.args(["ls-remote", "--tags", url])
.output();
match out {
Ok(o) if o.status.success() => {
let stdout = String::from_utf8_lossy(&o.stdout);
let mut tags = Vec::new();
for line in stdout.lines() {
let (sha, r#ref) = match line.split_once('\t') {
Some(x) => x,
None => continue,
};
if r#ref.ends_with("^{}") {
continue;
}
let tag = match r#ref.strip_prefix("refs/tags/") {
Some(t) => t.to_string(),
None => continue,
};
if sha.trim().is_empty() {
continue;
}
tags.push((tag, sha.trim().to_string()));
}
Ok(tags)
}
Ok(o) => {
let stderr = String::from_utf8_lossy(&o.stderr).to_string();
if stderr.contains("Could not resolve host")
|| stderr.contains("timed out")
|| stderr.contains("Connection refused")
|| stderr.contains("unable to access")
{
Err(SyncError::NetworkFailure(stderr))
} else {
Err(SyncError::Git {
args: format!("ls-remote --tags {}", url),
status: o.status.code(),
stderr,
})
}
}
Err(_) => Err(SyncError::GitUnavailable),
}
}
pub(crate) fn resolve_version_constraint(
url: &str,
constraint: &str,
) -> Result<Option<(String, String)>, SyncError> {
let tags = list_remote_tags(url)?;
Ok(crate::package::best_version_match(&tags, constraint))
}
fn git_cache_exists(base: &Path, dep: &crate::config::Dependency) -> bool {
let url = dep.git.clone().unwrap_or_default();
let reference = dep
.tag
.clone()
.map(|t| format!("tag:{}", t))
.or_else(|| dep.branch.clone().map(|b| format!("branch:{}", b)))
.or_else(|| dep.rev.clone().map(|r| format!("rev:{}", r)))
.unwrap_or_default();
let checkout = git_checkout(base, &url, &reference);
checkout.join(".git").exists()
}
pub fn plan_updates(base: &Path, config: &DevKitConfig) -> Result<UpdateReport, SyncError> {
if let Err(e) = crate::package::validate_manifest(base, config) {
return Err(SyncError::InvalidManifest(e.to_string()));
}
let lock = read_lock(base).map_err(|_| SyncError::MissingLock)?;
let locked: std::collections::HashMap<&str, &DependencyLock> = lock
.dependencies
.iter()
.map(|d| (d.name.as_str(), d))
.collect();
let mut changes = Vec::new();
for (name, dep) in &config.dependencies {
let old = locked
.get(name.as_str())
.map(|d| d.commit_sha.clone())
.unwrap_or_default();
if dep.git.is_none() {
changes.push(UpdateChange {
name: name.clone(),
source: "local".to_string(),
status: UpdateStatus::Unchanged,
old_commit: String::new(),
new_commit: String::new(),
detail: "local path dependency (no remote update)".to_string(),
resolved_tag: None,
});
continue;
}
let url = dep.git.clone().unwrap_or_default();
if dep.rev.is_some() {
changes.push(UpdateChange {
name: name.clone(),
source: "git".to_string(),
status: UpdateStatus::Pinned,
old_commit: old,
new_commit: dep.rev.clone().unwrap_or_default(),
detail: "pinned to rev (immutable)".to_string(),
resolved_tag: None,
});
continue;
}
if dep.version.is_some() && dep.tag.is_none() && dep.branch.is_none() && dep.rev.is_none() {
let constraint = dep.version.clone().unwrap();
match resolve_version_constraint(&url, &constraint) {
Ok(Some((tag, commit))) => {
let changed = !old.is_empty() && commit != old;
let first_lock = old.is_empty();
let (status, detail) = if first_lock {
(
UpdateStatus::Updated,
format!(
"not yet locked — would record {} (satisfies '{}')",
tag, constraint
),
)
} else if changed {
(
UpdateStatus::Updated,
format!("update-available ({} satisfies '{}')", tag, constraint),
)
} else {
(
UpdateStatus::Unchanged,
format!("up-to-date ({} satisfies '{}')", tag, constraint),
)
};
changes.push(UpdateChange {
name: name.clone(),
source: "git".to_string(),
status,
old_commit: old,
new_commit: commit,
detail,
resolved_tag: Some(tag),
});
}
Ok(None) => {
changes.push(UpdateChange {
name: name.clone(),
source: "git".to_string(),
status: UpdateStatus::Constraint,
old_commit: old,
new_commit: String::new(),
detail: format!(
"constraint '{}' unsatisfied by available tags",
constraint
),
resolved_tag: None,
});
}
Err(e) => {
let mut c = UpdateChange {
name: name.clone(),
source: "git".to_string(),
status: UpdateStatus::Error,
old_commit: old,
new_commit: String::new(),
detail: e.to_string(),
resolved_tag: None,
};
if !git_cache_exists(base, dep) {
c.detail = format!("missing cache (run `sdkt package fetch`): {}", e);
}
changes.push(c);
}
}
continue;
}
let available = match resolve_available_commit(&url, &dep.tag, &dep.branch, &dep.rev) {
Ok(c) => c,
Err(e) => {
changes.push(UpdateChange {
name: name.clone(),
source: "git".to_string(),
status: UpdateStatus::Error,
old_commit: old,
new_commit: String::new(),
detail: e.to_string(),
resolved_tag: None,
});
if !git_cache_exists(base, dep) {
changes.last_mut().unwrap().detail =
format!("missing cache (run `sdkt package fetch`): {}", e);
}
continue;
}
};
let changed = !old.is_empty() && available != old;
let first_lock = old.is_empty();
if changed || first_lock {
let detail = if first_lock {
"not yet locked — would record available commit".to_string()
} else {
format!(
"available commit {} != locked {}",
&available[..available.len().min(12)],
&old[..old.len().min(12)]
)
};
changes.push(UpdateChange {
name: name.clone(),
source: "git".to_string(),
status: UpdateStatus::Updated,
old_commit: old,
new_commit: available,
detail,
resolved_tag: None,
});
} else {
changes.push(UpdateChange {
name: name.clone(),
source: "git".to_string(),
status: UpdateStatus::Unchanged,
old_commit: old,
new_commit: available,
detail: "already at latest".to_string(),
resolved_tag: None,
});
}
}
Ok(summarize(changes))
}
pub fn apply_updates(
base: &Path,
config: &DevKitConfig,
) -> Result<(UpdateReport, LockFile), SyncError> {
if let Err(e) = crate::package::validate_manifest(base, config) {
return Err(SyncError::InvalidManifest(e.to_string()));
}
let plan = plan_updates(base, config)?;
let mut fetched: Vec<FetchOutcome> = Vec::new();
let cache = cache_root(base);
let fetcher = GitFetcher::new(cache);
for change in &plan.changes {
if change.status != UpdateStatus::Updated {
continue;
}
let Some(dep) = config.dependencies.get(&change.name) else {
continue;
};
if dep.git.is_none() {
continue;
}
let fetch_dep = if let Some(tag) = &change.resolved_tag {
Dependency {
git: dep.git.clone(),
tag: Some(tag.clone()),
branch: None,
rev: None,
version: None,
..Default::default()
}
} else {
dep.clone()
};
if fetch_dep.branch.is_some() {
let checkout = {
let url = fetch_dep.git.clone().unwrap_or_default();
let reference = format!("branch:{}", fetch_dep.branch.clone().unwrap());
git_checkout(base, &url, &reference)
};
if checkout.join(".git").exists() {
let sym = Command::new(crate::fetch::git_bin())
.current_dir(&checkout)
.args(["symbolic-ref", "-q", "HEAD"])
.output();
let on_branch = sym.map(|o| o.status.success()).unwrap_or(false);
if !on_branch {
return Err(SyncError::DetachedBranch(change.name.clone()));
}
}
}
let outcome = fetcher.fetch(&change.name, &fetch_dep, true)?;
fetched.push(outcome);
}
let mut lock = read_lock(base).map_err(|_| SyncError::MissingLock)?;
let new_deps = lock_dependencies_resolved(base, config, &fetched);
lock.dependencies = new_deps;
write_lock(base, &lock).map_err(|e| SyncError::Io {
context: format!("writing sdkt.lock: {}", e),
source: std::io::Error::other(e.to_string()),
})?;
Ok((plan, lock))
}
fn summarize(changes: Vec<UpdateChange>) -> UpdateReport {
let checked = changes.len();
let updated = changes
.iter()
.filter(|c| c.status == UpdateStatus::Updated)
.count();
let unchanged = changes
.iter()
.filter(|c| {
c.status == UpdateStatus::Unchanged
|| c.status == UpdateStatus::Pinned
|| c.status == UpdateStatus::Error
})
.count();
UpdateReport {
checked,
updated,
unchanged,
changes,
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::config::{Dependency, PackageConfig};
use std::collections::HashMap;
use std::io::Write;
fn git_cmd(dir: &Path) -> Command {
let mut c = Command::new("git");
c.current_dir(dir)
.env("GIT_CONFIG_COUNT", "1")
.env("GIT_CONFIG_KEY_0", "safe.directory")
.env("GIT_CONFIG_VALUE_0", "*");
c
}
fn fresh_repo_dir() -> PathBuf {
use std::sync::atomic::{AtomicU64, Ordering};
static COUNTER: AtomicU64 = AtomicU64::new(0);
let n = COUNTER.fetch_add(1, Ordering::Relaxed);
let nanos = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap()
.as_nanos();
let dir = std::env::temp_dir().join(format!(
"sdkt-sync-src-{}-{}-{}",
std::process::id(),
nanos,
n
));
let _ = std::fs::remove_dir_all(&dir);
std::fs::create_dir_all(&dir).unwrap();
dir
}
fn commit(repo: &Path, content: &[u8]) {
{
let mut fh = std::fs::File::create(repo.join("lib.rs")).unwrap();
fh.write_all(content).unwrap();
}
git_cmd(repo).args(["add", "lib.rs"]).output().unwrap();
git_cmd(repo)
.args(["commit", "-q", "-m", "update"])
.output()
.unwrap();
}
fn make_repo() -> PathBuf {
let dir = fresh_repo_dir();
git_cmd(&dir).args(["init", "-q"]).output().unwrap();
git_cmd(&dir)
.args(["config", "user.email", "t@sdkt.local"])
.output()
.unwrap();
git_cmd(&dir)
.args(["config", "user.name", "sdkt test"])
.output()
.unwrap();
commit(&dir, b"pub fn answer() -> u32 { 42 }");
dir
}
fn head(repo: &Path) -> String {
let o = git_cmd(repo).args(["rev-parse", "HEAD"]).output().unwrap();
String::from_utf8_lossy(&o.stdout).trim().to_string()
}
fn config_with(name: &str, dep: Dependency) -> DevKitConfig {
let mut deps = HashMap::new();
deps.insert(name.to_string(), dep);
DevKitConfig {
package: Some(PackageConfig {
name: Some("app".to_string()),
version: Some("0.1.0".to_string()),
description: None,
}),
dependencies: deps,
..Default::default()
}
}
fn write_lock_with(base: &Path, deps: Vec<DependencyLock>) {
let lock = LockFile {
version: crate::lock::LOCK_VERSION,
deploy_order: vec![],
contracts: vec![],
dependencies: deps,
};
crate::lock::write_lock(base, &lock).unwrap();
}
fn git_dep(
url: &str,
tag: Option<&str>,
branch: Option<&str>,
rev: Option<&str>,
) -> Dependency {
Dependency {
git: Some(url.to_string()),
tag: tag.map(|s| s.to_string()),
branch: branch.map(|s| s.to_string()),
rev: rev.map(|s| s.to_string()),
..Default::default()
}
}
fn git_dep_version(url: &str, version: &str) -> Dependency {
Dependency {
git: Some(url.to_string()),
version: Some(version.to_string()),
..Default::default()
}
}
#[test]
fn plan_pinned_rev_never_updates() {
let src = make_repo();
let url = src.to_string_lossy().to_string();
let pin = head(&src);
commit(&src, b"pub fn answer() -> u32 { 43 }");
let cfg = config_with("dep", git_dep(&url, None, None, Some(&pin)));
let base = std::env::temp_dir().join(format!(
"sdkt-sync-rev-{}",
std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap()
.as_nanos()
));
let _ = std::fs::remove_dir_all(&base);
std::fs::create_dir_all(&base).unwrap();
write_lock_with(
&base,
vec![DependencyLock {
name: "dep".to_string(),
source: "git".to_string(),
git_url: url.clone(),
commit_sha: pin.clone(),
..Default::default()
}],
);
let rep = plan_updates(&base, &cfg).unwrap();
assert_eq!(rep.changes.len(), 1);
assert_eq!(rep.changes[0].status, UpdateStatus::Pinned);
assert_eq!(rep.changes[0].new_commit, pin);
let _ = std::fs::remove_dir_all(&base);
let _ = std::fs::remove_dir_all(&src);
}
#[test]
fn plan_tag_update_detected() {
let src = make_repo();
let url = src.to_string_lossy().to_string();
git_cmd(&src).args(["tag", "v1.0.0"]).output().unwrap();
let v1 = head(&src);
commit(&src, b"pub fn answer() -> u32 { 43 }");
let v2 = head(&src);
git_cmd(&src)
.args(["tag", "-f", "v1.0.0"])
.output()
.unwrap();
let cfg = config_with("dep", git_dep(&url, Some("v1.0.0"), None, None));
let base = std::env::temp_dir().join(format!(
"sdkt-sync-tag-{}",
std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap()
.as_nanos()
));
let _ = std::fs::remove_dir_all(&base);
std::fs::create_dir_all(&base).unwrap();
write_lock_with(
&base,
vec![DependencyLock {
name: "dep".to_string(),
source: "git".to_string(),
git_url: url.clone(),
commit_sha: v1,
..Default::default()
}],
);
let rep = plan_updates(&base, &cfg).unwrap();
assert_eq!(rep.changes.len(), 1);
assert_eq!(rep.changes[0].status, UpdateStatus::Updated);
assert_eq!(rep.changes[0].new_commit, v2);
let _ = std::fs::remove_dir_all(&base);
let _ = std::fs::remove_dir_all(&src);
}
#[test]
fn plan_branch_update_detected() {
let src = make_repo();
let url = src.to_string_lossy().to_string();
git_cmd(&src)
.args(["branch", "-M", "main"])
.output()
.unwrap();
let old = head(&src);
commit(&src, b"pub fn answer() -> u32 { 43 }");
let new = head(&src);
let cfg = config_with("dep", git_dep(&url, None, Some("main"), None));
let base = std::env::temp_dir().join(format!(
"sdkt-sync-branch-{}",
std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap()
.as_nanos()
));
let _ = std::fs::remove_dir_all(&base);
std::fs::create_dir_all(&base).unwrap();
write_lock_with(
&base,
vec![DependencyLock {
name: "dep".to_string(),
source: "git".to_string(),
git_url: url.clone(),
commit_sha: old,
..Default::default()
}],
);
let rep = plan_updates(&base, &cfg).unwrap();
assert_eq!(rep.changes[0].status, UpdateStatus::Updated);
assert_eq!(rep.changes[0].new_commit, new);
let _ = std::fs::remove_dir_all(&base);
let _ = std::fs::remove_dir_all(&src);
}
#[test]
fn plan_unchanged_when_lock_matches() {
let src = make_repo();
let url = src.to_string_lossy().to_string();
git_cmd(&src).args(["tag", "v1.0.0"]).output().unwrap();
let v1 = head(&src);
let cfg = config_with("dep", git_dep(&url, Some("v1.0.0"), None, None));
let base = std::env::temp_dir().join(format!(
"sdkt-sync-unch-{}",
std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap()
.as_nanos()
));
let _ = std::fs::remove_dir_all(&base);
std::fs::create_dir_all(&base).unwrap();
write_lock_with(
&base,
vec![DependencyLock {
name: "dep".to_string(),
source: "git".to_string(),
git_url: url.clone(),
commit_sha: v1,
..Default::default()
}],
);
let rep = plan_updates(&base, &cfg).unwrap();
assert_eq!(rep.changes[0].status, UpdateStatus::Unchanged);
let _ = std::fs::remove_dir_all(&base);
let _ = std::fs::remove_dir_all(&src);
}
#[test]
fn plan_missing_lock_is_error() {
let src = make_repo();
let url = src.to_string_lossy().to_string();
let cfg = config_with("dep", git_dep(&url, Some("v1.0.0"), None, None));
let base = std::env::temp_dir().join(format!(
"sdkt-sync-nolock-{}",
std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap()
.as_nanos()
));
let _ = std::fs::remove_dir_all(&base);
std::fs::create_dir_all(&base).unwrap();
let err = plan_updates(&base, &cfg).unwrap_err();
assert!(matches!(err, SyncError::MissingLock));
let _ = std::fs::remove_dir_all(&base);
let _ = std::fs::remove_dir_all(&src);
}
#[test]
fn version_resolver_picks_highest_satisfying() {
let tags = vec![
("v1.0.0".to_string(), "c1".to_string()),
("v1.5.0".to_string(), "c2".to_string()),
("v2.0.0".to_string(), "c3".to_string()),
("latest".to_string(), "c9".to_string()),
("not-semver".to_string(), "cX".to_string()),
];
let (tag, commit) =
crate::package::best_version_match(&tags, ">=1.0, <2").expect("should satisfy");
assert_eq!(tag, "v1.5.0");
assert_eq!(commit, "c2");
let (tag, _) = crate::package::best_version_match(&tags, "=2.0.0").expect("exact");
assert_eq!(tag, "v2.0.0");
assert!(
crate::package::best_version_match(&tags, ">=3.0").is_none(),
"constraint >=3.0 must be unsatisfied"
);
}
#[test]
fn plan_version_update_detected() {
let src = make_repo();
let url = src.to_string_lossy().to_string();
git_cmd(&src).args(["tag", "v1.0.0"]).output().unwrap();
let v1 = head(&src);
commit(&src, b"pub fn answer() -> u32 { 43 }");
git_cmd(&src).args(["tag", "v1.5.0"]).output().unwrap();
let v15 = head(&src);
commit(&src, b"pub fn answer() -> u32 { 44 }");
git_cmd(&src).args(["tag", "v2.0.0"]).output().unwrap();
let cfg = config_with("dep", git_dep_version(&url, ">=1.0, <2"));
let base = std::env::temp_dir().join(format!(
"sdkt-sync-ver-{}-{}",
std::process::id(),
std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap()
.as_nanos()
));
let _ = std::fs::remove_dir_all(&base);
std::fs::create_dir_all(&base).unwrap();
write_lock_with(
&base,
vec![DependencyLock {
name: "dep".to_string(),
source: "git".to_string(),
git_url: url.clone(),
commit_sha: v1,
..Default::default()
}],
);
let rep = plan_updates(&base, &cfg).unwrap();
assert_eq!(rep.changes.len(), 1);
assert_eq!(rep.changes[0].status, UpdateStatus::Updated);
assert_eq!(rep.changes[0].new_commit, v15);
assert_eq!(rep.changes[0].resolved_tag.as_deref(), Some("v1.5.0"));
assert!(rep.changes[0].detail.contains("update-available"));
let _ = std::fs::remove_dir_all(&base);
let _ = std::fs::remove_dir_all(&src);
}
#[test]
fn plan_version_constraint_unsatisfied() {
let src = make_repo();
let url = src.to_string_lossy().to_string();
git_cmd(&src).args(["tag", "v1.0.0"]).output().unwrap();
let cfg = config_with("dep", git_dep_version(&url, ">=3.0"));
let base = std::env::temp_dir().join(format!(
"sdkt-sync-verbad-{}-{}",
std::process::id(),
std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap()
.as_nanos()
));
let _ = std::fs::remove_dir_all(&base);
std::fs::create_dir_all(&base).unwrap();
write_lock_with(
&base,
vec![DependencyLock {
name: "dep".to_string(),
source: "git".to_string(),
git_url: url.clone(),
commit_sha: String::new(),
..Default::default()
}],
);
let rep = plan_updates(&base, &cfg).unwrap();
assert_eq!(rep.changes.len(), 1);
assert_eq!(rep.changes[0].status, UpdateStatus::Constraint);
assert!(rep.changes[0].detail.contains("unsatisfied"));
let _ = std::fs::remove_dir_all(&base);
let _ = std::fs::remove_dir_all(&src);
}
#[test]
fn plan_version_up_to_date_when_locked() {
let src = make_repo();
let url = src.to_string_lossy().to_string();
git_cmd(&src).args(["tag", "v1.0.0"]).output().unwrap();
git_cmd(&src).args(["tag", "v1.5.0"]).output().unwrap();
let v15 = head(&src);
let cfg = config_with("dep", git_dep_version(&url, ">=1.0, <2"));
let base = std::env::temp_dir().join(format!(
"sdkt-sync-verok-{}-{}",
std::process::id(),
std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap()
.as_nanos()
));
let _ = std::fs::remove_dir_all(&base);
std::fs::create_dir_all(&base).unwrap();
write_lock_with(
&base,
vec![DependencyLock {
name: "dep".to_string(),
source: "git".to_string(),
git_url: url.clone(),
commit_sha: v15,
..Default::default()
}],
);
let rep = plan_updates(&base, &cfg).unwrap();
assert_eq!(rep.changes[0].status, UpdateStatus::Unchanged);
assert!(rep.changes[0].detail.contains("up-to-date"));
let _ = std::fs::remove_dir_all(&base);
let _ = std::fs::remove_dir_all(&src);
}
#[test]
fn apply_updates_refreshes_cache_and_lock() {
let src = make_repo();
let url = src.to_string_lossy().to_string();
git_cmd(&src).args(["tag", "v1.0.0"]).output().unwrap();
let v1 = head(&src);
commit(&src, b"pub fn answer() -> u32 { 43 }");
let v2 = head(&src);
git_cmd(&src)
.args(["tag", "-f", "v1.0.0"])
.output()
.unwrap();
let cfg = config_with("dep", git_dep(&url, Some("v1.0.0"), None, None));
let base = std::env::temp_dir().join(format!(
"sdkt-sync-apply-{}",
std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap()
.as_nanos()
));
let _ = std::fs::remove_dir_all(&base);
std::fs::create_dir_all(&base).unwrap();
let cache_key = crate::fetch::git_cache_key(&git_dep(&url, Some("v1.0.0"), None, None));
let checkout = base.join(".sdkt-cache").join("git").join(&cache_key);
let _ = std::fs::remove_dir_all(&checkout);
std::fs::create_dir_all(&checkout).unwrap();
let clone = Command::new(crate::fetch::git_bin())
.args(["clone", "-q", &url, checkout.to_string_lossy().as_ref()])
.output()
.unwrap();
assert!(clone.status.success(), "seed clone failed");
write_lock_with(
&base,
vec![DependencyLock {
name: "dep".to_string(),
source: "git".to_string(),
git_url: url.clone(),
commit_sha: v1,
cache_location: checkout.display().to_string(),
..Default::default()
}],
);
let (rep, lock) = apply_updates(&base, &cfg).unwrap();
assert_eq!(rep.changes[0].status, UpdateStatus::Updated);
assert_eq!(rep.changes[0].new_commit, v2);
let updated = lock.dependencies.iter().find(|d| d.name == "dep").unwrap();
assert_eq!(updated.commit_sha, v2);
let cur = head(&checkout);
assert_eq!(cur, v2);
let _ = std::fs::remove_dir_all(&base);
let _ = std::fs::remove_dir_all(&src);
}
#[test]
fn plan_errors_on_unknown_reference() {
let src = make_repo();
let url = src.to_string_lossy().to_string();
let cfg = config_with("dep", git_dep(&url, Some("v9.9.9"), None, None));
let base = std::env::temp_dir().join(format!(
"sdkt-sync-unknown-{}",
std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap()
.as_nanos()
));
let _ = std::fs::remove_dir_all(&base);
std::fs::create_dir_all(&base).unwrap();
write_lock_with(
&base,
vec![DependencyLock {
name: "dep".to_string(),
source: "git".to_string(),
git_url: url.clone(),
..Default::default()
}],
);
let rep = plan_updates(&base, &cfg).unwrap();
assert_eq!(rep.changes[0].status, UpdateStatus::Error);
let _ = std::fs::remove_dir_all(&base);
let _ = std::fs::remove_dir_all(&src);
}
}