limb 0.1.0

A focused CLI for git worktree management
Documentation
//! Implements `limb migrate`. Converts plain clone → bare-clone layout.

use std::path::Path;

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

use crate::cancel::Cancel;

use crate::cli::MigrateArgs;
use crate::context::Context;
use crate::git;

/// Runs `limb migrate`.
///
/// Converts a plain `.git`-based clone into the bare-clone + worktrees
/// layout that `limb` is designed around: the original `.git` directory
/// becomes `.bare/`, and the worktree content moves into a subdirectory
/// named after the checked-out branch.
///
/// Refuses to run on dirty trees, detached HEAD, or if a `.bare` already
/// exists. The original tree is renamed to `.{repo}-limb-old/` so the
/// operation is reversible by hand.
///
/// # Errors
///
/// Returns an error if the repo is dirty, detached, already bare, or if
/// any of the required filesystem moves fail.
pub fn run(ctx: &Context, args: &MigrateArgs) -> Result<()> {
    let repo = match &args.repo {
        Some(p) => p.clone(),
        None => ctx.repo()?,
    };
    migrate(&repo, args.dry_run, &ctx.cancel)
}

fn migrate(repo: &Path, dry_run: bool, cancel: &Cancel) -> Result<()> {
    let repo = repo
        .canonicalize()
        .with_context(|| format!("canonicalize {}", repo.display()))?;
    if !repo.is_dir() {
        anyhow::bail!("not a directory: {}", repo.display());
    }

    let git_dir = repo.join(".git");
    if repo.join(".bare").is_dir() {
        anyhow::bail!("{} already has .bare/; nothing to do", repo.display());
    }
    if !git_dir.is_dir() {
        anyhow::bail!(
            "{} does not look like a plain git clone (no .git directory); \
             try: `limb migrate` only works on repos you cloned directly, not worktrees",
            repo.display()
        );
    }

    let head_branch = git::capture(&repo, &["rev-parse", "--abbrev-ref", "HEAD"])?
        .trim()
        .to_string();
    if head_branch.is_empty() || head_branch == "HEAD" {
        anyhow::bail!("repo is in detached HEAD; check out a named branch first");
    }

    let dirty = git::capture(&repo, &["status", "--porcelain=v1"])?;
    if !dirty.trim().is_empty() {
        anyhow::bail!("working tree has uncommitted changes; commit or stash them first");
    }

    let parent = repo
        .parent()
        .context("repo has no parent directory")?
        .to_path_buf();
    let repo_name = repo
        .file_name()
        .and_then(|n| n.to_str())
        .context("repo has no basename")?
        .to_string();
    let new_root = parent.join(&repo_name);
    let bare_dir = new_root.join(".bare");
    let worktree_dir = new_root.join(&head_branch);
    let staging_dir = parent.join(format!(".{repo_name}-limb-migrate"));

    eprintln!("migrate plan:");
    eprintln!("  from: {}", repo.display());
    eprintln!("  bare:     {}", bare_dir.display());
    eprintln!(
        "  worktree: {}  (branch: {head_branch})",
        worktree_dir.display()
    );
    if dry_run {
        eprintln!("\ndry run; no changes made");
        return Ok(());
    }

    std::fs::create_dir_all(&staging_dir)
        .with_context(|| format!("create staging dir {}", staging_dir.display()))?;

    if let Err(e) = cancel.check() {
        let _ = std::fs::remove_dir_all(&staging_dir);
        return Err(e);
    }

    let staged_bare = staging_dir.join(".bare");
    std::fs::rename(&git_dir, &staged_bare)
        .with_context(|| format!("move {}{}", git_dir.display(), staged_bare.display()))?;

    git::run(&staged_bare, &["config", "--unset-all", "core.worktree"]).ok();
    git::run(&staged_bare, &["config", "core.bare", "true"])?;

    let staged_worktree = staging_dir.join(&head_branch);
    let staged_worktree_parent = staged_worktree
        .parent()
        .with_context(|| format!("no parent for {}", staged_worktree.display()))?;
    std::fs::create_dir_all(staged_worktree_parent)?;
    move_contents(&repo, &staged_worktree)?;

    std::fs::rename(&repo, parent.join(format!(".{repo_name}-limb-old")))
        .with_context(|| format!("rename old repo dir {}", repo.display()))?;
    std::fs::rename(&staging_dir, &new_root)
        .with_context(|| format!("rename staging → {}", new_root.display()))?;

    git::run(
        &new_root.join(".bare"),
        &[
            "worktree",
            "add",
            worktree_dir.to_string_lossy().as_ref(),
            &head_branch,
        ],
    )
    .ok();

    let old = parent.join(format!(".{repo_name}-limb-old"));
    if old.exists() {
        let _ = std::fs::remove_dir_all(&old);
    }

    eprintln!("\nmigrated: {} (branch {head_branch})", new_root.display());
    Ok(())
}

fn move_contents(src: &Path, dst: &Path) -> Result<()> {
    std::fs::create_dir_all(dst)?;
    for entry in std::fs::read_dir(src)? {
        let entry = entry?;
        let name = entry.file_name();
        if name == ".git" || name == ".bare" {
            continue;
        }
        let to = dst.join(&name);
        std::fs::rename(entry.path(), &to)
            .with_context(|| format!("move {}{}", entry.path().display(), to.display()))?;
    }
    Ok(())
}