repoverse 0.1.3

Multi-repo workspace tool: keep many git repos in sync and roll changes up across dependency boundaries
//! End-to-end tests over synthetic local repos. No real org/repo names.

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 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:"));

    // clean workspace: status shows no dirty, next says done
    rv(root)
        .args(["next", "--json"])
        .assert()
        .success()
        .stdout(predicates::str::contains("\"done\": true"));

    // dirty lib -> plan shows dirty, next points at lib
    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\""));

    // commit only 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}");
}