limb 0.1.0

A focused CLI for git worktree management
Documentation
//! Implements `limb setup`. Symlinks shared files into every worktree.

use std::path::Path;

use anyhow::{Context as _, Result};

use crate::cli::SetupArgs;
use crate::context::Context;
use crate::worktree::{self, Worktree};

/// Runs `limb setup`.
///
/// For every file declared under `[worktrees] shared = [...]` in
/// `.limb.toml`, creates a symlink in each worktree pointing back to the
/// source copy (typically under `.shared/`). A no-op if the worktree
/// already has a matching file, unless `--refresh-shared` is passed (in
/// which case symlinks pointing elsewhere are recreated; regular files
/// are preserved).
///
/// # Errors
///
/// Returns an error if no `.limb.toml` is present, the shared source
/// directory is missing, or a symlink cannot be created.
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)
    }
}