use crate::cli::output::Output;
use crate::core::manifest::Manifest;
use crate::core::repo::RepoInfo;
use crate::git::path_exists;
use std::path::PathBuf;
pub fn run_link(
workspace_root: &PathBuf,
manifest: &Manifest,
status: bool,
apply: bool,
) -> anyhow::Result<()> {
if status {
show_link_status(workspace_root, manifest)?;
} else if apply {
apply_links(workspace_root, manifest)?;
} else {
show_link_status(workspace_root, manifest)?;
}
Ok(())
}
fn show_link_status(workspace_root: &PathBuf, manifest: &Manifest) -> anyhow::Result<()> {
Output::header("File Link Status");
println!();
let repos: Vec<RepoInfo> = manifest
.repos
.iter()
.filter_map(|(name, config)| RepoInfo::from_config(name, config, workspace_root))
.collect();
let mut total_links = 0;
let mut valid_links = 0;
let mut broken_links = 0;
for (name, config) in &manifest.repos {
let repo = repos.iter().find(|r| &r.name == name);
if let Some(ref copyfiles) = config.copyfile {
for copyfile in copyfiles {
total_links += 1;
let source = repo
.map(|r| r.absolute_path.join(©file.src))
.unwrap_or_else(|| workspace_root.join(&config.path).join(©file.src));
let dest = workspace_root.join(©file.dest);
let status = if source.exists() && dest.exists() {
valid_links += 1;
"✓"
} else if !source.exists() {
broken_links += 1;
"✗ (source missing)"
} else {
broken_links += 1;
"✗ (dest missing)"
};
println!(" [copy] {} -> {} {}", copyfile.src, copyfile.dest, status);
}
}
if let Some(ref linkfiles) = config.linkfile {
for linkfile in linkfiles {
total_links += 1;
let source = repo
.map(|r| r.absolute_path.join(&linkfile.src))
.unwrap_or_else(|| workspace_root.join(&config.path).join(&linkfile.src));
let dest = workspace_root.join(&linkfile.dest);
let status = if source.exists() && dest.exists() && dest.is_symlink() {
valid_links += 1;
"✓"
} else if !source.exists() {
broken_links += 1;
"✗ (source missing)"
} else if !dest.exists() {
broken_links += 1;
"✗ (link missing)"
} else {
broken_links += 1;
"✗ (not a symlink)"
};
println!(" [link] {} -> {} {}", linkfile.src, linkfile.dest, status);
}
}
}
if let Some(ref manifest_config) = manifest.manifest {
let manifests_dir = workspace_root.join(".gitgrip").join("manifests");
if let Some(ref copyfiles) = manifest_config.copyfile {
for copyfile in copyfiles {
total_links += 1;
let source = manifests_dir.join(©file.src);
let dest = workspace_root.join(©file.dest);
let status = if source.exists() && dest.exists() {
valid_links += 1;
"✓"
} else if !source.exists() {
broken_links += 1;
"✗ (source missing)"
} else {
broken_links += 1;
"✗ (dest missing)"
};
println!(
" [copy] manifest:{} -> {} {}",
copyfile.src, copyfile.dest, status
);
}
}
if let Some(ref linkfiles) = manifest_config.linkfile {
for linkfile in linkfiles {
total_links += 1;
let source = manifests_dir.join(&linkfile.src);
let dest = workspace_root.join(&linkfile.dest);
let status = if source.exists() && dest.exists() && dest.is_symlink() {
valid_links += 1;
"✓"
} else if !source.exists() {
broken_links += 1;
"✗ (source missing)"
} else if !dest.exists() {
broken_links += 1;
"✗ (link missing)"
} else {
broken_links += 1;
"✗ (not a symlink)"
};
println!(
" [link] manifest:{} -> {} {}",
linkfile.src, linkfile.dest, status
);
}
}
}
println!();
if total_links == 0 {
println!("No file links defined in manifest.");
} else if broken_links == 0 {
Output::success(&format!("All {} link(s) valid", valid_links));
} else {
Output::warning(&format!(
"{} valid, {} broken out of {} total",
valid_links, broken_links, total_links
));
println!();
println!("Run 'gr link --apply' to fix broken links.");
}
Ok(())
}
fn apply_links(workspace_root: &PathBuf, manifest: &Manifest) -> anyhow::Result<()> {
Output::header("Applying File Links");
println!();
let repos: Vec<RepoInfo> = manifest
.repos
.iter()
.filter_map(|(name, config)| RepoInfo::from_config(name, config, workspace_root))
.collect();
let mut applied = 0;
let mut errors = 0;
for (name, config) in &manifest.repos {
let repo = repos.iter().find(|r| &r.name == name);
if !repo.map(|r| path_exists(&r.absolute_path)).unwrap_or(false) {
continue;
}
if let Some(ref copyfiles) = config.copyfile {
for copyfile in copyfiles {
let source = repo
.map(|r| r.absolute_path.join(©file.src))
.unwrap_or_else(|| workspace_root.join(&config.path).join(©file.src));
let dest = workspace_root.join(©file.dest);
if !source.exists() {
Output::warning(&format!("Source not found: {:?}", source));
errors += 1;
continue;
}
if let Some(parent) = dest.parent() {
std::fs::create_dir_all(parent)?;
}
match std::fs::copy(&source, &dest) {
Ok(_) => {
Output::success(&format!("[copy] {} -> {}", copyfile.src, copyfile.dest));
applied += 1;
}
Err(e) => {
Output::error(&format!("Failed to copy: {}", e));
errors += 1;
}
}
}
}
if let Some(ref linkfiles) = config.linkfile {
for linkfile in linkfiles {
let source = repo
.map(|r| r.absolute_path.join(&linkfile.src))
.unwrap_or_else(|| workspace_root.join(&config.path).join(&linkfile.src));
let dest = workspace_root.join(&linkfile.dest);
if !source.exists() {
Output::warning(&format!("Source not found: {:?}", source));
errors += 1;
continue;
}
if let Some(parent) = dest.parent() {
std::fs::create_dir_all(parent)?;
}
if dest.exists() || dest.is_symlink() {
let _ = std::fs::remove_file(&dest);
}
#[cfg(unix)]
{
match std::os::unix::fs::symlink(&source, &dest) {
Ok(_) => {
Output::success(&format!(
"[link] {} -> {}",
linkfile.src, linkfile.dest
));
applied += 1;
}
Err(e) => {
Output::error(&format!("Failed to create symlink: {}", e));
errors += 1;
}
}
}
#[cfg(windows)]
{
if source.is_dir() {
match std::os::windows::fs::symlink_dir(&source, &dest) {
Ok(_) => {
Output::success(&format!(
"[link] {} -> {}",
linkfile.src, linkfile.dest
));
applied += 1;
}
Err(e) => {
Output::error(&format!("Failed to create symlink: {}", e));
errors += 1;
}
}
} else {
match std::os::windows::fs::symlink_file(&source, &dest) {
Ok(_) => {
Output::success(&format!(
"[link] {} -> {}",
linkfile.src, linkfile.dest
));
applied += 1;
}
Err(e) => {
Output::error(&format!("Failed to create symlink: {}", e));
errors += 1;
}
}
}
}
}
}
}
if let Some(ref manifest_config) = manifest.manifest {
let manifests_dir = workspace_root.join(".gitgrip").join("manifests");
if manifests_dir.exists() {
if let Some(ref copyfiles) = manifest_config.copyfile {
for copyfile in copyfiles {
let source = manifests_dir.join(©file.src);
let dest = workspace_root.join(©file.dest);
if !source.exists() {
Output::warning(&format!("Source not found: {:?}", source));
errors += 1;
continue;
}
if let Some(parent) = dest.parent() {
std::fs::create_dir_all(parent)?;
}
match std::fs::copy(&source, &dest) {
Ok(_) => {
Output::success(&format!(
"[copy] manifest:{} -> {}",
copyfile.src, copyfile.dest
));
applied += 1;
}
Err(e) => {
Output::error(&format!("Failed to copy: {}", e));
errors += 1;
}
}
}
}
if let Some(ref linkfiles) = manifest_config.linkfile {
for linkfile in linkfiles {
let source = manifests_dir.join(&linkfile.src);
let dest = workspace_root.join(&linkfile.dest);
if !source.exists() {
Output::warning(&format!("Source not found: {:?}", source));
errors += 1;
continue;
}
if let Some(parent) = dest.parent() {
std::fs::create_dir_all(parent)?;
}
if dest.exists() || dest.is_symlink() {
let _ = std::fs::remove_file(&dest);
}
#[cfg(unix)]
{
match std::os::unix::fs::symlink(&source, &dest) {
Ok(_) => {
Output::success(&format!(
"[link] manifest:{} -> {}",
linkfile.src, linkfile.dest
));
applied += 1;
}
Err(e) => {
Output::error(&format!("Failed to create symlink: {}", e));
errors += 1;
}
}
}
#[cfg(windows)]
{
if source.is_dir() {
match std::os::windows::fs::symlink_dir(&source, &dest) {
Ok(_) => {
Output::success(&format!(
"[link] manifest:{} -> {}",
linkfile.src, linkfile.dest
));
applied += 1;
}
Err(e) => {
Output::error(&format!("Failed to create symlink: {}", e));
errors += 1;
}
}
} else {
match std::os::windows::fs::symlink_file(&source, &dest) {
Ok(_) => {
Output::success(&format!(
"[link] manifest:{} -> {}",
linkfile.src, linkfile.dest
));
applied += 1;
}
Err(e) => {
Output::error(&format!("Failed to create symlink: {}", e));
errors += 1;
}
}
}
}
}
}
}
}
println!();
if errors == 0 {
Output::success(&format!("Applied {} link(s)", applied));
} else {
Output::warning(&format!("{} applied, {} errors", applied, errors));
}
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
use crate::core::manifest::{
CopyFileConfig, LinkFileConfig, ManifestRepoConfig, ManifestSettings, MergeStrategy,
RepoConfig,
};
use std::collections::HashMap;
use tempfile::TempDir;
fn create_test_manifest(
copyfiles: Option<Vec<CopyFileConfig>>,
linkfiles: Option<Vec<LinkFileConfig>>,
) -> Manifest {
let mut repos = HashMap::new();
repos.insert(
"test-repo".to_string(),
RepoConfig {
url: "git@github.com:user/test-repo.git".to_string(),
path: "test-repo".to_string(),
default_branch: "main".to_string(),
copyfile: copyfiles,
linkfile: linkfiles,
platform: None,
reference: false,
groups: Vec::new(),
},
);
Manifest {
version: 1,
manifest: None,
repos,
settings: ManifestSettings {
pr_prefix: "[cross-repo]".to_string(),
merge_strategy: MergeStrategy::default(),
},
workspace: None,
}
}
#[test]
fn test_show_link_status_no_links() {
let temp = TempDir::new().unwrap();
let manifest = create_test_manifest(None, None);
let result = show_link_status(&temp.path().to_path_buf(), &manifest);
assert!(result.is_ok());
}
#[test]
fn test_apply_copyfile() {
let temp = TempDir::new().unwrap();
let workspace = temp.path().to_path_buf();
let repo_dir = workspace.join("test-repo");
std::fs::create_dir_all(&repo_dir).unwrap();
std::fs::write(repo_dir.join("README.md"), "# Test").unwrap();
let copyfiles = vec![CopyFileConfig {
src: "README.md".to_string(),
dest: "REPO_README.md".to_string(),
}];
let manifest = create_test_manifest(Some(copyfiles), None);
let result = apply_links(&workspace, &manifest);
assert!(result.is_ok());
let dest_path = workspace.join("REPO_README.md");
assert!(dest_path.exists());
let content = std::fs::read_to_string(&dest_path).unwrap();
assert_eq!(content, "# Test");
}
#[test]
#[cfg(unix)]
fn test_apply_linkfile() {
let temp = TempDir::new().unwrap();
let workspace = temp.path().to_path_buf();
let repo_dir = workspace.join("test-repo");
std::fs::create_dir_all(&repo_dir).unwrap();
std::fs::write(repo_dir.join("config.yaml"), "key: value").unwrap();
let linkfiles = vec![LinkFileConfig {
src: "config.yaml".to_string(),
dest: "linked-config.yaml".to_string(),
}];
let manifest = create_test_manifest(None, Some(linkfiles));
let result = apply_links(&workspace, &manifest);
assert!(result.is_ok());
let dest_path = workspace.join("linked-config.yaml");
assert!(dest_path.exists());
assert!(dest_path.is_symlink());
let content = std::fs::read_to_string(&dest_path).unwrap();
assert_eq!(content, "key: value");
}
#[test]
fn test_apply_links_missing_source() {
let temp = TempDir::new().unwrap();
let workspace = temp.path().to_path_buf();
let repo_dir = workspace.join("test-repo");
std::fs::create_dir_all(&repo_dir).unwrap();
let copyfiles = vec![CopyFileConfig {
src: "nonexistent.txt".to_string(),
dest: "dest.txt".to_string(),
}];
let manifest = create_test_manifest(Some(copyfiles), None);
let result = apply_links(&workspace, &manifest);
assert!(result.is_ok());
assert!(!workspace.join("dest.txt").exists());
}
#[test]
fn test_apply_links_creates_parent_dirs() {
let temp = TempDir::new().unwrap();
let workspace = temp.path().to_path_buf();
let repo_dir = workspace.join("test-repo");
std::fs::create_dir_all(&repo_dir).unwrap();
std::fs::write(repo_dir.join("file.txt"), "content").unwrap();
let copyfiles = vec![CopyFileConfig {
src: "file.txt".to_string(),
dest: "nested/dir/file.txt".to_string(),
}];
let manifest = create_test_manifest(Some(copyfiles), None);
let result = apply_links(&workspace, &manifest);
assert!(result.is_ok());
let dest_path = workspace.join("nested/dir/file.txt");
assert!(dest_path.exists());
}
#[test]
fn test_copyfile_overwrites_existing() {
let temp = TempDir::new().unwrap();
let workspace = temp.path().to_path_buf();
let repo_dir = workspace.join("test-repo");
std::fs::create_dir_all(&repo_dir).unwrap();
std::fs::write(repo_dir.join("config.txt"), "new content").unwrap();
std::fs::write(workspace.join("config.txt"), "old content").unwrap();
let copyfiles = vec![CopyFileConfig {
src: "config.txt".to_string(),
dest: "config.txt".to_string(),
}];
let manifest = create_test_manifest(Some(copyfiles), None);
let result = apply_links(&workspace, &manifest);
assert!(result.is_ok());
let content = std::fs::read_to_string(workspace.join("config.txt")).unwrap();
assert_eq!(content, "new content");
}
#[test]
fn test_manifest_copyfile() {
let temp = TempDir::new().unwrap();
let workspace = temp.path().to_path_buf();
let manifests_dir = workspace.join(".gitgrip").join("manifests");
std::fs::create_dir_all(&manifests_dir).unwrap();
std::fs::write(manifests_dir.join("CLAUDE.md"), "# Claude Guide").unwrap();
let mut repos = std::collections::HashMap::new();
repos.insert(
"test-repo".to_string(),
RepoConfig {
url: "git@github.com:test/repo.git".to_string(),
path: "test-repo".to_string(),
default_branch: "main".to_string(),
copyfile: None,
linkfile: None,
platform: None,
reference: false,
groups: Vec::new(),
},
);
let manifest = Manifest {
version: 1,
manifest: Some(ManifestRepoConfig {
url: "git@github.com:test/manifest.git".to_string(),
default_branch: "main".to_string(),
copyfile: Some(vec![CopyFileConfig {
src: "CLAUDE.md".to_string(),
dest: "CLAUDE.md".to_string(),
}]),
linkfile: None,
platform: None,
}),
repos,
settings: ManifestSettings {
pr_prefix: "[cross-repo]".to_string(),
merge_strategy: MergeStrategy::default(),
},
workspace: None,
};
let result = apply_links(&workspace, &manifest);
assert!(result.is_ok());
let dest_path = workspace.join("CLAUDE.md");
assert!(dest_path.exists());
let content = std::fs::read_to_string(&dest_path).unwrap();
assert_eq!(content, "# Claude Guide");
}
#[test]
#[cfg(unix)]
fn test_linkfile_points_to_source() {
let temp = TempDir::new().unwrap();
let workspace = temp.path().to_path_buf();
let repo_dir = workspace.join("test-repo");
std::fs::create_dir_all(&repo_dir).unwrap();
std::fs::write(repo_dir.join("shared.config"), "shared config").unwrap();
let linkfiles = vec![LinkFileConfig {
src: "shared.config".to_string(),
dest: "linked.config".to_string(),
}];
let manifest = create_test_manifest(None, Some(linkfiles));
let result = apply_links(&workspace, &manifest);
assert!(result.is_ok());
let dest_path = workspace.join("linked.config");
let link_target = std::fs::read_link(&dest_path).unwrap();
let expected_source = repo_dir.join("shared.config");
assert!(
link_target.ends_with("test-repo/shared.config"),
"Symlink should point to source, got: {:?}",
link_target
);
}
#[test]
#[cfg(unix)]
fn test_linkfile_replaces_existing_file() {
let temp = TempDir::new().unwrap();
let workspace = temp.path().to_path_buf();
let repo_dir = workspace.join("test-repo");
std::fs::create_dir_all(&repo_dir).unwrap();
std::fs::write(repo_dir.join("config.yaml"), "new: value").unwrap();
std::fs::write(workspace.join("linked.yaml"), "old: value").unwrap();
let linkfiles = vec![LinkFileConfig {
src: "config.yaml".to_string(),
dest: "linked.yaml".to_string(),
}];
let manifest = create_test_manifest(None, Some(linkfiles));
let result = apply_links(&workspace, &manifest);
assert!(result.is_ok());
let dest_path = workspace.join("linked.yaml");
assert!(dest_path.is_symlink());
let content = std::fs::read_to_string(&dest_path).unwrap();
assert_eq!(content, "new: value");
}
}