use std::fs;
use std::path::{Path, PathBuf};
use std::process::{Command, ExitCode};
use std::time::{SystemTime, UNIX_EPOCH};
use anyhow::{bail, Context, Result};
use serde::Serialize;
use sha2::{Digest, Sha256};
use crate::output::CommandReport;
use crate::paths::state::StateLayout;
use crate::profile::{self, ProfileName};
use crate::repo::marker::{self as repo_marker, RepoMarker};
use crate::repo::registry::{self as repo_registry, RepoAliases, RepoRegistryEntry};
#[derive(Serialize)]
pub struct LinkReport {
command: &'static str,
ok: bool,
profile: String,
locality_id: String,
action: &'static str,
resolution: &'static str,
marker_path: String,
registry_path: String,
overlay_path: String,
warnings: Vec<String>,
}
impl CommandReport for LinkReport {
fn exit_code(&self) -> ExitCode {
ExitCode::SUCCESS
}
fn render_text(&self) {
println!(
"Linked profile {} to project ID {} ({}, {}).",
self.profile, self.locality_id, self.action, self.resolution
);
println!("Marker: {}", self.marker_path);
println!("Registry: {}", self.registry_path);
println!("Project overlay: {}", self.overlay_path);
for warning in &self.warnings {
println!("Warning: {warning}");
}
}
}
pub fn run(
repo_root: &Path,
explicit_profile: Option<&str>,
explicit_locality_id: Option<&str>,
display_name: Option<&str>,
) -> Result<LinkReport> {
let request = LinkRequest {
explicit_profile,
explicit_locality_id,
display_name,
};
let result = link_repo(repo_root, request)?;
Ok(LinkReport {
command: "link",
ok: true,
profile: result.profile.to_string(),
locality_id: result.locality_id,
action: result.action,
resolution: result.resolution,
marker_path: result.marker_path.display().to_string(),
registry_path: result.registry_path.display().to_string(),
overlay_path: result.overlay_path.display().to_string(),
warnings: result.warnings,
})
}
pub(crate) struct LinkRequest<'a> {
pub(crate) explicit_profile: Option<&'a str>,
pub(crate) explicit_locality_id: Option<&'a str>,
pub(crate) display_name: Option<&'a str>,
}
pub(crate) struct LinkResult {
pub(crate) profile: ProfileName,
pub(crate) locality_id: String,
pub(crate) action: &'static str,
pub(crate) resolution: &'static str,
pub(crate) marker_path: PathBuf,
pub(crate) registry_path: PathBuf,
pub(crate) overlay_path: PathBuf,
pub(crate) warnings: Vec<String>,
}
pub(crate) struct PreparedLink {
pub(crate) layout: StateLayout,
pub(crate) profile: ProfileName,
repo_root: PathBuf,
marker: RepoMarker,
registry_entry: RepoRegistryEntry,
locality_id: String,
previous_locality_id: Option<String>,
current_clone_root: String,
action: &'static str,
resolution: &'static str,
warnings: Vec<String>,
}
pub(crate) fn prepare_link(repo_root: &Path, request: LinkRequest<'_>) -> Result<PreparedLink> {
let profile = profile::resolve(request.explicit_profile)?;
let layout = StateLayout::resolve(repo_root, profile.clone())?;
let aliases = collect_aliases(repo_root)?;
let existing_marker = repo_marker::load(repo_root)?;
let resolution = resolve_locality_id(
repo_root,
&layout,
request.explicit_locality_id,
existing_marker.as_ref(),
&aliases,
)?;
let chosen_display_name = choose_display_name(
request.display_name,
existing_marker
.as_ref()
.and_then(|marker| marker.display_name.as_deref()),
repo_root.file_name().and_then(|name| name.to_str()),
);
let locality_id = resolution.locality_id.clone();
let current_clone_root = repo_registry::normalize_clone_root(repo_root)?;
let marker = match existing_marker.as_ref() {
Some(existing_marker) => existing_marker.rewrite_with_locality_id(
locality_id.clone(),
chosen_display_name.as_ref().map(|value| value.to_owned()),
)?,
None => RepoMarker::new(
locality_id.clone(),
chosen_display_name.as_ref().map(|value| value.to_owned()),
)?,
};
let registry_path = layout.repo_metadata_path(&locality_id)?;
let existing_entry = repo_registry::load(®istry_path)?;
let mut known_clone_roots = existing_entry
.as_ref()
.map(|entry| entry.known_clone_roots.clone())
.unwrap_or_default();
if !known_clone_roots
.iter()
.any(|known| known == ¤t_clone_root)
{
known_clone_roots.push(current_clone_root.clone());
known_clone_roots.sort();
}
let registry_entry = RepoRegistryEntry::new_with_known_clone_roots(
choose_display_name(
request.display_name,
existing_entry
.as_ref()
.and_then(|entry| entry.display_name.as_deref())
.or(chosen_display_name.as_deref()),
repo_root.file_name().and_then(|name| name.to_str()),
)
.map(|value| value.to_owned()),
aliases,
known_clone_roots,
)?;
Ok(PreparedLink {
layout,
profile,
repo_root: repo_root.to_path_buf(),
marker,
registry_entry,
locality_id,
previous_locality_id: existing_marker
.as_ref()
.map(|marker| marker.locality_id.clone())
.filter(|existing_locality_id| existing_locality_id != &resolution.locality_id),
current_clone_root,
action: resolution.action,
resolution: resolution.resolution,
warnings: resolution.warnings,
})
}
pub(crate) fn finalize_link(prepared: PreparedLink) -> Result<LinkResult> {
let PreparedLink {
layout,
profile,
repo_root,
marker,
registry_entry,
locality_id,
previous_locality_id,
current_clone_root,
action,
resolution,
mut warnings,
} = prepared;
let marker_path = repo_marker::write(&repo_root, &marker)?;
let registry_path = layout.repo_metadata_path(&locality_id)?;
let registry_path = repo_registry::write(®istry_path, ®istry_entry)?;
if let Some(previous_locality_id) = previous_locality_id.as_deref() {
if let Err(error) =
remove_known_clone_root(&layout, previous_locality_id, ¤t_clone_root)
{
warnings.push(format!(
"failed to prune clone root {current_clone_root} from previous locality_id `{previous_locality_id}`: {error:#}",
));
}
}
let overlay_path = layout.repo_overlay_root(&locality_id)?;
fs::create_dir_all(&overlay_path)
.with_context(|| format!("failed to create directory {}", overlay_path.display()))?;
Ok(LinkResult {
profile,
locality_id,
action,
resolution,
marker_path,
registry_path,
overlay_path,
warnings,
})
}
pub(crate) fn link_repo(repo_root: &Path, request: LinkRequest<'_>) -> Result<LinkResult> {
let prepared = prepare_link(repo_root, request)?;
ensure_profile_exists(&prepared.layout)?;
finalize_link(prepared)
}
struct RepoIdResolution {
locality_id: String,
action: &'static str,
resolution: &'static str,
warnings: Vec<String>,
}
fn resolve_locality_id(
repo_root: &Path,
layout: &StateLayout,
explicit_locality_id: Option<&str>,
existing_marker: Option<&RepoMarker>,
aliases: &RepoAliases,
) -> Result<RepoIdResolution> {
let alias_matches = find_alias_matches(layout, aliases)?;
if let Some(locality_id) = explicit_locality_id {
let mut warnings = Vec::new();
if let Some(marker) = existing_marker {
if marker.locality_id != locality_id {
warnings.push(format!(
"relinked clone from locality_id `{}` to explicit locality_id `{locality_id}`",
marker.locality_id
));
}
}
return Ok(RepoIdResolution {
locality_id: locality_id.to_owned(),
action: "attached",
resolution: "explicit_locality_id",
warnings,
});
}
if let Some(marker) = existing_marker {
let mut warnings = Vec::new();
let conflicting_matches: Vec<String> = alias_matches
.iter()
.filter(|candidate| candidate.locality_id != marker.locality_id)
.map(|candidate| candidate.locality_id.clone())
.collect();
if !conflicting_matches.is_empty() {
warnings.push(format!(
"local marker locality_id `{}` wins over alias matches: {}",
marker.locality_id,
conflicting_matches.join(", ")
));
}
return Ok(RepoIdResolution {
locality_id: marker.locality_id.clone(),
action: "refreshed",
resolution: "local_marker",
warnings,
});
}
match alias_matches.as_slice() {
[] => Ok(RepoIdResolution {
locality_id: generate_locality_id(repo_root, layout)?,
action: "created",
resolution: "new_assignment",
warnings: Vec::new(),
}),
[single] => Ok(RepoIdResolution {
locality_id: single.locality_id.clone(),
action: "attached",
resolution: "alias_match",
warnings: Vec::new(),
}),
many => bail!(
"ambiguous repo link: multiple existing locality IDs match this clone's aliases: {}. Re-run with --project-id to attach explicitly",
many.iter()
.map(|candidate| candidate.locality_id.as_str())
.collect::<Vec<_>>()
.join(", ")
),
}
}
pub(crate) fn choose_display_name(
explicit: Option<&str>,
existing: Option<&str>,
fallback: Option<&str>,
) -> Option<String> {
explicit
.or(existing)
.or(fallback)
.map(str::trim)
.filter(|value| !value.is_empty())
.map(ToOwned::to_owned)
}
fn ensure_profile_exists(layout: &StateLayout) -> Result<()> {
let profile_root = layout.profile_root();
if profile_root.is_dir() {
return Ok(());
}
bail!(
"profile `{}` does not exist at {}; bootstrap it before linking repos",
layout.profile(),
profile_root.display()
)
}
#[derive(Clone)]
struct AliasMatch {
locality_id: String,
}
fn find_alias_matches(layout: &StateLayout, aliases: &RepoAliases) -> Result<Vec<AliasMatch>> {
let registry_root = layout.repo_registry_root();
if !registry_root.is_dir() {
return Ok(Vec::new());
}
let mut matches = Vec::new();
for entry in fs::read_dir(®istry_root)
.with_context(|| format!("failed to read directory {}", registry_root.display()))?
{
let entry = entry?;
if !entry.file_type()?.is_dir() {
continue;
}
let locality_id = entry.file_name().to_string_lossy().to_string();
let metadata_path = entry.path().join(repo_registry::REPO_METADATA_FILE);
let Some(candidate) = repo_registry::load(&metadata_path)? else {
continue;
};
if is_strong_match(aliases, &candidate.aliases) {
matches.push(AliasMatch { locality_id });
}
}
matches.sort_by(|left, right| left.locality_id.cmp(&right.locality_id));
Ok(matches)
}
fn is_strong_match(current: &RepoAliases, candidate: &RepoAliases) -> bool {
has_overlap(¤t.remote_urls, &candidate.remote_urls)
|| has_overlap(¤t.root_commits, &candidate.root_commits)
}
fn has_overlap(left: &[String], right: &[String]) -> bool {
let (smaller, larger) = if left.len() <= right.len() {
(left, right)
} else {
(right, left)
};
smaller
.iter()
.any(|value| larger.iter().any(|candidate| candidate == value))
}
pub(crate) fn find_alias_match_locality_ids(
layout: &StateLayout,
aliases: &RepoAliases,
) -> Result<Vec<String>> {
find_alias_matches(layout, aliases).map(|matches| {
matches
.into_iter()
.map(|candidate| candidate.locality_id)
.collect()
})
}
pub(crate) fn collect_aliases(repo_root: &Path) -> Result<RepoAliases> {
let remote_urls = collect_remote_urls(repo_root);
let root_commits = collect_root_commits(repo_root);
let repo_basenames = repo_root
.file_name()
.and_then(|name| name.to_str())
.map(|name| vec![name.to_owned()])
.unwrap_or_default();
Ok(RepoAliases {
remote_urls,
root_commits,
repo_basenames,
})
}
pub(crate) fn remove_known_clone_root(
layout: &StateLayout,
locality_id: &str,
clone_root: &str,
) -> Result<()> {
let registry_path = layout.repo_metadata_path(locality_id)?;
let Some(mut entry) = repo_registry::load(®istry_path)? else {
return Ok(());
};
if entry.remove_known_clone_root(clone_root) {
repo_registry::write(®istry_path, &entry)?;
}
Ok(())
}
fn collect_remote_urls(repo_root: &Path) -> Vec<String> {
let Ok(output) = git_output(repo_root, &["remote"]) else {
return Vec::new();
};
dedup(
output
.lines()
.map(str::trim)
.filter(|remote| !remote.is_empty())
.filter_map(|remote| git_output(repo_root, &["remote", "get-url", remote]).ok())
.map(|url| url.trim().to_owned())
.filter(|url| !url.is_empty())
.collect(),
)
}
fn collect_root_commits(repo_root: &Path) -> Vec<String> {
let Ok(output) = git_output(repo_root, &["rev-list", "--max-parents=0", "HEAD"]) else {
return Vec::new();
};
dedup(
output
.lines()
.map(str::trim)
.filter(|value| !value.is_empty())
.map(ToOwned::to_owned)
.collect(),
)
}
fn dedup(values: Vec<String>) -> Vec<String> {
let mut seen = std::collections::BTreeSet::new();
let mut deduped = Vec::new();
for value in values {
if seen.insert(value.clone()) {
deduped.push(value);
}
}
deduped
}
fn git_output(repo_root: &Path, args: &[&str]) -> Result<String> {
let output = Command::new("git")
.args(args)
.current_dir(repo_root)
.output()
.with_context(|| format!("failed to run `git {}`", args.join(" ")))?;
if !output.status.success() {
bail!(
"`git {}` failed: {}",
args.join(" "),
String::from_utf8_lossy(&output.stderr).trim()
);
}
Ok(String::from_utf8(output.stdout)?.trim().to_owned())
}
pub(crate) fn generate_locality_id(repo_root: &Path, layout: &StateLayout) -> Result<String> {
for attempt in 0u32..1024 {
let now = SystemTime::now()
.duration_since(UNIX_EPOCH)
.context("system clock is before UNIX_EPOCH")?
.as_nanos();
let seed = format!(
"{}:{}:{}:{}",
repo_root.display(),
layout.profile(),
std::process::id(),
now + u128::from(attempt)
);
let digest = Sha256::digest(seed.as_bytes());
let locality_id = format!("ccdrepo_{digest:x}");
let locality_id = locality_id[..32].to_owned();
if !layout.repo_registry_dir(&locality_id)?.exists() {
return Ok(locality_id);
}
}
bail!("failed to generate a unique locality_id after 1024 attempts")
}
#[cfg(test)]
mod tests {
use tempfile::tempdir;
use super::*;
#[test]
fn basename_only_matches_are_not_strong() {
let current = RepoAliases {
remote_urls: Vec::new(),
root_commits: Vec::new(),
repo_basenames: vec!["ccd-guide".to_owned()],
};
let candidate = RepoAliases {
remote_urls: Vec::new(),
root_commits: Vec::new(),
repo_basenames: vec!["ccd-guide".to_owned()],
};
assert!(!is_strong_match(¤t, &candidate));
}
#[test]
fn shared_root_commit_is_a_strong_match() {
let current = RepoAliases {
remote_urls: Vec::new(),
root_commits: vec!["abc123".to_owned()],
repo_basenames: vec!["ccd-guide".to_owned()],
};
let candidate = RepoAliases {
remote_urls: Vec::new(),
root_commits: vec!["def456".to_owned(), "abc123".to_owned()],
repo_basenames: vec!["other".to_owned()],
};
assert!(is_strong_match(¤t, &candidate));
}
#[test]
fn generate_project_id_creates_prefixed_identifier() {
let temp = tempdir().expect("tempdir");
let layout = StateLayout::new(
temp.path().join(".ccd"),
temp.path().join(".git/ccd"),
ProfileName::new("main").expect("profile"),
);
let locality_id = generate_locality_id(temp.path(), &layout).expect("project id");
assert!(locality_id.starts_with("ccdrepo_"));
assert_eq!(locality_id.len(), 32);
}
}