use crate::config::{Config, Project, Scheme};
use crate::remote::{self, SchemeFlag};
use crate::workspace::Workspace;
use anyhow::{bail, Result};
use std::path::Path;
fn project_url(cfg: &Config, p: &Project, flag: SchemeFlag) -> Result<String> {
let rem = cfg
.project_remote(p)
.ok_or_else(|| anyhow::anyhow!("no remote for project {}", p.name))?;
let resolved = remote::resolve(flag, cfg.defaults.scheme, &remote::SystemEnv);
Ok(remote::url_for(&rem.host, &p.name, &resolved))
}
fn scheme_flag(https: bool, ssh: bool) -> SchemeFlag {
if https {
SchemeFlag::Https
} else if ssh {
SchemeFlag::Ssh
} else {
SchemeFlag::Unset
}
}
fn current_or_workspace_targets(
ws: &Workspace,
targets: &[String],
) -> Result<Vec<(String, std::path::PathBuf)>> {
if targets == ["all"] {
return Ok(ws
.config
.projects
.iter()
.map(|p| (p.path.clone(), ws.project_dir(p)))
.collect());
}
if targets.is_empty() {
let cwd = std::env::current_dir()?;
if crate::git::is_repo(&cwd) {
let top = crate::git::check(&cwd, &["rev-parse", "--show-toplevel"])?
.trim()
.to_string();
let dir = std::path::PathBuf::from(top);
let label = dir
.strip_prefix(&ws.root)
.ok()
.map(|p| p.to_string_lossy().replace('\\', "/"))
.filter(|s| !s.is_empty())
.unwrap_or_else(|| ".".into());
return Ok(vec![(label, dir)]);
}
}
Ok(ws
.resolve_targets(targets)?
.into_iter()
.map(|p| (p.path.clone(), ws.project_dir(p)))
.collect())
}
fn current_git_target() -> Result<Option<(String, std::path::PathBuf)>> {
let cwd = std::env::current_dir()?;
if !crate::git::is_repo(&cwd) {
return Ok(None);
}
let top = crate::git::check(&cwd, &["rev-parse", "--show-toplevel"])?
.trim()
.to_string();
Ok(Some((".".into(), std::path::PathBuf::from(top))))
}
mod color {
use std::io::IsTerminal;
use std::sync::OnceLock;
fn on() -> bool {
static O: OnceLock<bool> = OnceLock::new();
*O.get_or_init(|| std::env::var_os("NO_COLOR").is_none() && std::io::stdout().is_terminal())
}
fn wrap(s: &str, code: &str) -> String {
if on() {
format!("\x1b[{code}m{s}\x1b[0m")
} else {
s.to_string()
}
}
pub fn green(s: &str) -> String {
wrap(s, "32")
}
pub fn yellow(s: &str) -> String {
wrap(s, "33")
}
pub fn red(s: &str) -> String {
wrap(s, "31")
}
pub fn cyan(s: &str) -> String {
wrap(s, "36")
}
pub fn blue(s: &str) -> String {
wrap(s, "34")
}
pub fn dim(s: &str) -> String {
wrap(s, "2")
}
}
pub mod doctor {
use super::*;
use crate::git;
use std::collections::BTreeSet;
use std::path::PathBuf;
#[derive(Debug, Clone, Eq, Ord, PartialEq, PartialOrd)]
struct Overlay {
display: String,
consumer: PathBuf,
sub_rel: String,
}
pub fn run(fix: bool) -> Result<()> {
let ws = Workspace::discover()?;
let overlays = collect_overlays(&ws)?;
let mut issues = Vec::new();
let mut fixed = 0usize;
for overlay in overlays {
let path = overlay.consumer.join(&overlay.sub_rel);
if git::gitlink_sha(&overlay.consumer, &overlay.sub_rel).is_none() {
continue;
}
let meta = std::fs::symlink_metadata(&path).ok();
let is_symlink = meta
.as_ref()
.map(|m| m.file_type().is_symlink())
.unwrap_or(false);
let is_empty_dir = meta.as_ref().map(|m| m.is_dir()).unwrap_or(false)
&& std::fs::read_dir(&path)
.map(|mut entries| entries.next().is_none())
.unwrap_or(false);
if is_symlink && !has_skip_worktree(&overlay.consumer, &overlay.sub_rel)? {
if fix {
git::set_skip_worktree(&overlay.consumer, &overlay.sub_rel, true)?;
println!(" fixed {} (set skip-worktree)", overlay.display);
fixed += 1;
} else {
issues.push(format!(
"{} {} is a Repoverse symlink but is missing skip-worktree",
color::yellow("fixable"),
overlay.display
));
}
} else if is_empty_dir {
issues.push(format!(
"{} {} is an empty gitlink directory; run `rv link`",
color::yellow("fixable"),
overlay.display
));
} else if !is_symlink {
issues.push(format!(
"{} {} is a gitlink path but is not a Repoverse symlink",
color::red("manual"),
overlay.display
));
}
}
if issues.is_empty() && fixed == 0 {
println!("repoverse doctor: clean");
} else if !issues.is_empty() {
println!("{}", color::dim("repoverse doctor"));
for issue in &issues {
println!(" {issue}");
}
println!();
println!("recommended: {}", color::cyan("rv doctor --fix"));
}
Ok(())
}
fn has_skip_worktree(repo: &Path, path: &str) -> Result<bool> {
let out = git::check(repo, &["ls-files", "-v", "--", path])?;
Ok(out
.lines()
.next()
.and_then(|line| line.chars().next())
.is_some_and(|flag| flag == 'S'))
}
fn collect_overlays(ws: &Workspace) -> Result<BTreeSet<Overlay>> {
let mut overlays = BTreeSet::new();
for link in &ws.config.links {
if let Some((consumer, sub_rel)) = owning_repo(&ws.root, &link.at, &ws.root) {
overlays.insert(Overlay {
display: link.at.clone(),
consumer,
sub_rel,
});
}
}
for consumer in nested_consumers(ws)? {
let cfg = Config::load(&consumer.join(crate::config::CONFIG_FILE))?;
let prefix = consumer
.strip_prefix(&ws.root)
.ok()
.map(|p| p.to_string_lossy().replace('\\', "/"))
.unwrap_or_else(|| consumer.display().to_string());
for link in &cfg.links {
if let Some((owner, sub_rel)) = owning_repo(&consumer, &link.at, &consumer) {
overlays.insert(Overlay {
display: format!("{prefix}/{}", link.at),
consumer: owner,
sub_rel,
});
}
}
for project in &cfg.projects {
for dep in &project.shared {
if let Some((owner, sub_rel)) = owning_repo(&consumer, &dep.path, &consumer) {
overlays.insert(Overlay {
display: format!("{prefix}/{}", dep.path),
consumer: owner,
sub_rel,
});
}
}
}
}
Ok(overlays)
}
fn nested_consumers(ws: &Workspace) -> Result<Vec<PathBuf>> {
let mut consumers = Vec::new();
for project in &ws.config.projects {
let dir = ws.project_dir(project);
if dir != ws.root && dir.join(crate::config::CONFIG_FILE).is_file() {
consumers.push(dir);
}
}
let mut stack = vec![ws.root.clone()];
while let Some(dir) = stack.pop() {
let Ok(entries) = std::fs::read_dir(&dir) else {
continue;
};
for entry in entries.flatten() {
let path = entry.path();
let name = entry.file_name();
let name = name.to_string_lossy();
if path.is_symlink() {
continue;
}
if path.is_dir() {
if name == ".git"
|| name == "node_modules"
|| name == "target"
|| name == "repos"
|| name.starts_with('.') && name != "."
{
continue;
}
stack.push(path);
} else if name == crate::config::CONFIG_FILE
&& dir != ws.root
&& !consumers.iter().any(|c| c == &dir)
{
consumers.push(dir.clone());
}
}
}
consumers.sort();
consumers.dedup();
Ok(consumers)
}
fn owning_repo(base: &Path, rel: &str, stop: &Path) -> Option<(PathBuf, String)> {
let full = base.join(rel);
let mut cur = full.parent();
while let Some(dir) = cur {
if dir.join(".git").exists() {
let sub_rel = full
.strip_prefix(dir)
.ok()
.map(|p| p.to_string_lossy().replace('\\', "/"))?;
return Some((dir.to_path_buf(), sub_rel));
}
if dir == stop {
break;
}
cur = dir.parent();
}
None
}
}
pub mod status {
use super::*;
use crate::git;
use std::collections::{BTreeMap, BTreeSet};
use std::path::{Component, Path, PathBuf};
#[derive(Clone)]
struct StatusProject {
name: String,
path: String,
dir: PathBuf,
}
pub fn layout() -> Result<()> {
let (rows, subs) = collect()?;
print_layout(&rows, &subs);
if rows.is_empty() && subs.is_empty() {
println!("no .repoverse.yaml and no .gitmodules here");
}
Ok(())
}
pub fn run(json: bool, dirty_only: bool, target: Option<&str>) -> Result<()> {
let cwd = std::env::current_dir()?;
let ws = Workspace::discover().ok();
if dirty_only {
return print_dirty_status(ws.as_ref());
}
if let Some(target) = target {
return print_repo_status(ws.as_ref(), &cwd, target);
}
if !json {
if let Some(ws) = &ws {
if cwd != ws.root {
if let Some(project) = project_containing_cwd(ws, &cwd) {
return print_repo_status(Some(ws), &cwd, &project.path);
}
}
}
}
if !json {
return print_status_tree(ws.as_ref(), &cwd);
}
let (rows, subs) = collect()?;
println!(
"{}",
serde_json::to_string_pretty(
&serde_json::json!({"projects": rows, "submodules": subs})
)?
);
Ok(())
}
fn collect() -> Result<(Vec<serde_json::Value>, Vec<serde_json::Value>)> {
let cwd = std::env::current_dir()?;
let ws = Workspace::discover().ok();
let root = ws
.as_ref()
.map(|w| w.root.clone())
.unwrap_or_else(|| cwd.clone());
let mut rows = Vec::new();
if let Some(ws) = &ws {
let lock = ws.lock()?;
for p in &ws.config.projects {
let dir = ws.project_dir(p);
let row = if !git::is_repo(&dir) {
serde_json::json!({"path": p.path, "name": p.name, "state": "missing"})
} else {
let branch = git::current_branch(&dir).unwrap_or_default();
let dirty = git::is_dirty(&dir).unwrap_or(false);
let (ahead, behind) = git::ahead_behind(&dir)?.unwrap_or((0, 0));
serde_json::json!({
"path": p.path, "name": p.name, "branch": branch,
"dirty": dirty, "ahead": ahead, "behind": behind,
"locked": lock.sha_for(&p.path),
})
};
rows.push(row);
}
}
let provided: std::collections::BTreeSet<String> = ws
.as_ref()
.map(|w| {
w.config
.projects
.iter()
.map(|p| p.path.clone())
.chain(w.config.provides.iter().cloned())
.collect()
})
.unwrap_or_default();
let mut subs = Vec::new();
if root.join(".gitmodules").is_file() {
let rid = git::run(&root, &["remote", "get-url", "origin"])
.ok()
.filter(|o| o.ok())
.map(|o| crate::submodule::normalize_url(o.stdout.trim()))
.unwrap_or_else(|| "workspace/root".into());
for n in crate::submodule::scan(&root, &rid).order {
for p in &n.paths {
let abs = root.join(p);
let is_link = std::fs::symlink_metadata(&abs)
.map(|m| m.file_type().is_symlink())
.unwrap_or(false);
let state = if is_link {
"shared"
} else if provided.contains(p)
|| provided.contains(n.id.split_once('/').map(|x| x.1).unwrap_or(""))
{
"adopted"
} else {
"submodule"
};
let (branch, dirty) = if git::is_repo(&abs) {
(
git::current_branch(&abs).unwrap_or_else(|_| "?".into()),
git::is_dirty(&abs).unwrap_or(false),
)
} else {
("-".into(), false)
};
let effective_target = if is_link {
display_target(&root, p, &abs, ws.as_ref())
} else {
None
};
subs.push(serde_json::json!({
"id": n.id, "path": p, "state": state,
"branch": branch, "dirty": dirty,
"shared_target": if is_link {
std::fs::read_link(&abs).ok()
.map(|t| t.to_string_lossy().into_owned())
} else { None },
"effective_target": effective_target,
"branch_conflict": !n.branch_conflicts.is_empty(),
}));
}
}
}
Ok((rows, subs))
}
fn display_target(
root: &Path,
path: &str,
abs: &Path,
ws: Option<&Workspace>,
) -> Option<String> {
let target_abs = std::fs::canonicalize(abs).ok()?;
if let Some(ws) = ws {
for p in &ws.config.projects {
let project_abs = std::fs::canonicalize(ws.project_dir(p)).ok();
if project_abs.as_ref() == Some(&target_abs) {
return Some(p.path.clone());
}
}
}
let raw = std::fs::read_link(abs).ok()?;
if raw.is_absolute() {
return Some(path_to_string(&lexical_normalize(root, &raw)));
}
let mut resolved = logical_parent(root, path, ws).unwrap_or_else(|| {
let mut p = PathBuf::from(path);
p.pop();
p
});
resolved.push(raw);
Some(path_to_string(&lexical_normalize(Path::new(""), &resolved)))
}
fn logical_parent(root: &Path, path: &str, ws: Option<&Workspace>) -> Option<PathBuf> {
let parts: Vec<&str> = path.split('/').collect();
for i in (1..parts.len()).rev() {
let prefix = parts[..i].join("/");
let abs = root.join(&prefix);
let is_link = std::fs::symlink_metadata(&abs)
.map(|m| m.file_type().is_symlink())
.unwrap_or(false);
if !is_link {
continue;
}
let target_abs = std::fs::canonicalize(&abs).ok()?;
let target = project_path_for_abs(&target_abs, ws)
.unwrap_or_else(|| path_to_string(&lexical_normalize(root, &target_abs)));
let mut out = PathBuf::from(target);
for part in &parts[i..parts.len() - 1] {
out.push(part);
}
return Some(out);
}
None
}
fn project_path_for_abs(target_abs: &Path, ws: Option<&Workspace>) -> Option<String> {
let ws = ws?;
for p in &ws.config.projects {
let project_abs = std::fs::canonicalize(ws.project_dir(p)).ok()?;
if project_abs == target_abs {
return Some(p.path.clone());
}
}
None
}
fn lexical_normalize(root: &Path, path: &Path) -> PathBuf {
let mut out = PathBuf::new();
for c in path.components() {
match c {
Component::CurDir => {}
Component::ParentDir => {
out.pop();
}
Component::Normal(p) => out.push(p),
Component::RootDir | Component::Prefix(_) => out.push(c.as_os_str()),
}
}
out.strip_prefix(root).unwrap_or(&out).to_path_buf()
}
fn path_to_string(path: &Path) -> String {
let s = path.to_string_lossy();
if s.is_empty() {
".".into()
} else {
s.into_owned()
}
}
fn print_repo_status(ws: Option<&Workspace>, cwd: &Path, target: &str) -> Result<()> {
let (label, dir) = resolve_status_target(ws, cwd, target)?;
if !git::is_repo(&dir) {
bail!("{label}: not a git checkout");
}
let branch = git::current_branch(&dir).unwrap_or_else(|_| "?".into());
let (ahead, behind) = git::ahead_behind(&dir)?.unwrap_or((0, 0));
let files = porcelain_status(&dir)?;
let mark = if files.is_empty() {
color::green("●")
} else {
color::yellow("●")
};
let ab = if ahead == 0 && behind == 0 {
String::new()
} else {
color::yellow(&format!(" ↑{ahead} ↓{behind}"))
};
println!("{} {} {}{}", mark, label, color::dim(&branch), ab);
if files.is_empty() {
println!("{}", color::green(" clean"));
} else {
for line in files {
println!(" {line}");
}
}
Ok(())
}
fn print_dirty_status(ws: Option<&Workspace>) -> Result<()> {
let Some(ws) = ws else {
bail!("no .repoverse.yaml found");
};
let mut any = false;
println!("{}", color::dim("repoverse dirty status"));
let root_files = if git::is_repo(&ws.root) {
porcelain_status(&ws.root)?
} else {
Vec::new()
};
if !root_files.is_empty() {
any = true;
let branch = git::current_branch(&ws.root).unwrap_or_else(|_| "?".into());
println!();
println!("{} {:<46} {}", color::yellow("●"), ".", color::dim(&branch));
for line in root_files {
println!(" {line}");
}
}
for p in collect_status_projects(ws)? {
let dir = p.dir;
if !git::is_repo(&dir) {
continue;
}
let files = porcelain_status(&dir)?;
if files.is_empty() {
continue;
}
any = true;
let branch = git::current_branch(&dir).unwrap_or_else(|_| "?".into());
let (ahead, behind) = git::ahead_behind(&dir)?.unwrap_or((0, 0));
let ab = if ahead == 0 && behind == 0 {
String::new()
} else {
color::yellow(&format!(" ↑{ahead} ↓{behind}"))
};
println!();
println!(
"{} {:<46} {}{}",
color::yellow("●"),
p.path,
color::dim(&branch),
ab
);
for line in files {
println!(" {line}");
}
}
if !any {
println!("{}", color::green(" clean"));
}
Ok(())
}
fn print_status_tree(ws: Option<&Workspace>, cwd: &Path) -> Result<()> {
let Some(ws) = ws else {
return print_repo_status(None, cwd, ".");
};
let root_branch = git::current_branch(&ws.root).unwrap_or_else(|_| "?".into());
let root_diverged = divergence_note(&ws.root);
let root_dirty = porcelain_status(&ws.root)
.map(|f| !f.is_empty())
.unwrap_or(false);
let root_name = ws
.root
.file_name()
.map(|s| s.to_string_lossy())
.unwrap_or_else(|| ".".into());
let root_mark = if root_dirty {
format!(" {}", color::red("[dirty]"))
} else {
String::new()
};
println!(
"{}/ {}{}{}",
color::cyan(&root_name),
color::blue(&format!("[{root_branch}]")),
root_diverged,
root_mark
);
let projects = collect_status_projects(ws)?;
for (idx, p) in projects.iter().enumerate() {
let last = idx + 1 == projects.len();
let prefix = if last { "└── " } else { "├── " };
let dir = p.dir.clone();
if !git::is_repo(&dir) {
println!(
"{}{} {}",
prefix,
color::cyan(&format!("{}/", p.path)),
color::red("[missing]")
);
continue;
}
let branch = git::current_branch(&dir).unwrap_or_else(|_| "?".into());
let diverged = divergence_note(&dir);
let dirty = porcelain_status(&dir)
.map(|f| !f.is_empty())
.unwrap_or(false);
let dirty_note = if dirty {
format!(" {}", color::red("[dirty]"))
} else {
String::new()
};
println!(
"{}{} {}{}{}",
prefix,
color::cyan(&format!("{}/", p.path)),
color::blue(&format!("[{branch}]")),
diverged,
dirty_note
);
}
println!();
println!("{}", color::green("Legend:"));
println!(" {} - on branch", color::blue("[branch]"));
println!(" {} - detached HEAD", color::blue("(detached)"));
println!(" {} - uncommitted changes", color::red("[dirty]"));
println!(" {} - repo not checked out", color::red("[missing]"));
println!(
" {}",
color::dim("use `rv status --dirty` to list changed files")
);
Ok(())
}
fn divergence_note(dir: &Path) -> String {
match git::ahead_behind(dir) {
Ok(Some((ahead, behind))) if ahead > 0 || behind > 0 => {
format!(" {}", color::yellow(&format!("↑{ahead} ↓{behind}")))
}
_ => String::new(),
}
}
fn porcelain_status(dir: &Path) -> Result<Vec<String>> {
let out = git::check(dir, &["status", "--porcelain", "--ignore-submodules=all"])?;
Ok(out
.lines()
.filter(|l| !l.trim().is_empty())
.map(|l| l.to_string())
.collect())
}
fn resolve_status_target(
ws: Option<&Workspace>,
cwd: &Path,
target: &str,
) -> Result<(String, PathBuf)> {
if target == "." {
return Ok((path_to_string(cwd), cwd.to_path_buf()));
}
if let Some(ws) = ws {
let projects = collect_status_projects(ws)?;
let matches: Vec<_> = projects
.iter()
.filter(|p| {
p.path == target
|| p.name == target
|| p.name.rsplit('/').next() == Some(target)
})
.collect();
if matches.len() == 1 {
let p = matches[0];
return Ok((p.path.clone(), p.dir.clone()));
}
if matches.len() > 1 {
bail!("ambiguous target `{target}`; use a full path or owner/repo name");
}
let rel = ws.root.join(target);
if rel.exists() {
return Ok((target.to_string(), rel));
}
}
let path = PathBuf::from(target);
if path.exists() {
return Ok((target.to_string(), path));
}
bail!("unknown status target `{target}`")
}
fn project_containing_cwd(ws: &Workspace, cwd: &Path) -> Option<StatusProject> {
let cwd = std::fs::canonicalize(cwd).ok()?;
collect_status_projects(ws)
.ok()?
.into_iter()
.filter_map(|p| {
let dir = std::fs::canonicalize(&p.dir).ok()?;
if cwd.starts_with(&dir) {
Some((dir.components().count(), p))
} else {
None
}
})
.max_by_key(|(depth, _)| *depth)
.map(|(_, p)| p)
}
fn collect_status_projects(ws: &Workspace) -> Result<Vec<StatusProject>> {
let mut projects = Vec::new();
let mut seen_configs = BTreeSet::new();
let mut seen_dirs = BTreeSet::new();
collect_status_projects_from(
ws,
&ws.root,
&ws.config,
&mut seen_configs,
&mut seen_dirs,
&mut projects,
)?;
projects.sort_by(|a, b| {
a.path
.to_ascii_lowercase()
.cmp(&b.path.to_ascii_lowercase())
});
Ok(projects)
}
fn collect_status_projects_from(
ws: &Workspace,
base: &Path,
cfg: &Config,
seen_configs: &mut BTreeSet<PathBuf>,
seen_dirs: &mut BTreeSet<PathBuf>,
projects: &mut Vec<StatusProject>,
) -> Result<()> {
let config_path = base.join(crate::config::CONFIG_FILE);
let config_key = std::fs::canonicalize(&config_path).unwrap_or(config_path);
if !seen_configs.insert(config_key) {
return Ok(());
}
let mut nested_configs = Vec::new();
for project in &cfg.projects {
let dir = if project.path == "." {
base.to_path_buf()
} else {
base.join(&project.path)
};
let dir_key = std::fs::canonicalize(&dir).unwrap_or_else(|_| dir.clone());
if seen_dirs.insert(dir_key) {
projects.push(StatusProject {
name: project.name.clone(),
path: display_path(ws, &dir),
dir: dir.clone(),
});
}
let nested = dir.join(crate::config::CONFIG_FILE);
if dir != base && nested.is_file() {
nested_configs.push((dir, nested));
}
}
for (dir, config_path) in nested_configs {
let cfg = Config::load(&config_path)?;
collect_status_projects_from(ws, &dir, &cfg, seen_configs, seen_dirs, projects)?;
}
Ok(())
}
fn display_path(ws: &Workspace, dir: &Path) -> String {
dir.strip_prefix(&ws.root)
.unwrap_or(dir)
.to_string_lossy()
.replace('\\', "/")
}
fn print_layout(rows: &[serde_json::Value], subs: &[serde_json::Value]) {
if rows.is_empty() && subs.is_empty() {
return;
}
println!("{}", color::dim("repoverse layout"));
println!(
"{}",
color::dim(
" legend: green=clean yellow=dirty cyan=shared->checkout grey=plain submodule"
)
);
let mut projects: BTreeMap<String, &serde_json::Value> = BTreeMap::new();
for r in rows {
if let Some(path) = r["path"].as_str() {
projects.insert(path.to_string(), r);
}
}
let mut target_to_paths: BTreeMap<String, Vec<&serde_json::Value>> = BTreeMap::new();
for s in subs {
if s["state"] == "shared" {
if let Some(target) = s["effective_target"].as_str() {
target_to_paths
.entry(target.to_string())
.or_default()
.push(s);
}
}
}
let direct_counts = direct_descendant_counts(subs);
let level_roots = direct_level_roots(&direct_counts);
let nested_roots = nested_shared_roots(subs, &target_to_paths);
let mut printed_projects = BTreeSet::new();
let mut printed_paths = BTreeSet::new();
print_level(
"Level 1: App Workspace",
"",
rows,
subs,
&projects,
&target_to_paths,
&direct_counts,
&nested_roots,
&mut printed_projects,
&mut printed_paths,
);
let mut level_num = 2;
for root in level_roots {
let title = format!("Level {level_num}: {}", title_for(&root));
print_level(
&title,
&format!("{root}/"),
rows,
subs,
&projects,
&target_to_paths,
&direct_counts,
&nested_roots,
&mut printed_projects,
&mut printed_paths,
);
level_num += 1;
}
for root in nested_roots {
let title = format!("Level {level_num}: {}", title_for(nested_name(&root)));
print_level(
&title,
&format!("{root}/"),
rows,
subs,
&projects,
&target_to_paths,
&direct_counts,
&[],
&mut printed_projects,
&mut printed_paths,
);
level_num += 1;
}
}
fn direct_descendant_counts(subs: &[serde_json::Value]) -> BTreeMap<String, usize> {
let mut counts = BTreeMap::new();
for s in subs {
let Some(path) = s["path"].as_str() else {
continue;
};
if let Some((root, _)) = path.split_once('/') {
*counts.entry(root.to_string()).or_insert(0) += 1;
}
}
counts
}
fn direct_level_roots(direct_counts: &BTreeMap<String, usize>) -> Vec<String> {
let mut roots = BTreeSet::new();
for (root, count) in direct_counts {
if *count > 1 {
roots.insert(root.to_string());
}
}
roots.into_iter().collect()
}
fn nested_shared_roots(
subs: &[serde_json::Value],
target_to_paths: &BTreeMap<String, Vec<&serde_json::Value>>,
) -> Vec<String> {
let mut roots = BTreeSet::new();
for paths in target_to_paths.values() {
if paths.len() <= 1 {
continue;
}
let Some(root) = paths
.iter()
.filter_map(|s| s["path"].as_str())
.filter(|path| {
subs.iter().any(|s| {
s["path"]
.as_str()
.map(|p| p.starts_with(&format!("{path}/")))
.unwrap_or(false)
})
})
.min_by_key(|path| path.matches('/').count())
else {
continue;
};
if root.contains('/') {
roots.insert(root.to_string());
}
}
roots.into_iter().collect()
}
fn nested_name(root: &str) -> &str {
root.rsplit('/').next().unwrap_or(root)
}
#[allow(clippy::too_many_arguments)]
fn print_level(
title: &str,
prefix: &str,
rows: &[serde_json::Value],
subs: &[serde_json::Value],
projects: &BTreeMap<String, &serde_json::Value>,
target_to_paths: &BTreeMap<String, Vec<&serde_json::Value>>,
direct_counts: &BTreeMap<String, usize>,
nested_roots: &[String],
printed_projects: &mut BTreeSet<String>,
printed_paths: &mut BTreeSet<String>,
) {
let level_paths: Vec<&serde_json::Value> = subs
.iter()
.filter(|s| {
let Some(path) = s["path"].as_str() else {
return false;
};
if prefix.is_empty() {
is_app_level_path(path, direct_counts)
} else {
path.starts_with(prefix)
&& !is_nested_child_for_level(path, prefix, nested_roots)
}
})
.collect();
let mut checkout_paths = BTreeSet::new();
let mut plain_submodules = Vec::new();
for s in &level_paths {
match s["state"].as_str().unwrap_or("submodule") {
"shared" => {
if let Some(target) = s["effective_target"].as_str() {
checkout_paths.insert(target.to_string());
}
}
"adopted" => {
if let Some(path) = s["path"].as_str() {
checkout_paths.insert(path.to_string());
}
}
_ => plain_submodules.push(*s),
}
}
let checkouts: Vec<&serde_json::Value> = rows
.iter()
.filter(|r| {
let Some(path) = r["path"].as_str() else {
return false;
};
checkout_paths.contains(path) && !printed_projects.contains(path)
})
.collect();
if checkouts.is_empty() && plain_submodules.is_empty() && level_paths.is_empty() {
return;
}
println!();
println!("{}", color::dim(title));
if !checkouts.is_empty() || !plain_submodules.is_empty() {
println!("{}", color::dim(" checkouts"));
for r in checkouts {
if let Some(path) = r["path"].as_str() {
printed_projects.insert(path.to_string());
}
print_checkout_row(r, true);
}
for s in plain_submodules {
print_plain_submodule_checkout(s);
}
}
let paths: Vec<&serde_json::Value> = level_paths
.into_iter()
.filter(|s| {
let Some(path) = s["path"].as_str() else {
return false;
};
s["state"] != "submodule" && !printed_paths.contains(path)
})
.collect();
if !paths.is_empty() {
println!("{}", color::dim(" paths"));
for s in paths {
if let Some(path) = s["path"].as_str() {
printed_paths.insert(path.to_string());
}
print_path_row(s, projects, target_to_paths);
}
}
}
fn is_nested_child_for_level(path: &str, prefix: &str, nested_roots: &[String]) -> bool {
for root in nested_roots {
let nested_prefix = format!("{root}/");
if nested_prefix == prefix {
continue;
}
if path.starts_with(&nested_prefix) {
return true;
}
}
false
}
fn is_app_level_path(path: &str, direct_counts: &BTreeMap<String, usize>) -> bool {
if path.matches('/').count() == 0 {
return true;
}
if let Some((root, _)) = path.split_once('/') {
return direct_counts.get(root).copied().unwrap_or(0) <= 1;
}
false
}
fn title_for(root: &str) -> String {
match root {
"bioscript" => "BioScript".into(),
"exvitae" => "ExVitae".into(),
"htslib-rs" => "htslib-rs".into(),
other => other
.split(['-', '_'])
.filter(|p| !p.is_empty())
.map(|p| {
let mut chars = p.chars();
match chars.next() {
Some(first) => format!("{}{}", first.to_uppercase(), chars.as_str()),
None => String::new(),
}
})
.collect::<Vec<_>>()
.join(" "),
}
}
fn print_checkout_row(r: &serde_json::Value, repoverse: bool) {
let path = r["path"].as_str().unwrap_or("?");
if r["state"] == "missing" {
println!(
" {} {}",
color::red("x"),
color::red(&format!("{path} (missing)"))
);
return;
}
let dirty = r["dirty"].as_bool().unwrap_or(false);
let mark = if dirty {
color::yellow("●")
} else {
color::green("●")
};
let suffix = if repoverse {
String::new()
} else {
format!(" {}", color::dim("[plain submodule, not mapped]"))
};
println!(
" {} {:<46} {}{}",
mark,
path,
r["branch"].as_str().unwrap_or("?"),
suffix
);
}
fn print_plain_submodule_checkout(s: &serde_json::Value) {
let dirty = s["dirty"].as_bool().unwrap_or(false);
let mark = if dirty {
color::yellow("●")
} else {
color::dim("●")
};
println!(
" {} {:<46} branch {} not mapped",
mark,
s["path"].as_str().unwrap_or("?"),
color::dim(s["branch"].as_str().unwrap_or("-"))
);
}
fn print_path_row(
s: &serde_json::Value,
projects: &BTreeMap<String, &serde_json::Value>,
target_to_paths: &BTreeMap<String, Vec<&serde_json::Value>>,
) {
let path = s["path"].as_str().unwrap_or("?");
let dirty = s["dirty"].as_bool().unwrap_or(false);
let conflict = if s["branch_conflict"].as_bool().unwrap_or(false) {
color::red(" branch-conflict")
} else {
String::new()
};
match s["state"].as_str().unwrap_or("submodule") {
"shared" => {
let target = s["effective_target"]
.as_str()
.or_else(|| s["shared_target"].as_str())
.unwrap_or("?");
let branch = projects
.get(target)
.and_then(|r| r["branch"].as_str())
.unwrap_or_else(|| s["branch"].as_str().unwrap_or("-"));
let mark = if dirty {
color::yellow("⇄")
} else {
color::cyan("⇄")
};
let fanin = target_to_paths
.get(target)
.map(|v| {
if v.len() > 1 {
format!(" {}", color::dim(&format!("[shared x{}]", v.len())))
} else {
String::new()
}
})
.unwrap_or_default();
println!(
" {} {:<62} -> {:<34} {}{}{}",
mark,
path,
color::cyan(target),
color::dim(branch),
fanin,
conflict
);
}
"adopted" => {
let mark = if dirty {
color::yellow("●")
} else {
color::green("●")
};
println!(
" {} {:<62} {}{}",
mark,
path,
color::green("[repoverse checkout]"),
conflict
);
}
_ => {
let mark = if dirty {
color::yellow("●")
} else {
color::dim("●")
};
println!(
" {} {:<62} {} {}{}",
mark,
path,
color::dim(s["branch"].as_str().unwrap_or("-")),
color::dim("[plain submodule, not mapped]"),
conflict
);
}
}
}
}
pub mod init {
use super::*;
use crate::config::Submodules;
use crate::git;
pub fn run(https: bool, ssh: bool) -> Result<()> {
let cwd = std::env::current_dir()?;
let cfg_path =
Config::discover(&cwd).ok_or_else(|| anyhow::anyhow!("no .repoverse.yaml here"))?;
let root = cfg_path.parent().unwrap().to_path_buf();
let cfg = Config::load(&cfg_path)?;
let flag = scheme_flag(https, ssh);
for p in &cfg.projects {
if p.path == "." {
continue;
}
let dir = root.join(&p.path);
if git::is_repo(&dir) {
println!(" ok {}", p.path);
continue;
}
let url = project_url(&cfg, p, flag)?;
println!(" clone {}", p.path);
git::clone(&root, &url, &p.path)?;
git::checkout(&dir, cfg.project_revision(p))?;
if p.submodules != Submodules::None {
let mut a = vec!["submodule", "update", "--init", "--recursive"];
if p.submodules == Submodules::Shallow {
a.push("--depth");
a.push("1");
}
git::check(&dir, &a)?;
}
}
super::tasks_cmd::run(super::tasks_cmd::Task::Setup, false, &[])
.unwrap_or_else(|e| eprintln!(" setup skipped: {e:#}"));
Ok(())
}
}
pub mod fetch {
use super::*;
use crate::git;
use rayon::prelude::*;
pub fn run(jobs: usize, targets: &[String]) -> Result<()> {
let ws = Workspace::discover()?;
if !targets.is_empty() && ws.resolve_targets(targets).is_err() {
if let Some((label, dir)) = current_git_target()? {
let mut args = vec![
"-c",
"submodule.recurse=false",
"-c",
"fetch.recurseSubmodules=false",
"fetch",
"--recurse-submodules=no",
];
args.extend(targets.iter().map(String::as_str));
git::check(&dir, &args)?;
println!(" fetched {}", label);
return Ok(());
}
}
let projects = current_or_workspace_targets(&ws, targets)?;
let pool = rayon::ThreadPoolBuilder::new()
.num_threads(jobs.max(1))
.build()?;
pool.install(|| {
projects.par_iter().for_each(|(label, dir)| {
if git::is_repo(dir) {
match git::fetch(dir) {
Ok(()) => println!(" fetched {}", label),
Err(e) => eprintln!(" FAIL {} {e:#}", label),
}
}
});
});
Ok(())
}
}
pub mod history {
use super::*;
use crate::git;
#[derive(Clone, Copy)]
pub enum Action {
Merge,
Rebase,
}
impl Action {
fn verb(self) -> &'static str {
match self {
Action::Merge => "merge",
Action::Rebase => "rebase",
}
}
}
pub fn run(action: Action, args: &[String]) -> Result<()> {
let ws = Workspace::discover()?;
let (git_args, target_args) = split_git_args_and_targets(&ws, args);
if git_args.is_empty() {
bail!("rv {} requires git arguments", action.verb());
}
let targets = current_or_workspace_targets(&ws, &target_args)?;
if targets.is_empty() {
bail!("no targets selected");
}
let mut failed = false;
for (label, dir) in targets {
if !git::is_repo(&dir) {
eprintln!(" skip {} (missing)", label);
continue;
}
if let Err(e) = git::fetch(&dir) {
failed = true;
eprintln!(" FAIL fetch {} {e:#}", label);
continue;
}
let resolved_args = resolve_revisions(&dir, &git_args);
let mut cmd_args = match action {
Action::Merge => vec![
"-c",
"submodule.recurse=false",
"merge",
"--no-recurse-submodules",
],
Action::Rebase => vec!["-c", "submodule.recurse=false", "rebase"],
};
cmd_args.extend(resolved_args.iter().map(String::as_str));
match git::check(&dir, &cmd_args) {
Ok(_) => println!(" {} {}", action.verb(), label),
Err(e) => {
failed = true;
eprintln!(" FAIL {} {} — {e:#}", action.verb(), label);
}
}
}
if !failed {
super::adopt::relink()?;
} else {
eprintln!(
"{} failed; leaving tree as-is for conflict/error inspection",
action.verb()
);
}
Ok(())
}
fn split_git_args_and_targets(ws: &Workspace, args: &[String]) -> (Vec<String>, Vec<String>) {
if args.len() < 2 {
return (args.to_vec(), Vec::new());
}
let mut split = args.len();
while split > 1 {
let candidate = &args[split - 1..];
if ws.resolve_targets(candidate).is_ok() {
split -= 1;
} else {
break;
}
}
(args[..split].to_vec(), args[split..].to_vec())
}
fn resolve_revisions(dir: &Path, args: &[String]) -> Vec<String> {
args.iter().map(|arg| resolve_revision(dir, arg)).collect()
}
fn resolve_revision(dir: &Path, rev: &str) -> String {
if rev.starts_with('-') {
return rev.to_string();
}
if rev.contains('/') || rev == "HEAD" {
return rev.to_string();
}
let origin = format!("origin/{rev}");
if git::run(dir, &["rev-parse", "--verify", &origin])
.map(|o| o.ok())
.unwrap_or(false)
{
origin
} else {
rev.to_string()
}
}
}
pub mod pull {
use super::*;
use crate::git;
pub fn run(rebase: bool, targets: &[String]) -> Result<()> {
let ws = Workspace::discover()?;
let mut failed = false;
for (label, dir) in current_or_workspace_targets(&ws, targets)? {
if !git::is_repo(&dir) {
continue;
}
let mut a = vec![
"-c",
"submodule.recurse=false",
"pull",
"--recurse-submodules=no",
];
if rebase {
a.push("--rebase");
}
match git::check(&dir, &a) {
Ok(_) => println!(" pulled {}", label),
Err(e) => {
failed = true;
eprintln!(" FAIL {} {e:#}", label);
}
}
}
if !failed {
super::adopt::relink()?;
} else {
eprintln!("pull failed; leaving tree as-is for conflict/error inspection");
}
Ok(())
}
}
pub mod sync {
use super::*;
use crate::git;
pub fn run(force: bool) -> Result<()> {
let ws = Workspace::discover()?;
let lock = ws.lock()?;
for p in &ws.config.projects {
let dir = ws.project_dir(p);
if !git::is_repo(&dir) {
eprintln!(" skip {} (missing — run `rv init`)", p.path);
continue;
}
if force {
git::check(&dir, &["reset", "--hard"])?;
git::check(&dir, &["clean", "-fd"])?;
} else if git::is_dirty(&dir)? {
eprintln!(" skip {} (dirty — use --force)", p.path);
continue;
}
let target = lock
.sha_for(&p.path)
.map(|s| s.to_string())
.unwrap_or_else(|| ws.config.project_revision(p).to_string());
git::checkout(&dir, &target)?;
println!(" synced {} -> {}", p.path, &target[..target.len().min(12)]);
}
Ok(())
}
}
pub mod pin {
use super::*;
use crate::git;
use crate::lock::{Lock, LockedProject};
pub fn run() -> Result<()> {
let ws = Workspace::discover()?;
let mut lock = Lock::new();
for p in &ws.config.projects {
let dir = ws.project_dir(p);
if !git::is_repo(&dir) {
continue;
}
lock.projects.push(LockedProject {
path: p.path.clone(),
name: p.name.clone(),
revision: ws.config.project_revision(p).to_string(),
sha: git::head_sha(&dir)?,
});
}
lock.save(&ws.lock_path)?;
println!(
"pinned {} projects -> {}",
lock.projects.len(),
ws.lock_path.display()
);
Ok(())
}
}
pub mod update {
use super::*;
use crate::git;
use crate::lock::{Lock, LockedProject};
pub fn run() -> Result<()> {
let ws = Workspace::discover()?;
let mut lock = Lock::new();
for p in &ws.config.projects {
let dir = ws.project_dir(p);
if !git::is_repo(&dir) {
continue;
}
git::fetch(&dir)?;
let rev = ws.config.project_revision(p);
let sha = git::check(&dir, &["rev-parse", &format!("origin/{rev}")])
.or_else(|_| git::check(&dir, &["rev-parse", rev]))?
.trim()
.to_string();
lock.projects.push(LockedProject {
path: p.path.clone(),
name: p.name.clone(),
revision: rev.to_string(),
sha,
});
}
lock.save(&ws.lock_path)?;
println!("updated lock to branch tips");
Ok(())
}
}
pub mod branch {
use super::*;
use crate::git;
pub fn run(name: &str) -> Result<()> {
let ws = Workspace::discover()?;
let mut touched = 0;
for p in &ws.config.projects {
let dir = ws.project_dir(p);
if git::is_repo(&dir) && git::is_dirty(&dir)? {
git::checkout_new_branch(&dir, name)?;
println!(" {} -> {name}", p.path);
touched += 1;
}
}
if touched == 0 {
println!("no dirty repos; nothing to branch");
}
Ok(())
}
}
pub mod checkout {
use super::*;
use crate::git;
pub fn run(rev: &str, targets: &[String], reset: bool) -> Result<()> {
let ws = Workspace::discover()?;
for (label, dir) in current_or_workspace_targets(&ws, targets)? {
if !git::is_repo(&dir) {
continue;
}
if reset {
git::check(&dir, &["reset", "--hard"])?;
}
git::checkout(&dir, rev)?;
println!(" {} -> {rev}", label);
}
super::adopt::relink()?;
Ok(())
}
}
pub mod switch {
use super::*;
use crate::git;
pub fn run(create: bool, branch: &str, targets: &[String]) -> Result<()> {
let ws = Workspace::discover()?;
for p in ws.resolve_targets(targets)? {
let dir = ws.project_dir(p);
if !git::is_repo(&dir) {
continue;
}
if create {
git::checkout_new_branch(&dir, branch)?;
} else {
git::checkout(&dir, branch)?;
}
println!(" {} -> {branch}", p.path);
}
Ok(())
}
}
pub mod remote_cmd {
use super::*;
use crate::git;
pub fn run(scheme: &str) -> Result<()> {
let ws = Workspace::discover()?;
let flag = if scheme == "https" {
SchemeFlag::Https
} else {
SchemeFlag::Ssh
};
for p in &ws.config.projects {
let dir = ws.project_dir(p);
if !git::is_repo(&dir) {
continue;
}
let url = project_url(&ws.config, p, flag)?;
git::check(&dir, &["remote", "set-url", "origin", &url])?;
println!(" {} -> {scheme}", p.path);
}
Ok(())
}
}
pub mod commit {
use super::*;
use crate::git;
pub fn run(message: &str, targets: &[String]) -> Result<()> {
let ws = Workspace::discover()?;
let mut n = 0;
for p in ws.resolve_targets(targets)? {
let dir = ws.project_dir(p);
if git::is_repo(&dir) && git::is_dirty(&dir)? {
git::check(&dir, &["add", "-A"])?;
git::check(&dir, &["commit", "-m", message])?;
println!(" committed {}", p.path);
n += 1;
}
}
println!("{n} repo(s) committed");
Ok(())
}
}
pub mod push {
use super::*;
use crate::git;
pub fn run() -> Result<()> {
let ws = Workspace::discover()?;
for p in &ws.config.projects {
let dir = ws.project_dir(p);
if !git::is_repo(&dir) {
continue;
}
let br = git::current_branch(&dir)?;
match git::check(&dir, &["push", "-u", "origin", &br]) {
Ok(_) => println!(" pushed {} ({br})", p.path),
Err(e) => eprintln!(" FAIL {} {e:#}", p.path),
}
}
Ok(())
}
}
pub mod tasks_cmd {
use super::*;
use crate::git;
use crate::tasks::{self, Kind};
use std::process::Command;
#[derive(Clone, Copy)]
pub enum Task {
Setup,
Lint,
Test,
}
impl Task {
fn kind(self) -> Kind {
match self {
Task::Setup => Kind::Setup,
Task::Lint => Kind::Lint,
Task::Test => Kind::Test,
}
}
}
fn run_script(dir: &Path, script: &str) -> Result<bool> {
let status = Command::new("bash")
.arg("-eo")
.arg("pipefail")
.arg("-c")
.arg(script)
.current_dir(dir)
.status()?;
Ok(status.success())
}
pub fn run(task: Task, force: bool, targets: &[String]) -> Result<()> {
let ws = Workspace::discover()?;
let kind = task.kind();
let mut failures = 0;
for p in ws.resolve_targets(targets)? {
let dir = ws.project_dir(p);
if !git::is_repo(&dir) {
continue;
}
let dirty = git::is_dirty(&dir).unwrap_or(false);
if !matches!(task, Task::Setup) && !force && !dirty {
continue;
}
match tasks::resolve(p, &dir, kind)? {
None => eprintln!(
" skip {} ({} — no command resolved)",
p.path,
kind.as_str()
),
Some(r) => {
for c in &r.caveats {
eprintln!(" note {} {}", p.path, c);
}
match run_script(&dir, &r.script) {
Ok(true) => println!(" ok {} {} [{}]", p.path, kind.as_str(), r.source),
Ok(false) | Err(_) => {
eprintln!(" FAIL {} {}", p.path, kind.as_str());
failures += 1;
}
}
}
}
}
if failures > 0 {
bail!("{failures} {} failure(s)", kind.as_str());
}
Ok(())
}
}
pub mod check {
use super::*;
pub fn run(json: bool, targets: &[String]) -> Result<()> {
let lint = super::tasks_cmd::run(super::tasks_cmd::Task::Lint, false, targets);
let test = super::tasks_cmd::run(super::tasks_cmd::Task::Test, false, targets);
if json {
println!(
"{}",
serde_json::json!({
"lint_ok": lint.is_ok(),
"test_ok": test.is_ok(),
})
);
}
if lint.is_err() || test.is_err() {
bail!("check failed");
}
println!("check: all green");
Ok(())
}
}
pub mod import {
use super::*;
fn attr<'a>(tag: &'a str, key: &str) -> Option<&'a str> {
let pat = format!("{key}=\"");
let i = tag.find(&pat)? + pat.len();
let rest = &tag[i..];
let j = rest.find('"')?;
Some(&rest[..j])
}
pub fn run(manifest: &str) -> Result<()> {
let text = std::fs::read_to_string(manifest)?;
let mut default_rev = "main".to_string();
let mut host = "github.com".to_string();
let mut projects = Vec::new();
for raw in text.split('<') {
let tag = raw.trim();
if let Some(t) = tag.strip_prefix("default ") {
if let Some(r) = attr(t, "revision") {
default_rev = r.to_string();
}
} else if let Some(t) = tag.strip_prefix("remote ") {
if let Some(f) = attr(t, "fetch") {
host = f
.trim_start_matches("ssh://")
.trim_start_matches("https://")
.trim_start_matches("git@")
.trim_end_matches('/')
.to_string();
}
} else if let Some(t) = tag.strip_prefix("project ") {
let name = attr(t, "name").unwrap_or_default().to_string();
let path = attr(t, "path").unwrap_or(&name).to_string();
let rev = attr(t, "revision").map(|s| s.to_string());
projects.push(Project {
name,
path,
revision: rev.filter(|r| r != &default_rev),
remote: None,
submodules: Default::default(),
consumes: vec![],
ci: None,
setup: None,
lint: None,
test: None,
shared: vec![],
});
}
}
let mut remotes = std::collections::BTreeMap::new();
remotes.insert("github".to_string(), crate::config::Remote { host });
let cfg = Config {
version: 1,
defaults: crate::config::Defaults {
remote: "github".into(),
revision: default_rev,
scheme: Scheme::Ssh,
},
remotes,
projects,
provides: vec![],
links: vec![],
};
cfg.validate()?;
let out = std::path::Path::new(crate::config::CONFIG_FILE);
std::fs::write(out, serde_yaml::to_string(&cfg)?)?;
println!(
"wrote {} ({} projects) from {manifest}",
out.display(),
cfg.projects.len()
);
Ok(())
}
}
pub mod adopt {
use super::*;
use crate::config::{Defaults, Remote};
use std::collections::BTreeMap;
#[cfg(unix)]
struct Occurrence {
consumer: std::path::PathBuf,
sub_rel: String,
ws_rel: String,
sha: String,
url: String,
sub_name: String,
branch: Option<String>,
}
#[cfg(unix)]
fn occurrences(root: &Path, ws_paths: &[String]) -> Result<Vec<Occurrence>> {
let mut out = Vec::new();
for wsp in ws_paths {
let dir = root.join(wsp);
if !crate::git::is_repo(&dir) {
continue;
}
let sup = crate::git::run(&dir, &["rev-parse", "--show-superproject-working-tree"])?;
if !sup.ok() || sup.stdout.trim().is_empty() {
bail!("{wsp}: no superproject (not a submodule?)");
}
let consumer = std::fs::canonicalize(sup.stdout.trim())
.unwrap_or_else(|_| std::path::PathBuf::from(sup.stdout.trim()));
let dir_canon = std::fs::canonicalize(&dir).unwrap_or_else(|_| dir.clone());
let sub_rel = match dir_canon.strip_prefix(&consumer) {
Ok(p) => p.to_string_lossy().replace('\\', "/"),
Err(_) => bail!(
"{wsp}: cannot locate inside superproject {} \
(refusing to guess — no changes made)",
consumer.display()
),
};
let sha = crate::git::head_sha(&dir)
.ok()
.or_else(|| crate::git::gitlink_sha(&consumer, &sub_rel))
.ok_or_else(|| anyhow::anyhow!("{wsp}: cannot resolve sha"))?;
let url = crate::git::run(&dir, &["remote", "get-url", "origin"])?
.stdout
.trim()
.to_string();
let gm = std::fs::read_to_string(consumer.join(".gitmodules")).unwrap_or_default();
let entry = crate::submodule::parse(&gm)
.into_iter()
.find(|e| e.path == sub_rel);
let sub_name = entry
.as_ref()
.map(|e| e.name.clone())
.unwrap_or_else(|| sub_rel.clone());
let branch = entry.and_then(|e| e.branch);
out.push(Occurrence {
consumer,
sub_rel,
ws_rel: wsp.clone(),
sha,
url,
sub_name,
branch,
});
}
Ok(out)
}
#[cfg_attr(not(unix), allow(unused_variables))]
pub fn step(
repo: &str,
into: Option<&str>,
normalize_branch: Option<&str>,
standalone: bool,
yes: bool,
) -> Result<()> {
#[cfg(not(unix))]
{
bail!(
"`adopt --step` symlink overlay is unix-only here; on Windows run it in CI via rv"
);
}
#[cfg(unix)]
{
let root = std::env::current_dir()?;
if !root.join(".gitmodules").is_file() {
bail!("run from the workspace root (no .gitmodules here)");
}
let root_id = git_origin_name(&root)
.map(|n| crate::submodule::normalize_url(&n))
.unwrap_or_else(|| "workspace/root".into());
let plan = crate::submodule::scan(&root, &root_id);
let node = plan
.order
.iter()
.find(|n| n.id == repo || n.id.ends_with(&format!("/{repo}")))
.ok_or_else(|| {
anyhow::anyhow!("`{repo}` not found in scan; try `rv adopt --plan`")
})?;
let occ = occurrences(&root, &node.paths)?;
if occ.is_empty() {
bail!("no checked-out copies of `{}` found", node.id);
}
let sha0 = occ[0].sha.clone();
if let Some(bad) = occ.iter().find(|o| o.sha != sha0) {
bail!(
"copies are on different commits ({} vs {}). Converge to one \
SHA before lifting (see noodles procedure).",
&sha0[..12.min(sha0.len())],
&bad.sha[..12.min(bad.sha.len())]
);
}
if !node.branch_conflicts.is_empty() {
println!(
" note: .gitmodules branch labels differ ({}) but all copies \
are on the same commit {} — safe to lift.",
node.branch_conflicts.join(", "),
&sha0[..12.min(sha0.len())]
);
}
for o in &occ {
if crate::git::is_dirty(&root.join(&o.ws_rel)).unwrap_or(false) {
bail!("{} is dirty — commit/stash before lifting", o.ws_rel);
}
}
let basename = node.id.rsplit('/').next().unwrap().to_string();
let into_dir = match into {
Some(d) => d.trim_matches('/').to_string(),
None => "repos".to_string(),
};
let dest_rel = if into_dir.is_empty() || into_dir == "." {
basename.clone()
} else {
format!("{into_dir}/{basename}")
};
let ignore_entry = if into_dir.is_empty() || into_dir == "." {
format!("/{basename}/")
} else {
format!("/{}/", into_dir.split('/').next().unwrap())
};
let dest = root.join(&dest_rel);
if let Some(parent) = dest.parent() {
std::fs::create_dir_all(parent).ok();
}
println!(
"lift `{}` -> one shared copy at {}",
node.id,
color::cyan(&format!("./{dest_rel}"))
);
println!(" canonical commit: {}", &sha0[..12.min(sha0.len())]);
println!(" {} consumer copies become symlinks:", occ.len());
for o in &occ {
println!(" {} (in {})", o.ws_rel, o.sub_name);
}
println!(" workspace .gitignore += {ignore_entry}");
{
let probe = root.join(&occ[0].ws_rel);
match canonical_branch(&probe, &occ, normalize_branch, &sha0) {
Some(b) => println!(" .repoverse.yaml revision (branch intent): {b}"),
None => println!(
" no branch detected — pass --normalize-branch <b> to record intent"
),
}
}
if let Some(b) = normalize_branch {
println!(
" normalize .gitmodules branch= -> {b} in {} consumer(s)",
occ.len()
);
}
if !yes {
println!(
"\ndry-run. Nothing changed. Re-run with --yes to perform it.\n\
(.gitmodules + gitlinks stay intact; no pushes; reversible.)"
);
return Ok(());
}
let gi = root.join(".gitignore");
let cur = std::fs::read_to_string(&gi).unwrap_or_default();
if !cur.lines().any(|l| l.trim() == ignore_entry) {
let mut s = cur;
if !s.is_empty() && !s.ends_with('\n') {
s.push('\n');
}
s.push_str(&format!("{ignore_entry}\n"));
std::fs::write(&gi, s)?;
println!(" gitignored {ignore_entry}");
}
if !dest.exists() {
println!("→ cloning shared copy (non-recursive)");
crate::git::clone(&root, &o_url(&occ[0]), &dest_rel)?;
crate::git::checkout(&dest, &sha0)?;
}
for o in &occ {
let abs = root.join(&o.ws_rel);
establish_link(&o.consumer, &o.sub_rel, &o.sub_name, &abs, &dest)?;
println!(" linked {} -> shared", o.ws_rel);
}
if let Some(b) = normalize_branch {
for o in &occ {
crate::git::check(
&o.consumer,
&[
"config",
"-f",
".gitmodules",
&format!("submodule.{}.branch", o.sub_name),
b,
],
)?;
crate::git::check(&o.consumer, &["add", ".gitmodules"])?;
println!(
" normalized {} branch= -> {b} (staged, not pushed)",
o.ws_rel
);
}
}
let branch = canonical_branch(&dest, &occ, normalize_branch, &sha0);
if let Some(b) = &branch {
if crate::git::run(&dest, &["rev-parse", "--verify", &format!("origin/{b}")])
.map(|o| o.ok())
.unwrap_or(false)
{
let _ = crate::git::check(
&dest,
&["checkout", "-B", b, "--track", &format!("origin/{b}")],
);
} else {
let _ = crate::git::check(&dest, &["checkout", "-B", b]);
}
println!(" shared copy on branch {b} (editable)");
}
match &branch {
Some(b) => println!(" recording branch intent: {b}"),
None => println!(
" no branch detected for {} — yaml inherits workspace default; \
re-run with --normalize-branch <b> to pin intent",
node.id
),
}
write_shared_config(&root, node, &occ, &dest_rel, branch.as_deref(), standalone)?;
println!(
"\ndone. Edit ./{} once; `rv rollup` propagates the SHA to all \
consumers. .gitmodules untouched; nothing pushed.",
dest_rel
);
Ok(())
}
}
#[cfg(unix)]
fn o_url(o: &Occurrence) -> String {
o.url.clone()
}
#[cfg(unix)]
fn establish_link(
consumer: &Path,
sub_rel: &str,
sub_name: &str,
_link_abs: &Path,
provider: &Path,
) -> Result<()> {
let _ = crate::git::submodule_deinit(consumer, sub_rel);
let logical = consumer.join(sub_rel);
let real_link = match (logical.parent(), logical.file_name()) {
(Some(p), Some(name)) => std::fs::canonicalize(p)
.map(|rp| rp.join(name))
.unwrap_or_else(|_| logical.clone()),
_ => logical.clone(),
};
let provider = std::fs::canonicalize(provider).unwrap_or_else(|_| provider.to_path_buf());
if std::fs::symlink_metadata(&real_link)
.map(|m| m.file_type().is_symlink())
.unwrap_or(false)
{
std::fs::remove_file(&real_link).ok();
}
let rl_canon = std::fs::canonicalize(&real_link).unwrap_or_else(|_| real_link.clone());
if rl_canon == provider
|| provider.starts_with(&rl_canon)
|| rl_canon.starts_with(&provider)
{
bail!(
"refusing to link {} -> {}: would create a symlink cycle / \
self-nest (provider and link overlap). This is the parent-contains-shared \
case — handle it as a nested workspace, not a top-level link.",
real_link.display(),
provider.display()
);
}
let meta = std::fs::symlink_metadata(&real_link);
match meta {
Ok(m) if m.file_type().is_symlink() => {
std::fs::remove_file(&real_link).ok();
}
Ok(m) if m.is_dir() => {
let is_submodule = crate::git::gitlink_sha(consumer, sub_rel).is_some()
|| real_link.join(".git").exists();
let empty = std::fs::read_dir(&real_link)
.map(|mut d| d.next().is_none())
.unwrap_or(true);
if !is_submodule && !empty {
bail!(
"refusing to delete non-submodule directory {} \
({} — not a gitlink): aborting to avoid data loss",
real_link.display(),
"populated"
);
}
std::fs::remove_dir_all(&real_link).ok();
}
Ok(_) => {
std::fs::remove_file(&real_link).ok();
}
Err(_) => {}
}
if let Some(p) = real_link.parent() {
std::fs::create_dir_all(p).ok();
}
let target = pathdiff_rel(&real_link, &provider);
std::os::unix::fs::symlink(&target, &real_link)?;
let _ = crate::git::run(
consumer,
&["config", &format!("submodule.{sub_name}.ignore"), "all"],
);
crate::git::set_skip_worktree(consumer, sub_rel, true)?;
Ok(())
}
#[cfg(unix)]
fn unlink_one(consumer: &Path, sub_rel: &str, sub_name: &str) -> Result<()> {
let real = consumer.join(sub_rel);
if std::fs::symlink_metadata(&real)
.map(|m| m.file_type().is_symlink())
.unwrap_or(false)
{
std::fs::remove_file(&real).ok();
}
let _ = crate::git::set_skip_worktree(consumer, sub_rel, false);
let _ = crate::git::run(
consumer,
&["config", "--unset", &format!("submodule.{sub_name}.ignore")],
);
crate::git::check(consumer, &["submodule", "update", "--init", sub_rel])?;
Ok(())
}
pub fn unlink(repo: Option<&str>) -> Result<()> {
#[cfg(not(unix))]
{
let _ = repo;
bail!("`rv unlink` is unix-only here");
}
#[cfg(unix)]
{
let ws = Workspace::discover()?;
let owning = |at: &str| -> Option<(std::path::PathBuf, String)> {
let full = ws.root.join(at);
let mut cur = full.parent();
while let Some(d) = cur {
if d.join(".git").exists() {
let rel = full
.strip_prefix(d)
.ok()
.map(|p| p.to_string_lossy().replace('\\', "/"))?;
return Some((d.to_path_buf(), rel));
}
if d == ws.root {
break;
}
cur = d.parent();
}
None
};
let mut n = 0;
let mut root_links: Vec<_> = ws.config.links.iter().collect();
root_links.sort_by_key(|l| l.at.matches('/').count());
for l in root_links {
if let Some(r) = repo {
if l.repo != r && !l.repo.ends_with(&format!("/{r}")) {
continue;
}
}
let Some((consumer, sub_rel)) = owning(&l.at) else {
eprintln!(" skip {} (no owning repo)", l.at);
continue;
};
let sub_name = sub_rel.rsplit('/').next().unwrap_or(&sub_rel);
match unlink_one(&consumer, &sub_rel, sub_name) {
Ok(()) => {
println!(" unlinked {} (restored submodule)", l.at);
n += 1;
}
Err(e) => eprintln!(" FAIL unlink {} — {e:#}", l.at),
}
}
println!("unlink: {n} restored. Do git work, then `rv link`.");
Ok(())
}
}
pub fn relink() -> Result<()> {
#[cfg(not(unix))]
{
bail!("`rv link` is unix-only here; run in CI via rv on Windows");
}
#[cfg(unix)]
{
let ws = Workspace::discover()?;
let provider_dir = |name: &str| -> Option<std::path::PathBuf> {
ws.config
.projects
.iter()
.find(|p| p.name == name)
.map(|p| ws.project_dir(p))
.filter(|d| d.exists())
};
let mut consumers: Vec<std::path::PathBuf> = Vec::new();
for p in &ws.config.projects {
let d = ws.project_dir(p);
if d != ws.root && d.join(crate::config::CONFIG_FILE).is_file() {
consumers.push(d);
}
}
let mut stack = vec![ws.root.clone()];
while let Some(d) = stack.pop() {
let Ok(rd) = std::fs::read_dir(&d) else {
continue;
};
for e in rd.flatten() {
let p = e.path();
let name = e.file_name();
let name = name.to_string_lossy();
if p.is_symlink() {
continue;
}
if p.is_dir() {
if name == ".git"
|| name == "node_modules"
|| name == "target"
|| name == "repos"
|| name.starts_with('.') && name != "."
{
continue;
}
stack.push(p);
} else if name == crate::config::CONFIG_FILE
&& d != ws.root
&& !consumers.iter().any(|c| c == &d)
{
consumers.push(d.clone());
}
}
}
let owning = |at: &str| -> Option<(std::path::PathBuf, String)> {
let full = ws.root.join(at);
let mut cur = full.parent();
while let Some(d) = cur {
if d.join(".git").exists() {
let rel = full
.strip_prefix(d)
.ok()
.map(|p| p.to_string_lossy().replace('\\', "/"))?;
return Some((d.to_path_buf(), rel));
}
if d == ws.root {
break;
}
cur = d.parent();
}
None
};
let mut linked = 0;
let mut skipped = 0;
let mut root_links: Vec<_> = ws.config.links.iter().collect();
root_links.sort_by_key(|l| l.at.matches('/').count());
for l in root_links
.iter()
.copied()
.filter(|l| l.at.matches('/').count() <= 1)
{
let Some(pd) = provider_dir(&l.repo) else {
eprintln!(" skip {} (no provider for {})", l.at, l.repo);
skipped += 1;
continue;
};
let Some((consumer, sub_rel)) = owning(&l.at) else {
eprintln!(" skip {} (no owning repo)", l.at);
skipped += 1;
continue;
};
let sub_name = sub_rel.rsplit('/').next().unwrap_or(&sub_rel);
establish_link(&consumer, &sub_rel, sub_name, &ws.root.join(&l.at), &pd)?;
println!(" linked {} -> shared ({})", l.at, l.repo);
linked += 1;
}
for cdir in consumers {
let ccfg = Config::load(&cdir.join(crate::config::CONFIG_FILE))?;
let nested_owning = |at: &str| -> Option<(std::path::PathBuf, String)> {
let full = cdir.join(at);
let mut cur = full.parent();
while let Some(d) = cur {
if d.join(".git").exists() {
let rel = full
.strip_prefix(d)
.ok()
.map(|p| p.to_string_lossy().replace('\\', "/"))?;
return Some((d.to_path_buf(), rel));
}
if d == cdir {
break;
}
cur = d.parent();
}
Some((cdir.clone(), at.to_string()))
};
let nested_provider_dir = |name: &str| -> Option<std::path::PathBuf> {
provider_dir(name).or_else(|| {
ccfg.projects
.iter()
.find(|p| p.name == name)
.map(|p| {
if p.path == "." {
cdir.clone()
} else {
cdir.join(&p.path)
}
})
.filter(|d| d.exists())
})
};
let mut nested_links: Vec<_> = ccfg.links.iter().collect();
nested_links.sort_by_key(|l| l.at.matches('/').count());
for l in nested_links {
let Some(pd) = nested_provider_dir(&l.repo) else {
eprintln!(
" skip {} (no provider for {}) — left as submodule",
l.at, l.repo
);
skipped += 1;
continue;
};
let Some((consumer, sub_rel)) = nested_owning(&l.at) else {
eprintln!(" skip {} (no owning repo)", l.at);
skipped += 1;
continue;
};
let link_abs = consumer.join(&sub_rel);
let sub_name = sub_rel.rsplit('/').next().unwrap_or(&sub_rel);
establish_link(&consumer, &sub_rel, sub_name, &link_abs, &pd)?;
println!(
" linked {}/{} -> shared ({})",
cdir.strip_prefix(&ws.root).unwrap_or(&cdir).display(),
l.at,
l.repo
);
linked += 1;
}
for cp in &ccfg.projects {
for dep in &cp.shared {
let Some(pd) = nested_provider_dir(&dep.name) else {
eprintln!(
" skip {} (no provider for {}) — left as submodule",
dep.path, dep.name
);
skipped += 1;
continue;
};
let link_abs = cdir.join(&dep.path);
let sub_name = dep.path.rsplit('/').next().unwrap_or(&dep.path);
establish_link(&cdir, &dep.path, sub_name, &link_abs, &pd)?;
println!(
" linked {}/{} -> shared",
cdir.strip_prefix(&ws.root).unwrap_or(&cdir).display(),
dep.path
);
linked += 1;
}
}
}
for l in root_links
.into_iter()
.filter(|l| l.at.matches('/').count() > 1)
{
let Some(pd) = provider_dir(&l.repo) else {
eprintln!(" skip {} (no provider for {})", l.at, l.repo);
skipped += 1;
continue;
};
let Some((consumer, sub_rel)) = owning(&l.at) else {
eprintln!(" skip {} (no owning repo)", l.at);
skipped += 1;
continue;
};
let sub_name = sub_rel.rsplit('/').next().unwrap_or(&sub_rel);
establish_link(&consumer, &sub_rel, sub_name, &ws.root.join(&l.at), &pd)?;
println!(" linked {} -> shared ({})", l.at, l.repo);
linked += 1;
}
println!("relink: {linked} linked, {skipped} left as submodule");
Ok(())
}
}
#[cfg(unix)]
fn pathdiff_rel(link: &Path, target: &Path) -> std::path::PathBuf {
let from = link.parent().unwrap_or(Path::new("."));
let f: Vec<_> = from.components().collect();
let t: Vec<_> = target.components().collect();
let mut i = 0;
while i < f.len() && i < t.len() && f[i] == t[i] {
i += 1;
}
let mut out = std::path::PathBuf::new();
for _ in i..f.len() {
out.push("..");
}
for c in &t[i..] {
out.push(c.as_os_str());
}
out
}
#[cfg(unix)]
fn canonical_branch(
dest: &Path,
occ: &[Occurrence],
normalize_branch: Option<&str>,
sha: &str,
) -> Option<String> {
if let Some(b) = normalize_branch {
return Some(b.to_string());
}
let labels: std::collections::BTreeSet<&String> =
occ.iter().filter_map(|o| o.branch.as_ref()).collect();
if labels.len() == 1 {
return Some(labels.into_iter().next().unwrap().clone());
}
let out = crate::git::run(
dest,
&[
"branch",
"-r",
"--contains",
sha,
"--format=%(refname:short)",
],
)
.ok()?;
let mut remotes: Vec<String> = out
.stdout
.lines()
.map(|l| l.trim().trim_start_matches("origin/").to_string())
.filter(|b| !b.is_empty() && !b.starts_with("HEAD"))
.collect();
if let Some(m) = remotes
.iter()
.find(|b| occ.iter().any(|o| o.branch.as_deref() == Some(b.as_str())))
.cloned()
{
return Some(m);
}
remotes.sort();
remotes.into_iter().next()
}
#[cfg(unix)]
fn write_shared_config(
root: &Path,
node: &crate::submodule::Node,
occ: &[Occurrence],
dest_rel: &str,
branch: Option<&str>,
standalone: bool,
) -> Result<()> {
let cfg_path = root.join(crate::config::CONFIG_FILE);
let mut cfg = if cfg_path.is_file() {
Config::load(&cfg_path)?
} else {
Config {
version: 1,
defaults: crate::config::Defaults {
remote: "github".into(),
revision: "main".into(),
scheme: Scheme::Ssh,
},
remotes: {
let mut m = std::collections::BTreeMap::new();
m.insert(
"github".into(),
crate::config::Remote {
host: "github.com".into(),
},
);
m
},
projects: vec![],
provides: vec![],
links: vec![],
}
};
let owner_repo = node
.id
.split_once('/')
.map(|x| x.1.to_string())
.unwrap_or_else(|| node.id.clone());
if !cfg.provides.contains(&owner_repo) {
cfg.provides.push(owner_repo.clone());
}
if !cfg.projects.iter().any(|p| p.path == dest_rel) {
cfg.projects.push(Project {
name: owner_repo.clone(),
path: dest_rel.to_string(),
revision: branch.map(|s| s.to_string()),
remote: None,
submodules: Default::default(),
consumes: vec![],
ci: None,
setup: None,
lint: None,
test: None,
shared: vec![],
});
}
for o in occ {
if !cfg.links.iter().any(|l| l.at == o.ws_rel) {
cfg.links.push(crate::config::Link {
repo: owner_repo.clone(),
at: o.ws_rel.clone(),
branch: branch.map(|s| s.to_string()),
});
}
}
std::fs::write(&cfg_path, serde_yaml::to_string(&cfg)?)?;
println!(
" recorded provides:{owner_repo} + {} link(s) in {}",
occ.len(),
cfg_path.display()
);
if standalone {
for o in occ {
let cpath = o.consumer.join(crate::config::CONFIG_FILE);
let mut ccfg = if cpath.is_file() {
Config::load(&cpath)?
} else {
Config {
version: 1,
defaults: Default::default(),
remotes: cfg.remotes.clone(),
projects: vec![],
provides: vec![],
links: vec![],
}
};
let dep = crate::config::SharedDep {
name: owner_repo.clone(),
path: o.sub_rel.clone(),
revision: branch.map(|s| s.to_string()),
};
if let Some(self_p) = ccfg.projects.iter_mut().find(|p| p.path == ".") {
if !self_p.shared.iter().any(|s| s.path == dep.path) {
self_p.shared.push(dep);
}
} else {
ccfg.projects.push(Project {
name: "self".into(),
path: ".".into(),
revision: None,
remote: None,
submodules: Default::default(),
consumes: vec![],
ci: None,
setup: None,
lint: None,
test: None,
shared: vec![dep],
});
}
std::fs::write(&cpath, serde_yaml::to_string(&ccfg)?)?;
println!(" + standalone consumer config: {}", cpath.display());
}
}
Ok(())
}
pub fn plan(json: bool) -> Result<()> {
let root = std::env::current_dir()?;
if !root.join(".gitmodules").is_file() {
bail!("no .gitmodules here — nothing to scan");
}
let root_id = git_origin_name(&root)
.map(|n| crate::submodule::normalize_url(&n))
.unwrap_or_else(|| "workspace/root".into());
let plan = crate::submodule::scan(&root, &root_id);
let status_of = |rel: &str| -> Option<(String, bool)> {
let d = root.join(rel);
if !crate::git::is_repo(&d) {
return None;
}
let br = crate::git::current_branch(&d).unwrap_or_else(|_| "?".into());
let dirty = crate::git::is_dirty(&d).unwrap_or(false);
Some((br, dirty))
};
let sha_of = |rel: &str| checked_out_or_gitlink_sha(&root, rel);
if json {
let enriched: Vec<_> = plan
.order
.iter()
.map(|n| {
let locs: Vec<_> = n
.paths
.iter()
.map(|p| {
let s = status_of(p);
serde_json::json!({
"path": p,
"branch": s.as_ref().map(|x| x.0.clone()),
"dirty": s.as_ref().map(|x| x.1),
"present": s.is_some(),
})
})
.collect();
serde_json::json!({
"id": n.id, "fan_in": n.fan_in,
"has_submodules": n.has_submodules,
"recommendation": n.recommendation,
"branch_conflicts": n.branch_conflicts,
"locations": locs,
})
})
.collect();
println!("{}", serde_json::to_string_pretty(&enriched)?);
return Ok(());
}
println!(
"{}",
color::dim("conversion plan — convert top-to-bottom (leaves first)")
);
for (i, n) in plan.order.iter().enumerate() {
let num = color::dim(&format!("{:>3}.", i + 1));
let padded = format!("{:<38}", n.id);
let (tag, name) = match n.recommendation {
"lift-shared" => (
color::cyan(&format!("⇧ lift-shared ×{}", n.fan_in)),
color::cyan(&padded),
),
"leaf-convert" => (color::green("leaf"), padded),
"convert" => (color::yellow("convert"), padded),
_ => (color::dim("root"), color::dim(&padded)),
};
println!("{num} {} {}", name, tag);
for p in &n.paths {
match status_of(p) {
Some((br, dirty)) => {
let dot = if dirty {
color::yellow("●")
} else {
color::green("●")
};
println!(" {} {:<48} {}", dot, p, color::dim(&br));
}
None => println!(
" {} {}",
color::dim("○"),
color::dim(&format!("{p} (not checked out)"))
),
}
}
if !n.branch_conflicts.is_empty() {
println!(
" {}",
color::red(&format!(
"⚠ .gitmodules branch= labels differ: {}",
n.branch_conflicts.join(", ")
))
);
}
if n.paths.len() > 1 {
let shas: std::collections::BTreeSet<String> =
n.paths.iter().filter_map(|p| sha_of(p)).collect();
if shas.len() > 1 {
let short: Vec<String> = shas
.iter()
.map(|s| s[..12.min(s.len())].to_string())
.collect();
println!(
" {}",
color::red(&format!(
"⚠ COMMIT DIVERGENCE — copies on {} different commits: {} \
(converge before lifting)",
shas.len(),
short.join(", ")
))
);
}
}
}
println!(
"{}",
color::dim(
" legend: cyan=lift-shared(≥2 parents→1 copy) green=leaf \
yellow=convert ● clean ● dirty red=conflict"
)
);
println!(
"{}",
color::dim(" `rv adopt --step <repo>` lifts one (dry-run unless --yes)")
);
Ok(())
}
fn checked_out_or_gitlink_sha(root: &Path, rel: &str) -> Option<String> {
let full = root.join(rel);
if crate::git::is_repo(&full) {
return crate::git::head_sha(&full).ok();
}
let mut cur = full.parent();
while let Some(repo) = cur {
if repo.join(".git").exists() {
let sub_rel = full
.strip_prefix(repo)
.ok()
.map(|p| p.to_string_lossy().replace('\\', "/"))?;
return crate::git::gitlink_sha(repo, &sub_rel);
}
if repo == root {
break;
}
cur = repo.parent();
}
None
}
fn parse_gitmodules(text: &str) -> Vec<(String, String)> {
let mut out = Vec::new();
let mut path = None;
let mut url = None;
for line in text.lines() {
let l = line.trim();
if l.starts_with("[submodule") {
path = None;
url = None;
} else if let Some(v) = l.strip_prefix("path = ") {
path = Some(v.trim().to_string());
} else if let Some(v) = l.strip_prefix("url = ") {
url = Some(v.trim().to_string());
}
if let (Some(p), Some(u)) = (&path, &url) {
out.push((p.clone(), u.clone()));
path = None;
url = None;
}
}
out
}
fn name_from_url(url: &str) -> String {
let s = url
.trim_end_matches(".git")
.trim_start_matches("https://")
.trim_start_matches("ssh://")
.trim_start_matches("git@");
let s = s.replacen(':', "/", 1);
let parts: Vec<&str> = s.split('/').collect();
if parts.len() >= 3 {
format!("{}/{}", parts[parts.len() - 2], parts[parts.len() - 1])
} else {
s.to_string()
}
}
pub fn run(_yes: bool, dry_run: bool) -> Result<()> {
let root = std::env::current_dir()?;
let gm = root.join(".gitmodules");
if !gm.is_file() {
bail!("no .gitmodules here — nothing to adopt");
}
let subs = parse_gitmodules(&std::fs::read_to_string(&gm)?);
let mut by_name: BTreeMap<String, (String, String)> = BTreeMap::new();
let mut projects = Vec::new();
for (path, url) in &subs {
let name = name_from_url(url);
match by_name.get(&name) {
Some((_, u)) if u == url => {
println!(" dedupe {name} ({path} collapses to existing)");
continue;
}
_ => {}
}
by_name.insert(name.clone(), (path.clone(), url.clone()));
projects.push(Project {
name,
path: path.clone(),
revision: None,
remote: None,
submodules: Default::default(),
consumes: vec![],
ci: None,
setup: None,
lint: None,
test: None,
shared: vec![],
});
}
let consumes: Vec<String> = projects.iter().map(|p| p.path.clone()).collect();
let root_name = git_origin_name(&root).unwrap_or_else(|| "root/repo".into());
projects.push(Project {
name: root_name,
path: ".".into(),
revision: None,
remote: None,
submodules: Default::default(),
consumes,
ci: None,
setup: None,
lint: None,
test: None,
shared: vec![],
});
let mut remotes = BTreeMap::new();
remotes.insert(
"github".into(),
Remote {
host: "github.com".into(),
},
);
let cfg = Config {
version: 1,
defaults: Defaults {
remote: "github".into(),
revision: "main".into(),
scheme: Scheme::Ssh,
},
remotes,
projects,
provides: vec![],
links: vec![],
};
cfg.validate()?;
let yaml = serde_yaml::to_string(&cfg)?;
if dry_run {
println!("--- {} (dry-run) ---\n{yaml}", crate::config::CONFIG_FILE);
} else {
std::fs::write(crate::config::CONFIG_FILE, &yaml)?;
println!(
"wrote {} ({} projects). Review, then `rv init`/`rv pin`.",
crate::config::CONFIG_FILE,
cfg.projects.len()
);
}
Ok(())
}
fn git_origin_name(root: &Path) -> Option<String> {
let o = crate::git::run(root, &["remote", "get-url", "origin"]).ok()?;
if o.ok() {
Some(name_from_url(o.stdout.trim()))
} else {
None
}
}
}
pub mod plan {
use super::*;
use crate::git;
use crate::graph::Graph;
fn node_status(ws: &Workspace, path: &str) -> String {
let Some(p) = ws.config.projects.iter().find(|p| p.path == path) else {
return "unknown".into();
};
let dir = ws.project_dir(p);
if !git::is_repo(&dir) {
return "missing".into();
}
if git::is_dirty(&dir).unwrap_or(false) {
return "dirty".into();
}
match git::ahead_behind(&dir).ok().flatten() {
Some((a, _)) if a > 0 => "ahead".into(),
_ => "clean".into(),
}
}
pub fn run(json: bool) -> Result<()> {
let ws = Workspace::discover()?;
let g = Graph::from_config(&ws.config);
let order = g.topo_order()?;
let nodes: Vec<_> = order
.iter()
.map(|p| serde_json::json!({"path": p, "status": node_status(&ws, p)}))
.collect();
if json {
println!("{}", serde_json::to_string_pretty(&nodes)?);
} else {
println!("rollup order (dependencies first):");
for n in &nodes {
println!(
" {:<24} {}",
n["path"].as_str().unwrap(),
n["status"].as_str().unwrap()
);
}
}
Ok(())
}
pub fn next(json: bool) -> Result<()> {
let ws = Workspace::discover()?;
let g = Graph::from_config(&ws.config);
let order = g.topo_order()?;
for path in &order {
let st = node_status(&ws, path);
if st == "dirty" || st == "ahead" {
let blocked: Vec<_> = g
.deps
.get(path)
.into_iter()
.flatten()
.filter(|d| {
let s = node_status(&ws, d);
s == "dirty" || s == "ahead"
})
.cloned()
.collect();
let out = serde_json::json!({
"next": {
"repo": path,
"status": st,
"blocked_by": blocked,
"commands": [format!("rv commit -m \"...\" {path}"),
format!("rv rollup --step {path}")],
}
});
if json {
println!("{}", serde_json::to_string_pretty(&out)?);
} else {
println!("next: {path} ({st}); then `rv rollup --step {path}`");
}
return Ok(());
}
}
if json {
println!(
"{}",
serde_json::to_string_pretty(&serde_json::json!({"next": null, "done": true}))?
);
} else {
println!("nothing to roll up — workspace clean");
}
Ok(())
}
}
pub mod rollup {
use super::*;
use crate::git;
use crate::graph::Graph;
pub fn run(
dry_run: bool,
direct: bool,
step: bool,
from: Option<&str>,
slug: Option<&str>,
) -> Result<()> {
let ws = Workspace::discover()?;
let g = Graph::from_config(&ws.config);
let mut order = g.topo_order()?;
if let Some(f) = from {
if let Some(i) = order.iter().position(|p| p == f) {
order = order[i..].to_vec();
}
}
let slug = slug
.map(|s| s.to_string())
.unwrap_or_else(|| default_slug(&ws));
let changed: Vec<String> = order
.iter()
.filter(|p| {
let pr = ws.config.projects.iter().find(|x| &&x.path == p);
pr.map(|pr| {
let d = ws.project_dir(pr);
git::is_repo(&d)
&& (git::is_dirty(&d).unwrap_or(false)
|| matches!(git::ahead_behind(&d).ok().flatten(), Some((a, _)) if a > 0))
})
.unwrap_or(false)
})
.cloned()
.collect();
if dry_run {
println!("rollup plan (slug rv/{slug}):");
for p in &changed {
println!(" - {p}");
}
if changed.is_empty() {
println!(" (nothing changed)");
}
return Ok(());
}
if !direct {
if !crate::gh::available() {
bail!("PR-mode rollup needs `gh`; install it or use `rv rollup --direct`");
}
bail!(
"PR-mode rollup (gh auto-merge cascade) not yet implemented in \
this build — use `rv rollup --direct` for the local fast-path"
);
}
for path in &changed {
let p = ws.config.projects.iter().find(|x| &x.path == path).unwrap();
let dir = ws.project_dir(p);
println!("→ {path}");
git::checkout_new_branch(&dir, &format!("rv/{slug}"))?;
for k in [
crate::tasks::Kind::Setup,
crate::tasks::Kind::Lint,
crate::tasks::Kind::Test,
] {
if let Some(r) = crate::tasks::resolve(p, &dir, k)? {
let ok = std::process::Command::new("bash")
.args(["-eo", "pipefail", "-c", &r.script])
.current_dir(&dir)
.status()?
.success();
if !ok {
bail!("{} {} failed; stopping rollup", path, k.as_str());
}
}
}
if git::is_dirty(&dir)? {
git::check(&dir, &["add", "-A"])?;
git::check(&dir, &["commit", "-m", &format!("rollup: {slug}")])?;
}
git::check(&dir, &["push", "-u", "origin", &format!("rv/{slug}")])?;
let sha = git::head_sha(&dir)?;
for consumer in g.consumers_of(path) {
bump_consumer(&ws, &consumer, path, &sha)?;
}
if step {
println!("(--step: stopped after {path})");
break;
}
}
println!("rollup complete (--direct)");
Ok(())
}
fn default_slug(ws: &Workspace) -> String {
for p in &ws.config.projects {
let d = ws.project_dir(p);
if git::is_repo(&d) {
if let Ok(b) = git::current_branch(&d) {
if b != "main" && b != "master" && !b.starts_with("rv/") {
return b;
}
}
}
}
format!(
"rollup-{:x}",
std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap()
.as_secs()
)
}
fn bump_consumer(ws: &Workspace, consumer: &str, dep: &str, sha: &str) -> Result<()> {
let cp = ws
.config
.projects
.iter()
.find(|x| x.path == consumer)
.unwrap();
let cdir = ws.project_dir(cp);
let sub = cdir.join(dep);
if cdir.join(".gitmodules").is_file()
&& (sub.join(".git").exists() || git::gitlink_sha(&cdir, dep).is_some())
{
git::set_gitlink(&cdir, dep, sha)?;
println!(" bumped gitlink {consumer}:{dep} -> {}", &sha[..12]);
} else {
let mut lock = ws.lock()?;
if let Some(e) = lock.projects.iter_mut().find(|e| e.path == dep) {
e.sha = sha.to_string();
} else {
lock.projects.push(crate::lock::LockedProject {
path: dep.to_string(),
name: ws
.config
.projects
.iter()
.find(|x| x.path == dep)
.map(|x| x.name.clone())
.unwrap_or_default(),
revision: "main".into(),
sha: sha.to_string(),
});
}
lock.save(&ws.lock_path)?;
println!(" bumped lock {dep} -> {}", &sha[..12]);
}
Ok(())
}
}
pub mod pr {
use super::*;
use crate::git;
use crate::graph::Graph;
use std::process::Command;
#[derive(Debug, Clone)]
struct PrPlan {
path: String,
repo: String,
base: String,
head: String,
is_root: bool,
}
pub fn run(dry_run: bool, json: bool) -> Result<()> {
let ws = Workspace::discover()?;
let plan = build_plan(&ws, !json)?;
if dry_run || json {
print_plan(&plan, json)?;
return Ok(());
}
if plan.is_empty() {
println!("no PRs to open");
return Ok(());
}
open_prs(&plan)
}
fn build_plan(ws: &Workspace, announce_skips: bool) -> Result<Vec<PrPlan>> {
let g = Graph::from_config(&ws.config);
let order = g.topo_order()?; let mut plan = Vec::new();
for path in &order {
let p = ws.config.projects.iter().find(|x| &x.path == path).unwrap();
let dir = ws.project_dir(p);
if !git::is_repo(&dir) {
continue;
}
let head = git::current_branch(&dir)?;
let base = ws.config.project_revision(p).to_string();
if head == base {
if announce_skips {
println!(" skip {path}: current branch is configured revision `{base}`");
}
continue;
}
if head.starts_with("rv/") {
if announce_skips {
println!(" skip {path}: `{head}` is a generated rollup branch");
}
continue;
}
let origin_url = git::check(&dir, &["remote", "get-url", "origin"])?;
let repo = repo_name_from_url(origin_url.trim());
plan.push(PrPlan {
path: path.clone(),
repo,
base,
head,
is_root: path == ".",
});
}
Ok(plan)
}
fn print_plan(plan: &[PrPlan], json: bool) -> Result<()> {
if json {
let items: Vec<_> = plan
.iter()
.map(|p| {
serde_json::json!({
"path": p.path,
"repo": p.repo,
"base": p.base,
"head": p.head,
"remote": "origin",
})
})
.collect();
println!("{}", serde_json::to_string_pretty(&items)?);
} else {
println!("PR plan:");
for p in plan {
println!(" {}: {} {}...{}", p.path, p.repo, p.base, p.head);
}
if plan.is_empty() {
println!(" (nothing to open)");
}
}
Ok(())
}
fn open_prs(plan: &[PrPlan]) -> Result<()> {
if !crate::gh::available() {
eprintln!("gh not found — push your branches and open PRs manually:");
for p in plan {
println!(
" https://github.com/{}/compare/{}...{}?expand=1 ({})",
p.repo, p.base, p.head, p.path
);
}
return Ok(());
}
let mut links: Vec<String> = Vec::new();
for p in plan {
let mut body = String::from("Opened by `rv pr`.");
if p.is_root && !links.is_empty() {
body.push_str("\n\nChild PRs:\n");
for l in &links {
body.push_str(&format!("- {l}\n"));
}
}
let out = Command::new("gh")
.args([
"pr", "create", "--repo", &p.repo, "--base", &p.base, "--head", &p.head,
"--fill", "--body", &body,
])
.output()?;
if out.status.success() {
let url = String::from_utf8_lossy(&out.stdout).trim().to_string();
println!(" PR {}: {url}", p.path);
if !p.is_root {
links.push(url);
}
} else {
eprintln!(
" FAIL pr {}: {}",
p.path,
String::from_utf8_lossy(&out.stderr).trim()
);
}
}
Ok(())
}
fn repo_name_from_url(url: &str) -> String {
let s = url
.trim()
.trim_end_matches(".git")
.trim_start_matches("https://")
.trim_start_matches("ssh://")
.trim_start_matches("git@");
let s = s.replacen(':', "/", 1);
let parts: Vec<&str> = s.split('/').collect();
if parts.len() >= 3 {
format!("{}/{}", parts[parts.len() - 2], parts[parts.len() - 1])
} else {
s.to_string()
}
}
#[cfg(test)]
mod tests {
use super::repo_name_from_url;
#[test]
fn parses_origin_repo_names() {
assert_eq!(
repo_name_from_url("git@github.com:acme/lib.git"),
"acme/lib"
);
assert_eq!(
repo_name_from_url("https://github.com/acme/app.git"),
"acme/app"
);
assert_eq!(
repo_name_from_url("ssh://git@github.com/acme/root.git"),
"acme/root"
);
}
}
}
pub mod release {
use super::*;
use crate::git;
pub fn run(tag: Option<&str>) -> Result<()> {
super::pin::run()?;
let ws = Workspace::discover()?;
if ws.config.projects.iter().any(|p| p.path == ".") {
let root = &ws.root;
git::check(root, &["add", crate::lock::LOCK_FILE])?;
if !git::run(root, &["diff", "--cached", "--quiet"])?.ok() {
git::check(root, &["commit", "-m", "release: pin lock"])?;
println!("committed lock");
}
if let Some(t) = tag {
git::check(root, &["tag", t])?;
println!("tagged {t}");
}
}
Ok(())
}
}