use std::path::Path;
use anyhow::{Context as _, Result};
use crate::cancel::Cancel;
use crate::cli::MigrateArgs;
use crate::context::Context;
use crate::git;
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(())
}