use std::path::Path;
use anyhow::{Context as _, Result};
use crate::cli::SetupArgs;
use crate::context::Context;
use crate::worktree::{self, Worktree};
pub fn run(ctx: &Context, args: &SetupArgs) -> Result<()> {
let repo_config = ctx
.repo_config()?
.context("no .limb.toml in this repo or any ancestor; try: `limb doctor`")?;
if repo_config.worktrees_shared.is_empty() {
eprintln!("no shared files declared in .limb.toml; nothing to do");
return Ok(());
}
let repo = ctx.repo()?;
let worktrees = worktree::list(&repo)?;
let source_dir = repo_config.resolved_shared_source();
if !source_dir.is_dir() {
anyhow::bail!(
"shared source dir does not exist: {}; \
try: `mkdir -p {}` and add the files listed under worktrees.shared",
source_dir.display(),
source_dir.display()
);
}
let mut summary = Summary::default();
for wt in &worktrees {
if wt.bare {
continue;
}
for relative in &repo_config.worktrees_shared {
sync_one(
&source_dir,
relative,
wt,
args.refresh_shared,
args.dry_run,
&mut summary,
);
}
}
summary.emit();
Ok(())
}
#[derive(Default)]
struct Summary {
linked: usize,
relinked: usize,
already_ok: usize,
skipped_missing_source: usize,
skipped_conflict: usize,
}
impl Summary {
fn emit(&self) {
let action = |n: usize, verb: &str| {
if n == 0 {
return;
}
eprintln!(" {n} {verb}");
};
eprintln!("setup:");
action(self.linked, "linked");
action(self.relinked, "relinked");
action(self.already_ok, "already ok");
action(self.skipped_missing_source, "skipped (source missing)");
action(self.skipped_conflict, "skipped (existing non-symlink)");
}
}
fn sync_one(
source_dir: &Path,
relative: &Path,
worktree: &Worktree,
refresh: bool,
dry_run: bool,
summary: &mut Summary,
) {
let src = source_dir.join(relative);
let dst = worktree.path.join(relative);
if !src.exists() {
eprintln!("skip: {} → source missing", dst.display());
summary.skipped_missing_source += 1;
return;
}
match classify(&dst, &src) {
State::Absent => create_link(&src, &dst, dry_run, summary, LinkAction::New),
State::CorrectLink => {
summary.already_ok += 1;
}
State::WrongLink if refresh => {
if !dry_run {
let _ = std::fs::remove_file(&dst);
}
create_link(&src, &dst, dry_run, summary, LinkAction::Relink);
}
State::WrongLink => {
eprintln!(
"skip: {} → symlink points elsewhere; use --refresh-shared to replace",
dst.display()
);
summary.skipped_conflict += 1;
}
State::RegularFile => {
eprintln!(
"skip: {} → regular file exists; remove it manually to let `limb setup` link",
dst.display()
);
summary.skipped_conflict += 1;
}
}
}
#[derive(Clone, Copy)]
enum LinkAction {
New,
Relink,
}
impl LinkAction {
const fn verb(self) -> &'static str {
match self {
Self::New => "link",
Self::Relink => "relink",
}
}
}
fn create_link(src: &Path, dst: &Path, dry_run: bool, summary: &mut Summary, action: LinkAction) {
if let Some(parent) = dst.parent()
&& !parent.exists()
&& !dry_run
{
let _ = std::fs::create_dir_all(parent);
}
let verb = action.verb();
if dry_run {
eprintln!("would {verb}: {} → {}", dst.display(), src.display());
} else {
match symlink(src, dst) {
Ok(()) => eprintln!("{verb}: {} → {}", dst.display(), src.display()),
Err(e) => {
eprintln!("error: {verb} {}: {e}", dst.display());
return;
}
}
}
match action {
LinkAction::New => summary.linked += 1,
LinkAction::Relink => summary.relinked += 1,
}
}
enum State {
Absent,
CorrectLink,
WrongLink,
RegularFile,
}
fn classify(dst: &Path, expected: &Path) -> State {
let Ok(metadata) = std::fs::symlink_metadata(dst) else {
return State::Absent;
};
if metadata.file_type().is_symlink() {
match std::fs::read_link(dst) {
Ok(target) if target == *expected => State::CorrectLink,
_ => State::WrongLink,
}
} else {
State::RegularFile
}
}
#[cfg(unix)]
fn symlink(src: &Path, dst: &Path) -> std::io::Result<()> {
std::os::unix::fs::symlink(src, dst)
}
#[cfg(windows)]
fn symlink(src: &Path, dst: &Path) -> std::io::Result<()> {
if src.is_dir() {
std::os::windows::fs::symlink_dir(src, dst)
} else {
std::os::windows::fs::symlink_file(src, dst)
}
}