use assert_cmd::Command;
use predicates::prelude::PredicateBooleanExt;
use std::path::Path;
use std::process::Command as Std;
use tempfile::tempdir;
fn git(dir: &Path, args: &[&str]) {
let ok = Std::new("git")
.arg("-C")
.arg(dir)
.args(args)
.status()
.unwrap()
.success();
assert!(ok, "git {args:?} failed");
}
fn git_output(dir: &Path, args: &[&str]) -> String {
let out = Std::new("git")
.arg("-C")
.arg(dir)
.args(args)
.output()
.unwrap();
assert!(out.status.success(), "git {args:?} failed");
String::from_utf8_lossy(&out.stdout).trim().to_string()
}
fn init_repo(dir: &Path) {
std::fs::create_dir_all(dir).unwrap();
git(dir, &["init", "-q", "-b", "main"]);
git(dir, &["config", "user.email", "t@t.t"]);
git(dir, &["config", "user.name", "t"]);
git(dir, &["config", "commit.gpgsign", "false"]);
std::fs::write(dir.join("README.md"), "x\n").unwrap();
git(dir, &["add", "-A"]);
git(dir, &["commit", "-q", "-m", "init"]);
}
fn head_sha(dir: &Path) -> String {
git_output(dir, &["rev-parse", "HEAD"])
}
fn rv(root: &Path) -> Command {
let mut c = Command::cargo_bin("rv").unwrap();
c.current_dir(root);
c
}
#[cfg(unix)]
#[test]
fn status_target_ignores_symlinked_gitlink_submodules() {
let dir = tempdir().unwrap();
let root = dir.path();
init_repo(&root.join("app"));
init_repo(&root.join("dep"));
let dep_sha = head_sha(&root.join("dep"));
git(
&root.join("app"),
&[
"update-index",
"--add",
"--cacheinfo",
&format!("160000,{dep_sha},dep"),
],
);
git(
&root.join("app"),
&["commit", "-q", "-m", "add dep gitlink"],
);
std::os::unix::fs::symlink("../dep", root.join("app/dep")).unwrap();
std::fs::write(
root.join(".repoverse.yaml"),
r#"version: 1
remotes: { github: { host: github.com } }
projects:
- { name: acme/app, path: app }
"#,
)
.unwrap();
rv(root)
.args(["status", "app"])
.assert()
.success()
.stdout(predicates::str::contains("clean"));
std::fs::write(root.join("app/change.txt"), "change\n").unwrap();
rv(root)
.args(["status", "--dirty"])
.assert()
.success()
.stdout(predicates::str::contains("app"))
.stdout(predicates::str::contains("change.txt"));
}
#[test]
fn status_includes_projects_from_nested_repoverse_configs() {
let dir = tempdir().unwrap();
let root = dir.path();
init_repo(&root.join("app"));
init_repo(&root.join("app/repos/nested"));
std::fs::write(
root.join(".repoverse.yaml"),
r#"version: 1
remotes: { github: { host: github.com } }
projects:
- { name: acme/app, path: app }
"#,
)
.unwrap();
std::fs::write(
root.join("app/.repoverse.yaml"),
r#"version: 1
remotes: { github: { host: github.com } }
projects:
- { name: acme/nested, path: repos/nested }
"#,
)
.unwrap();
std::fs::write(root.join("app/repos/nested/change.txt"), "change\n").unwrap();
rv(root)
.args(["status"])
.assert()
.success()
.stdout(predicates::str::contains("app/repos/nested/"))
.stdout(predicates::str::contains("[dirty]"));
rv(root)
.args(["status", "--dirty"])
.assert()
.success()
.stdout(predicates::str::contains("app/repos/nested"))
.stdout(predicates::str::contains("change.txt"));
}
#[test]
fn init_does_not_clone_over_existing_nested_paths() {
let dir = tempdir().unwrap();
let root = dir.path();
init_repo(&root.join("app"));
std::fs::create_dir_all(root.join("app/repos/nested")).unwrap();
std::fs::write(root.join("app/repos/nested/README.md"), "already here\n").unwrap();
std::fs::write(
root.join(".repoverse.yaml"),
r#"version: 1
remotes: { github: { host: github.com } }
projects:
- { name: acme/app, path: app }
"#,
)
.unwrap();
std::fs::write(
root.join("app/.repoverse.yaml"),
r#"version: 1
remotes: { github: { host: github.com } }
projects:
- { name: acme/nested, path: repos/nested }
"#,
)
.unwrap();
rv(root)
.arg("init")
.assert()
.success()
.stdout(predicates::str::contains("ok repos/nested (existing)"));
}
#[test]
fn import_roundtrips_real_manifest() {
let dir = tempdir().unwrap();
let manifest = dir.path().join("manifest.xml");
std::fs::write(
&manifest,
r#"<?xml version='1.0' encoding='UTF-8'?>
<manifest>
<remote name="github" fetch="ssh://git@github.com/" />
<default remote="github" revision="main" />
<project name="acme/lib" path="lib" revision="main" />
<project name="acme/app" path="app" revision="dev" />
</manifest>"#,
)
.unwrap();
rv(dir.path())
.args(["import", "manifest.xml"])
.assert()
.success();
let cfg: serde_yaml::Value =
serde_yaml::from_str(&std::fs::read_to_string(dir.path().join(".repoverse.yaml")).unwrap())
.unwrap();
let projects = cfg["projects"].as_sequence().unwrap();
let lib = projects.iter().find(|p| p["name"] == "acme/lib").unwrap();
let app = projects.iter().find(|p| p["name"] == "acme/app").unwrap();
assert!(lib.get("revision").is_none(), "default revision dropped");
assert_eq!(app["revision"], "dev", "non-default revision kept");
}
#[test]
fn pin_sync_commit_plan_flow() {
let dir = tempdir().unwrap();
let root = dir.path();
init_repo(&root.join("lib"));
init_repo(&root.join("app"));
std::fs::write(
root.join(".repoverse.yaml"),
r#"version: 1
remotes: { github: { host: github.com } }
projects:
- { name: acme/lib, path: lib }
- { name: acme/app, path: app, consumes: [lib] }
"#,
)
.unwrap();
rv(root).arg("pin").assert().success();
let lock = std::fs::read_to_string(root.join(".repoverse.lock")).unwrap();
assert!(lock.contains("acme/lib"));
assert!(lock.contains("sha:"));
rv(root)
.args(["next", "--json"])
.assert()
.success()
.stdout(predicates::str::contains("\"done\": true"));
std::fs::write(root.join("lib/new.txt"), "change\n").unwrap();
rv(root)
.args(["plan", "--json"])
.assert()
.success()
.stdout(predicates::str::contains("\"status\": \"dirty\""));
rv(root)
.args(["next", "--json"])
.assert()
.success()
.stdout(predicates::str::contains("\"repo\": \"lib\""));
rv(root)
.args(["commit", "-m", "change", "lib"])
.assert()
.success();
rv(root)
.args(["status", "--json"])
.assert()
.success()
.stdout(predicates::str::contains("\"dirty\": false"));
}
#[test]
fn gitlinks_reports_status_and_commits_stale_gitlinks() {
let dir = tempdir().unwrap();
let root = dir.path();
init_repo(root);
init_repo(&root.join("lib"));
init_repo(&root.join("app"));
let old_lib = head_sha(&root.join("lib"));
git(
&root.join("app"),
&[
"update-index",
"--add",
"--cacheinfo",
&format!("160000,{old_lib},lib"),
],
);
git(
&root.join("app"),
&["commit", "-q", "-m", "add lib gitlink"],
);
std::fs::write(root.join("lib/new.txt"), "change\n").unwrap();
git(&root.join("lib"), &["add", "-A"]);
git(&root.join("lib"), &["commit", "-q", "-m", "change lib"]);
let new_lib = head_sha(&root.join("lib"));
std::fs::write(
root.join(".repoverse.yaml"),
r#"version: 1
remotes: { github: { host: github.com } }
projects:
- { name: acme/lib, path: lib }
- { name: acme/app, path: app, consumes: [lib] }
"#,
)
.unwrap();
rv(root)
.arg("gitlinks")
.assert()
.success()
.stdout(predicates::str::contains("app [drift:1]"))
.stdout(predicates::str::contains("1 drift(s)"));
rv(root)
.arg("status")
.assert()
.success()
.stdout(predicates::str::contains("app/"))
.stdout(predicates::str::contains("[gitlinks:1]"));
rv(root)
.args(["gitlinks", "--commit"])
.assert()
.success()
.stdout(predicates::str::contains("committed app"))
.stdout(predicates::str::contains("gitlinks clean"));
let committed = git_output(&root.join("app"), &["ls-tree", "HEAD", "--", "lib"]);
assert!(committed.contains(&new_lib));
rv(root)
.arg("gitlinks")
.assert()
.success()
.stdout(predicates::str::contains("gitlinks clean"));
}
#[test]
fn pr_plan_uses_origin_and_configured_revision() {
let dir = tempdir().unwrap();
let root = dir.path();
init_repo(&root.join("lib"));
init_repo(&root.join("app"));
init_repo(&root.join("rollup"));
git(
&root.join("lib"),
&["remote", "add", "origin", "git@github.com:acme/lib.git"],
);
git(
&root.join("app"),
&["remote", "add", "origin", "git@github.com:acme/app.git"],
);
git(
&root.join("rollup"),
&["remote", "add", "origin", "git@github.com:acme/rollup.git"],
);
git(
&root.join("lib"),
&["checkout", "-q", "-b", "feature/topic"],
);
git(&root.join("app"), &["checkout", "-q", "-b", "stable"]);
git(&root.join("rollup"), &["checkout", "-q", "-b", "rv/stable"]);
std::fs::write(
root.join(".repoverse.yaml"),
r#"version: 1
remotes: { github: { host: github.com } }
defaults:
revision: stable
projects:
- { name: acme/lib, path: lib }
- { name: acme/app, path: app }
- { name: acme/rollup, path: rollup }
"#,
)
.unwrap();
rv(root)
.args(["pr", "--dry-run", "--json"])
.assert()
.success()
.stdout(predicates::str::contains("\"repo\": \"acme/lib\""))
.stdout(predicates::str::contains("\"base\": \"stable\""))
.stdout(predicates::str::contains("\"head\": \"feature/topic\""))
.stdout(predicates::str::contains("acme/app").not())
.stdout(predicates::str::contains("acme/rollup").not());
}
#[test]
fn topo_order_is_dependencies_first() {
let dir = tempdir().unwrap();
let root = dir.path();
init_repo(&root.join("lib"));
init_repo(&root.join("app"));
std::fs::write(
root.join(".repoverse.yaml"),
r#"version: 1
remotes: { github: { host: github.com } }
projects:
- { name: acme/app, path: app, consumes: [lib] }
- { name: acme/lib, path: lib }
"#,
)
.unwrap();
let out = rv(root).arg("plan").output().unwrap();
let s = String::from_utf8_lossy(&out.stdout);
let lib = s.find("lib").unwrap();
let app = s.find("app").unwrap();
assert!(lib < app, "lib must come before app:\n{s}");
}