From 1397a2abbe495d6876ef36f9cddb9630aa221d3a Mon Sep 17 00:00:00 2001 From: Rebecca Turner Date: Fri, 18 Oct 2024 16:05:30 -0700 Subject: [PATCH] Convert multiple worktrees (#21) Closes #4 --- src/app.rs | 1 + src/cli.rs | 4 + src/clone.rs | 1 + src/convert.rs | 833 +++++++++++------- src/git/worktree/mod.rs | 9 +- src/lib.rs | 2 + src/only_paths_in_parent_directory.rs | 64 ++ src/topological_sort.rs | 1 - tests/clone_simple.rs | 10 +- tests/config_remotes_default.rs | 2 +- tests/convert_bare_dot_git.rs | 41 + tests/convert_bare_ends_with_dot_git.rs | 41 + tests/convert_bare_no_dot.rs | 41 + tests/convert_bare_starts_with_dot.rs | 41 + tests/convert_common_parent.rs | 34 + tests/convert_common_parent_extra_dotfiles.rs | 47 + tests/convert_common_parent_extra_files.rs | 38 + tests/convert_common_prefix.rs | 38 + tests/convert_destination_explicit.rs | 28 + tests/convert_explicit_default_branch.rs | 35 + ...nvert_explicit_default_branch_not_found.rs | 17 + tests/convert_multiple_worktrees.rs | 17 +- tests/convert_no_local_default_branch.rs | 36 + 23 files changed, 1069 insertions(+), 312 deletions(-) create mode 100644 src/only_paths_in_parent_directory.rs create mode 100644 tests/convert_bare_dot_git.rs create mode 100644 tests/convert_bare_ends_with_dot_git.rs create mode 100644 tests/convert_bare_no_dot.rs create mode 100644 tests/convert_bare_starts_with_dot.rs create mode 100644 tests/convert_common_parent.rs create mode 100644 tests/convert_common_parent_extra_dotfiles.rs create mode 100644 tests/convert_common_parent_extra_files.rs create mode 100644 tests/convert_common_prefix.rs create mode 100644 tests/convert_destination_explicit.rs create mode 100644 tests/convert_explicit_default_branch.rs create mode 100644 tests/convert_explicit_default_branch_not_found.rs create mode 100644 tests/convert_no_local_default_branch.rs diff --git a/src/app.rs b/src/app.rs index e59fb73..d2e532f 100644 --- a/src/app.rs +++ b/src/app.rs @@ -50,6 +50,7 @@ impl App { self.git()?, ConvertPlanOpts { default_branch: args.default_branch.clone(), + destination: args.destination.clone(), }, )? .execute()?, diff --git a/src/cli.rs b/src/cli.rs index bbbcae1..cf8e57e 100644 --- a/src/cli.rs +++ b/src/cli.rs @@ -88,6 +88,10 @@ pub struct ConvertArgs { /// A default branch to create a worktree for. #[arg(long)] pub default_branch: Option, + + /// The directory to place the worktrees into. + #[arg()] + pub destination: Option, } #[derive(Args, Clone, Debug)] diff --git a/src/clone.rs b/src/clone.rs index ed5d8e5..d6271b1 100644 --- a/src/clone.rs +++ b/src/clone.rs @@ -42,6 +42,7 @@ pub fn clone(git: AppGit<'_>, args: CloneArgs) -> miette::Result<()> { git.with_directory(destination), ConvertPlanOpts { default_branch: None, + destination: None, }, )? .execute()?; diff --git a/src/convert.rs b/src/convert.rs index b531e0f..b831cb9 100644 --- a/src/convert.rs +++ b/src/convert.rs @@ -1,162 +1,428 @@ use std::fmt::Display; +use camino::Utf8PathBuf; use miette::miette; use owo_colors::OwoColorize; use owo_colors::Stream; -use tap::Tap; +use rustc_hash::FxHashSet as HashSet; +use tracing::instrument; use crate::app_git::AppGit; use crate::format_bulleted_list::format_bulleted_list; +use crate::format_bulleted_list_multiline; use crate::fs; use crate::git::BranchRef; use crate::git::LocalBranchRef; use crate::normal_path::NormalPath; +use crate::only_paths_in_parent_directory; +use crate::topological_sort::topological_sort; use crate::utf8tempdir::Utf8TempDir; use crate::AddWorktreeOpts; +use crate::RenamedWorktree; +use crate::ResolveUniqueNameOpts; +use crate::Worktree; +use crate::WorktreeHead; +use crate::Worktrees; #[derive(Debug)] pub struct ConvertPlanOpts { pub default_branch: Option, + pub destination: Option, } #[derive(Debug)] pub struct ConvertPlan<'a> { + /// A Git instance in the repository to convert. git: AppGit<'a>, - repo_name: String, - steps: Vec, + /// A temporary directory where worktrees will be placed while the repository is rearranged. + tempdir: Utf8PathBuf, + /// The destination where the worktree container will be created. + destination: Utf8PathBuf, + /// The path of the repository to create. + repo: Utf8PathBuf, + /// The plan for converting the repo to a bare repo. + /// + /// If this is `Some`, the main worktree is not yet bare. + make_bare: Option, + /// Plans for renaming the worktrees. + /// + /// These are ordered by a topological sort, to account for nested worktrees (if we move + /// `/puppy` before `/puppy/doggy`, then `/puppy/doggy` will not be where we expect it after + /// the first move). + /// + /// These contain unique names for each worktree, which are usually the name of the checked + /// out branch. + worktrees: Vec, + /// New worktrees to create. + /// + /// This contains the default branch, unless it's already checked out. + new_worktrees: Vec, } impl Display for ConvertPlan<'_> { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - write!(f, "{}", format_bulleted_list(&self.steps)) + write!( + f, + "Converting {} to a worktree repository{}.", + NormalPath::try_display_cwd(&self.repo), + if self.repo == self.destination { + String::new() + } else { + format!(" at {}", NormalPath::try_display_cwd(&self.destination)) + }, + )?; + + let moves = self + .worktrees + .iter() + .filter(|worktree| worktree.worktree.path != worktree.destination(self)) + .map(|worktree| { + format!( + "{} -> {}", + NormalPath::try_display_cwd(&worktree.worktree.path), + NormalPath::try_display_cwd(worktree.destination(self)), + ) + }) + .collect::>(); + + if !moves.is_empty() { + write!( + f, + "\nI'll move the following worktrees to new locations:\n\ + {}", + format_bulleted_list_multiline( + self.worktrees + .iter() + .filter(|worktree| { worktree.worktree.path != worktree.destination(self) }) + .map(|worktree| { + format!( + "{} -> {}", + NormalPath::try_display_cwd(&worktree.worktree.path), + NormalPath::try_display_cwd(worktree.destination(self)), + ) + }) + ) + )?; + } + + if !self.new_worktrees.is_empty() { + write!( + f, + "\nI'll{} create new worktrees for the following branches:\n\ + {}", + if moves.is_empty() { "" } else { " also" }, + format_bulleted_list_multiline(self.new_worktrees.iter().map(|worktree| { + format!( + "{} in {}", + worktree + .start_point + .qualified_branch_name() + .if_supports_color(Stream::Stdout, |text| text.cyan()), + NormalPath::try_display_cwd(worktree.destination(self)), + ) + })) + )?; + } + + if let Some(main_plan) = &self.make_bare { + if main_plan.git_dir() != main_plan.git_destination(self) { + write!( + f, + "\n{}I'll move the Git directory and convert the repository to a bare repository:\n\ + {}", + if moves.is_empty() && self.new_worktrees.is_empty() { + "" + } else { + "Additionally, " + }, + format_bulleted_list_multiline([ + format!( + "{} -> {}", + NormalPath::try_display_cwd(main_plan.git_dir()), + NormalPath::try_display_cwd(main_plan.git_destination(self)), + ) + ]) + )?; + } else { + write!( + f, + "\n{}I'll convert the repository to a bare repository.", + if moves.is_empty() && self.new_worktrees.is_empty() { + "" + } else { + "Additionally, " + }, + )?; + } + } + + Ok(()) } } impl<'a> ConvertPlan<'a> { + #[instrument(level = "trace")] pub fn new(git: AppGit<'a>, opts: ConvertPlanOpts) -> miette::Result { // Figuring out which worktrees to create is non-trivial: - // - [x] We might already have worktrees. - // - [x] We might have any number of remotes. - // Pick a reasonable & configurable default to determine the default branch. - // - [x] We might already have the default branch checked out. - // - [x] We might _not_ have the default branch checked out. - // - [x] We might have unstaged/uncommitted work. - // TODO: The `git reset` causes staged changes to be lost; bring back the - // `git status push`/`pop`? - // - [x] We might not be on _any_ branch. - // - [x] There is no local branch for the default branch. - // (`convert_multiple_remotes`) - - let tempdir = NormalPath::from_cwd(Utf8TempDir::new()?.into_path())?; + // - We might already have worktrees. (`convert_multiple_worktrees`) + // - We might have any number of remotes. + // (`convert_multiple_remotes`) + // - We might already have the default branch checked out. + // (`convert_default_branch_checked_out`) + // - We might _not_ have the default branch checked out. + // (`convert_non_default_branch_checked_out`) + // - We might have unstaged/uncommitted work. + // TODO: The `git reset` causes staged changes to be lost; bring back the + // `git status push`/`pop`? + // (`convert_uncommitted_changes`, `convert_unstaged_changes`) + // - We might not be on _any_ branch. + // (`convert_detached_head`) + // - There is no local branch for the default branch. + // (`config_default_branches`). + // + // Where do we want to place the resulting repo? + // - If it's non-bare: in the default worktree's path + // - If it's bare: + // - If the git dir is `.git`, then in its parent directory + // - If the git dir _ends with_ `.git`, then in the same directory, but with the `.git` + // suffix removed + // - Otherwise just use the git dir path. + + let tempdir = Utf8TempDir::new()?.into_path(); + let repo = git.path().repo_root_or_git_common_dir_if_bare()?; + let repo = repo + .parent() + .ok_or_else(|| miette!("Repository path has no parent: {repo}"))?; let worktrees = git.worktree().list()?; - // TODO: - // - toposort worktrees - // - resolve them all into unique directory names - if worktrees.len() != 1 { - return Err(miette!( - "Cannot convert a repository with multiple worktrees into a `git-prole` checkout:\n{worktrees}", - )); - } + let destination = Self::destination_plan(&worktrees, &opts)?; + let destination_name = destination + .file_name() + .ok_or_else(|| miette!("Destination has no basename: {destination}"))?; + tracing::debug!(%destination, "Destination determined"); let default_branch = match opts.default_branch { - Some(default_branch) => LocalBranchRef::new(default_branch).into(), + // Tests: + // - `convert_explicit_default_branch` + // - `convert_explicit_default_branch_not_found` + Some(default_branch) => git + .refs() + .rev_parse_symbolic_full_name(&default_branch)? + .ok_or_else(|| miette!("`--default-branch` not found: {default_branch}"))? + .try_into()?, None => git.preferred_branch()?, }; tracing::debug!(%default_branch, "Default branch determined"); - let default_branch_dirname = git.worktree().dirname_for(default_branch.branch_name()); - let head = git.refs().head_kind()?; - tracing::debug!(%head, "HEAD determined"); - let worktree_dirname = head.branch_name().unwrap_or("work"); - // TODO: Is this sufficient if handling multiple worktrees? - let default_branch_is_checked_out = head.is_on_branch(default_branch.branch_name()); - - // The path of the repository/main worktree before we start meddling with it. - let repo_root = NormalPath::from_cwd(git.path().repo_root()?)?; - let repo_name = repo_root - .file_name() - .ok_or_else(|| miette!("Repository has no basename: {repo_root}"))?; - // The path of the `.git` directory before we start meddling with it. - let repo_git_dir = NormalPath::from_cwd(git.path().git_common_dir()?)?; - // The path where we'll put the main worktree once we're done meddling with it. - let repo_worktree = repo_root.clone().tap_mut(|p| p.push(worktree_dirname)); - - // The path in the `tempdir` where we'll place the `.git` directory while we're setting up - // worktrees. - let temp_git_dir = tempdir.clone().tap_mut(|p| p.push(".git")); - // The path in the `tempdir` where we'll place the current worktree while we - // reassociate it with the (now-bare) repository. - let temp_worktree = tempdir.clone().tap_mut(|p| p.push(worktree_dirname)); - - // I don't know, what if you have `fix/main` (not a `fix` remote, but a - // branch named `fix/main`!) checked out, and the default branch is `main`? - if !default_branch_is_checked_out && worktree_dirname == default_branch_dirname { - return Err( - miette!("Worktree directory names for default branch ({default_branch_dirname}) and current branch ({worktree_dirname}) would conflict") - ); + + // TODO: Check for branch with the default as an upstream as well? + // + // Tests: + // - `convert_default_branch_checked_out` + // - `convert_non_default_branch_checked_out` + let has_worktree_for_default_branch = + worktrees.for_branch(&default_branch.as_local()).is_some(); + let new_worktrees = if has_worktree_for_default_branch { + Vec::new() + } else { + let name = git + .worktree() + .dirname_for(default_branch.branch_name()) + .to_owned(); + + // If we're creating a worktree for a default branch from a + // remote, we may not have a corresponding local branch + // yet. + let (create_branch, start_point) = match &default_branch { + BranchRef::Local(_) => (None, default_branch), + BranchRef::Remote(remote_branch) => { + if git.branch().exists_local(remote_branch.branch_name())? { + // Test: `convert_multiple_remotes` + (None, BranchRef::Local(remote_branch.as_local())) + } else { + // Test: `convert_no_local_default_branch` + tracing::warn!( + %remote_branch, + "Fetching the default branch" + ); + git.remote().fetch( + remote_branch.remote(), + Some(&format!("{:#}:{remote_branch:#}", remote_branch.as_local())), + )?; + (Some(remote_branch.as_local()), default_branch) + } + } + }; + + vec![NewWorktreePlan { + name, + create_branch, + start_point, + }] + }; + + // Tests: + // - `convert_multiple_worktrees` + // + // Note: It's hard to write behavior tests for this because the tempdirs that tests run in + // are randomly generated, so even though `rustc_hash` makes the `HashMap` iteration order + // deterministic, the hashes of worktree paths aren't deterministic because they include + // the tempdir paths. + let mut worktrees = git.worktree().resolve_unique_names(ResolveUniqueNameOpts { + worktrees, + names: new_worktrees + .iter() + .map(|plan| plan.name.to_owned()) + .collect(), + directory_names: &HashSet::from_iter([destination_name]), + })?; + + tracing::debug!( + "Worktree names resolved:\n{}", + format_bulleted_list(worktrees.iter().map(|(path, worktree)| { + format!("{} → {}", NormalPath::try_display_cwd(path), &worktree.name) + })) + ); + + let mut make_bare = None; + + // Note: Worktrees may be nested in each other, so we have to move them in a + // topologically-sorted order! E.g. if we have worktrees `/puppy` and + // `/puppy/doggy`, if we move `/puppy` first then `/puppy/doggy` will no longer be + // where we expect it! + let worktree_plans = topological_sort(&worktrees.keys().collect::>())? + .into_iter() + .map(|path| { + let renamed = worktrees + .remove(&path) + .expect("Topological sort will not invent worktrees"); + + let plan = WorktreePlan::from(renamed); + + // Test: `convert_default_branch_checked_out` (and many others) + if plan.worktree.is_main && !plan.worktree.head.is_bare() { + make_bare = Some(MainWorktreePlan { + inner: plan.clone(), + }); + } + + plan + }) + .collect::>(); + + let ret = Self { + git, + tempdir, + destination, + worktrees: worktree_plans, + repo: repo.to_owned(), + make_bare, + new_worktrees, + }; + + tracing::debug!( + "Worktree plans determined:\n{}", + format_bulleted_list(ret.worktrees.iter().map(|plan| { + format!( + "{} → {} → {}", + NormalPath::try_display_cwd(&plan.worktree.path), + NormalPath::try_display_cwd(plan.temp_destination(&ret)), + NormalPath::try_display_cwd(plan.destination(&ret)), + ) + })) + ); + + match &ret.make_bare { + Some(make_bare) => { + tracing::debug!( + git_dir=%NormalPath::try_display_cwd(make_bare.git_dir()), + temp_git_destination=%NormalPath::try_display_cwd(make_bare.temp_git_destination(&ret)), + git_destination=%NormalPath::try_display_cwd(make_bare.git_destination(&ret)), + worktree_temp_git_destination=%NormalPath::try_display_cwd(make_bare.worktree_temp_git_destination(&ret)), + worktree_git_destination=%NormalPath::try_display_cwd(make_bare.worktree_git_destination(&ret)), + worktree_plan=%format!( + "{} → {} → {}", + NormalPath::try_display_cwd(&make_bare.inner.worktree.path), + NormalPath::try_display_cwd(make_bare.inner.temp_destination(&ret)), + NormalPath::try_display_cwd(make_bare.inner.destination(&ret)), + ), + "Plan for converting to a bare repository determined", + ); + } + None => { + tracing::debug!("Repository is already bare"); + } } - let mut steps = vec![ - Step::Move { - from: repo_git_dir.clone(), - to: temp_git_dir.clone(), - }, - Step::SetConfig { - repo: temp_git_dir.clone(), - key: "core.bare".to_owned(), - value: "true".to_owned(), - }, - Step::Move { - from: repo_root.clone(), - to: temp_worktree.clone(), - }, - Step::CreateDir { - path: repo_root.clone(), - }, - Step::Move { - from: temp_git_dir.clone(), - to: repo_git_dir.clone(), - }, - Step::CreateWorktreeNoCheckout { - repo: repo_git_dir.clone(), - path: repo_worktree.clone(), - commitish: head.commitish().to_owned(), - }, - Step::Reset { - repo: repo_worktree.clone(), - }, - Step::Move { - from: repo_worktree.clone().tap_mut(|p| p.push(".git")), - to: temp_worktree.clone().tap_mut(|p| p.push(".git")), - }, - Step::RemoveDirectory { - path: repo_worktree.clone(), - }, - Step::Move { - from: temp_worktree.clone(), - to: repo_worktree.clone(), - }, - ]; - - if !default_branch_is_checked_out { - let default_branch_root = repo_root - .clone() - .tap_mut(|p| p.push(default_branch_dirname)); - - steps.push(Step::CreateWorktree { - repo: repo_git_dir.clone(), - path: default_branch_root.clone(), - branch: default_branch, - }); + Ok(ret) + } + + #[instrument(level = "trace")] + fn destination_plan( + worktrees: &Worktrees, + opts: &ConvertPlanOpts, + ) -> miette::Result { + if let Some(destination) = &opts.destination { + // `convert_destination_explicit` + return Ok(NormalPath::from_cwd(destination.clone())?.into()); } - Ok(Self { - steps, - git, - repo_name: repo_name.to_owned(), - }) + let main = worktrees.main(); + match main.head { + WorktreeHead::Detached(_) | WorktreeHead::Branch(_, _) => { + if worktrees.len() > 1 { + if let Some(common_parent) = only_paths_in_parent_directory(worktrees.keys()) { + // There's some common prefix all the worktrees belong to, let's put the + // new repo there. + // + // Tests: + // - `convert_common_prefix` + // - `convert_common_parent` + // - `convert_common_parent_extra_files` + // - `convert_common_parent_extra_dotfiles` + tracing::debug!(path = %common_parent, "Worktrees have a common parent"); + return Ok(common_parent.to_owned()); + } + } + // Tests: + // - `convert_common_prefix` + // - `convert_multiple_worktrees` + // - `convert_detached_head` + Ok(main.path.clone()) + } + WorktreeHead::Bare => { + let basename = main + .path + .file_name() + .ok_or_else(|| miette!("Git directory has no basename: {}", main.path))?; + + let parent = main + .path + .parent() + .ok_or_else(|| miette!("Git directory has no parent: {}", main.path))?; + + if basename == ".git" || basename.starts_with(".") { + // Tests: + // - `convert_bare_dot_git` + // - `convert_bare_starts_with_dot` + Ok(parent.to_owned()) + } else if let Some(stripped_basename) = basename.strip_suffix(".git") { + // `my-repo.git` -> `my-repo` + // + // Tests: + // - `convert_bare_ends_with_dot_git` + Ok(parent.join(stripped_basename)) + } else { + // Is this what you want? No clue! + // + // Tests: + // - `convert_bare_no_dot` + Ok(main.path.clone()) + } + } + } } + #[instrument(level = "trace")] pub fn execute(&self) -> miette::Result<()> { tracing::info!("{self}"); @@ -166,110 +432,88 @@ impl<'a> ConvertPlan<'a> { // TODO: Ask the user before we start messing around with their repo layout! - for step in &self.steps { - tracing::debug!(%step, "Performing step"); - match step { - Step::MoveWorktree { from, to, is_main } => { - if *is_main { - // The main worktree cannot be moved with `git worktree move`. - fs::rename(from, to)?; - self.git - .with_directory(to.as_path().to_owned()) - .worktree() - .repair()?; - } else { - self.git.worktree().rename(from, to)?; - } - } - Step::CreateDir { path } => { - fs::create_dir_all(path)?; - } - Step::Move { from, to } => { - fs::rename(from, to)?; - } - Step::SetConfig { repo, key, value } => { - self.git - .with_directory(repo.as_path().to_owned()) - .config() - .set(key, value)?; - } - Step::CreateWorktree { - repo: repo_root, - path, - branch, - } => { - // If we're creating a worktree for a default branch from a - // remote, we may not have a corresponding local branch - // yet. - let (create_branch, start_point) = match branch { - BranchRef::Remote(remote_branch) => { - if self - .git - .branch() - .exists_local(remote_branch.branch_name())? - { - (None, &BranchRef::Local(remote_branch.as_local())) - } else { - tracing::warn!( - %remote_branch, - "Fetching the default branch" - ); - self.git.remote().fetch( - remote_branch.remote(), - Some(&format!( - "{:#}:{remote_branch:#}", - remote_branch.as_local() - )), - )?; - (Some(remote_branch.as_local()), branch) - } - } - BranchRef::Local(_) => (None, branch), - }; - - self.git - .with_directory(repo_root.as_path().to_owned()) - .worktree() - // .add(path.as_path(), commitish.qualified_branch_name())?; - .add( - path.as_path(), - &AddWorktreeOpts { - track: create_branch.is_some(), - create_branch: create_branch.as_ref(), - start_point: Some(start_point.qualified_branch_name()), - ..Default::default() - }, - )?; - } - Step::CreateWorktreeNoCheckout { - repo, - path, - commitish, - } => { - self.git - .with_directory(repo.as_path().to_owned()) - .worktree() - .add( - path, - &AddWorktreeOpts { - checkout: false, - start_point: Some(commitish), - ..Default::default() - }, - )?; - } - Step::Reset { repo } => { - self.git.with_directory(repo.as_path().to_owned()).reset()?; - } - Step::RemoveDirectory { path } => { - fs::remove_dir(path)?; - } - } + // If the repository isn't already bare, separate the `.git` directory from its worktree + // and make it bare. + // + // Test: (for all the `make_bare` behavior) + // - `convert_default_branch_checked_out` (and many more) + if let Some(make_bare) = &self.make_bare { + fs::rename(make_bare.git_dir(), make_bare.temp_git_destination(self))?; + self.git + .with_directory(make_bare.temp_git_destination(self)) + .config() + .set("core.bare", "true")?; + } + + // Move worktrees to the tempdir. + for plan in &self.worktrees { + fs::rename(&plan.worktree.path, plan.temp_destination(self))?; + } + + // Create the destination if it doesn't exist. + if !self.destination.exists() { + fs::create_dir_all(&self.destination)?; + } + + // Move the `.git` directory to its new location. + if let Some(make_bare) = &self.make_bare { + fs::rename( + make_bare.temp_git_destination(self), + make_bare.git_destination(self), + )?; + + // Make the main worktree into a real worktree, now that we've removed its `.git` + // directory. + self.git + .with_directory(make_bare.git_destination(self)) + .worktree() + .add( + &make_bare.inner.destination(self), + &AddWorktreeOpts { + checkout: false, + start_point: Some(&make_bare.inner.worktree.head.commitish() + .expect("If we're converting to a bare repository, the main worktree is never bare") + .to_string()), + ..Default::default() + }, + )?; + + self.git + .with_directory(make_bare.inner.destination(self)) + .reset()?; + fs::rename( + make_bare.worktree_git_destination(self), + make_bare.worktree_temp_git_destination(self), + )?; + fs::remove_dir(make_bare.inner.destination(self))?; + } + + // Move worktrees back from the tempdir. + for plan in &self.worktrees { + fs::rename(plan.temp_destination(self), plan.destination(self))?; + } + + // Repair worktrees with their new paths. + let git = self.git.with_directory(self.destination.clone()); + git.worktree() + .repair(self.worktrees.iter().map(|plan| plan.destination(self)))?; + + // Create new worktrees. + for plan in &self.new_worktrees { + git.worktree().add( + &plan.destination(self), + &AddWorktreeOpts { + track: plan.create_branch.is_some(), + create_branch: plan.create_branch.as_ref(), + start_point: Some(plan.start_point.qualified_branch_name()), + ..Default::default() + }, + )?; } tracing::info!( "{} has been converted to a worktree checkout", - self.repo_name + NormalPath::from_cwd(&self.destination)? ); tracing::info!("You may need to `cd .` to refresh your shell"); @@ -277,97 +521,82 @@ impl<'a> ConvertPlan<'a> { } } +/// A plan for converting one worktree into a worktree repo. +/// +/// **Note:** This is isomorphic to [`RenamedWorktree`]. #[derive(Debug, Clone)] -pub enum Step { - Move { - from: NormalPath, - to: NormalPath, - }, - SetConfig { - repo: NormalPath, - key: String, - value: String, - }, - CreateWorktreeNoCheckout { - repo: NormalPath, - path: NormalPath, - commitish: String, - }, - Reset { - repo: NormalPath, - }, - RemoveDirectory { - path: NormalPath, - }, - /// Will be needed for multiple worktree support. - #[expect(dead_code)] - MoveWorktree { - from: NormalPath, - to: NormalPath, - is_main: bool, - }, - CreateDir { - path: NormalPath, - }, - CreateWorktree { - repo: NormalPath, - path: NormalPath, - branch: BranchRef, - }, +struct WorktreePlan { + /// The name of the worktree; this is the last component of the destination path. + name: String, + /// The worktree itself. + worktree: Worktree, } -impl Display for Step { - fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - match self { - Step::MoveWorktree { - from, - to, - is_main: _, - } => { - write!(f, "Move {from} to {to}") - } - Step::CreateDir { path } => { - write!(f, "Create directory {path}") - } - Step::CreateWorktree { - path, - branch: commitish, - repo: repo_root, - } => { - write!( - f, - "In {repo_root}, create a worktree for {} at {path}", - commitish.if_supports_color(Stream::Stdout, |branch| branch.cyan()) - ) - } - Step::SetConfig { repo, key, value } => { - write!( - f, - "In {repo}, set {}={}", - key.if_supports_color(Stream::Stdout, |text| text.cyan()), - value.if_supports_color(Stream::Stdout, |text| text.cyan()), - ) - } - Step::Move { from, to } => { - write!(f, "Move {from} to {to}") - } - Step::CreateWorktreeNoCheckout { - repo, - commitish, - path, - } => { - write!( - f, - "In {repo}, create but don't check out a worktree for {} at {path}", - commitish.if_supports_color(Stream::Stdout, |text| text.cyan()), - ) - } - Step::Reset { repo } => { - write!(f, "In {repo}, reset the index state") - } - Step::RemoveDirectory { path } => { - write!(f, "Remove {path}") - } - } +impl From for WorktreePlan { + fn from(RenamedWorktree { name, worktree }: RenamedWorktree) -> Self { + Self { name, worktree } + } +} + +impl WorktreePlan { + /// Where we'll place the worktree in the temporary directory. + fn temp_destination(&self, convert_plan: &ConvertPlan<'_>) -> Utf8PathBuf { + convert_plan.tempdir.join(&self.name) + } + + /// Where we'll place the worktree when we're done. + fn destination(&self, convert_plan: &ConvertPlan<'_>) -> Utf8PathBuf { + convert_plan.destination.join(&self.name) + } +} + +/// A plan for creating a new worktree for a worktree repo. +#[derive(Debug, Clone)] +struct NewWorktreePlan { + /// The name of the worktree; this is the last component of the destination path. + name: String, + /// A local branch to create, if the `start_point` doesn't already exist. + create_branch: Option, + /// The branch the worktree will have checked out. + start_point: BranchRef, +} + +impl NewWorktreePlan { + /// Where the new worktree will be created. + fn destination(&self, convert_plan: &ConvertPlan<'_>) -> Utf8PathBuf { + convert_plan.destination.join(&self.name) + } +} + +#[derive(Debug, Clone)] +struct MainWorktreePlan { + /// The plan for the main worktree. + inner: WorktreePlan, +} + +impl MainWorktreePlan { + /// The path of the `.git` directory before meddling. + fn git_dir(&self) -> Utf8PathBuf { + self.inner.worktree.path.join(".git") + } + + /// Where we'll place the `.git` directory in the temporary directory. + fn temp_git_destination(&self, convert_plan: &ConvertPlan<'_>) -> Utf8PathBuf { + convert_plan.tempdir.join(".git") + } + + /// Where we'll place the `.git` directory when we're done. + fn git_destination(&self, convert_plan: &ConvertPlan<'_>) -> Utf8PathBuf { + convert_plan.destination.join(".git") + } + + /// Where we'll place the _worktree's_ `.git` symlink in the temporary directory. + fn worktree_temp_git_destination(&self, convert_plan: &ConvertPlan<'_>) -> Utf8PathBuf { + self.inner.temp_destination(convert_plan).join(".git") + } + + /// Where we'll place the _worktree's_ `.git` symlink when we're done. + fn worktree_git_destination(&self, convert_plan: &ConvertPlan<'_>) -> Utf8PathBuf { + self.inner.destination(convert_plan).join(".git") } } diff --git a/src/git/worktree/mod.rs b/src/git/worktree/mod.rs index 0908377..737e278 100644 --- a/src/git/worktree/mod.rs +++ b/src/git/worktree/mod.rs @@ -1,3 +1,4 @@ +use std::ffi::OsStr; use std::fmt::Debug; use std::process::Command; @@ -139,11 +140,15 @@ impl<'a> GitWorktree<'a> { } #[instrument(level = "trace")] - pub fn repair(&self) -> miette::Result<()> { + pub fn repair( + &self, + paths: impl IntoIterator> + Debug, + ) -> miette::Result<()> { self.0 .command() .args(["worktree", "repair"]) - .status_checked() + .args(paths) + .output_checked_utf8() .into_diagnostic()?; Ok(()) } diff --git a/src/lib.rs b/src/lib.rs index 50d5dc4..abe08f6 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -20,6 +20,7 @@ mod gh; mod git; mod install_tracing; mod normal_path; +mod only_paths_in_parent_directory; mod parse; mod topological_sort; mod utf8tempdir; @@ -55,4 +56,5 @@ pub use git::Worktree; pub use git::WorktreeHead; pub use git::Worktrees; pub use normal_path::NormalPath; +pub use only_paths_in_parent_directory::only_paths_in_parent_directory; pub use utf8tempdir::Utf8TempDir; diff --git a/src/only_paths_in_parent_directory.rs b/src/only_paths_in_parent_directory.rs new file mode 100644 index 0000000..e7b48d0 --- /dev/null +++ b/src/only_paths_in_parent_directory.rs @@ -0,0 +1,64 @@ +use std::fmt::Debug; + +use camino::Utf8Path; +use miette::IntoDiagnostic; +use rustc_hash::FxHashSet; +use tracing::instrument; + +/// Check if a set of paths all have the same parent directory and they are the only paths in that +/// directory (other than dotfiles). +#[instrument(level = "trace")] +pub fn only_paths_in_parent_directory<'p, I, P>(paths: I) -> Option<&'p Utf8Path> +where + I: IntoIterator + Debug, + P: AsRef + 'p + ?Sized, +{ + let mut paths = paths.into_iter(); + let mut names = FxHashSet::default(); + let first = paths.next()?.as_ref(); + let parent = first.parent()?; + names.insert(first.file_name()?); + + for path in paths { + let path = path.as_ref(); + if path.parent()? != parent { + return None; + } + names.insert(path.file_name()?); + } + + match path_contains_only_names_and_dotfiles(parent, &names) { + Ok(true) => Some(parent), + Ok(false) => None, + Err(error) => { + tracing::debug!( + directory=%parent, + error=%error, + "Error while listing directory" + ); + None + } + } +} + +/// Check if a path contains only files listed in the given set of names and dotfiles. +#[instrument(level = "trace")] +fn path_contains_only_names_and_dotfiles( + path: &Utf8Path, + names: &FxHashSet<&str>, +) -> miette::Result { + for entry in path.read_dir_utf8().into_diagnostic()? { + let entry = entry.into_diagnostic()?; + let name = entry.file_name(); + if !name.starts_with('.') && !names.contains(name) { + tracing::debug!( + directory=%path, + entry=%name, + "Directory entry is not a dotfile or listed in known paths" + ); + return Ok(false); + } + } + + Ok(true) +} diff --git a/src/topological_sort.rs b/src/topological_sort.rs index 9933ff3..27621ed 100644 --- a/src/topological_sort.rs +++ b/src/topological_sort.rs @@ -14,7 +14,6 @@ use rustc_hash::FxHashSet as HashSet; /// This implements Kahn's algorithm. /// /// See: -#[cfg_attr(not(test), expect(dead_code))] pub fn topological_sort

(paths: &[P]) -> miette::Result> where P: AsRef, diff --git a/tests/clone_simple.rs b/tests/clone_simple.rs index 9c1141e..c4535ea 100644 --- a/tests/clone_simple.rs +++ b/tests/clone_simple.rs @@ -4,15 +4,17 @@ use test_harness::GitProle; use test_harness::WorktreeState; #[test] -fn clone_simple() { - let prole = GitProle::new().unwrap(); - prole.setup_repo("remote/my-repo").unwrap(); +fn clone_simple() -> miette::Result<()> { + let prole = GitProle::new()?; + prole.setup_repo("remote/my-repo")?; prole .cmd() .args(["clone", "remote/my-repo"]) .status_checked() .unwrap(); + prole.sh("ls -la && ls -la my-repo")?; + prole .repo_state("my-repo") .worktrees([ @@ -25,4 +27,6 @@ fn clone_simple() { ), ]) .assert(); + + Ok(()) } diff --git a/tests/config_remotes_default.rs b/tests/config_remotes_default.rs index 5f63d15..2b1c6f5 100644 --- a/tests/config_remotes_default.rs +++ b/tests/config_remotes_default.rs @@ -6,7 +6,7 @@ use test_harness::GitProle; use test_harness::WorktreeState; #[test] -fn convert_multiple_remotes() -> miette::Result<()> { +fn config_remotes_default() -> miette::Result<()> { let prole = GitProle::new()?; setup_repo_multiple_remotes(&prole, "my-remotes/my-repo", "my-repo")?; diff --git a/tests/convert_bare_dot_git.rs b/tests/convert_bare_dot_git.rs new file mode 100644 index 0000000..882d673 --- /dev/null +++ b/tests/convert_bare_dot_git.rs @@ -0,0 +1,41 @@ +use command_error::CommandExt; +use miette::IntoDiagnostic; +use test_harness::GitProle; +use test_harness::WorktreeState; + +#[test] +fn convert_bare_dot_git() -> miette::Result<()> { + let prole = GitProle::new()?; + prole.sh(r#" + mkdir -p my-repo/.git + cd my-repo/.git || exit + git init --bare + + git worktree add ../main + cd ../main || exit + echo "puppy doggy" > README.md + git add . + git commit -m "Initial commit" + + git worktree add ../puppy + git worktree add --detach ../doggy + "#)?; + + prole + .cd_cmd("my-repo/main") + .arg("convert") + .status_checked() + .into_diagnostic()?; + + prole + .repo_state("my-repo") + .worktrees([ + WorktreeState::new_bare(), + WorktreeState::new("main").branch("main"), + WorktreeState::new("puppy").branch("puppy"), + WorktreeState::new("doggy").detached("4023d080"), + ]) + .assert(); + + Ok(()) +} diff --git a/tests/convert_bare_ends_with_dot_git.rs b/tests/convert_bare_ends_with_dot_git.rs new file mode 100644 index 0000000..e46b18e --- /dev/null +++ b/tests/convert_bare_ends_with_dot_git.rs @@ -0,0 +1,41 @@ +use command_error::CommandExt; +use miette::IntoDiagnostic; +use test_harness::GitProle; +use test_harness::WorktreeState; + +#[test] +fn convert_bare_ends_with_dot_git() -> miette::Result<()> { + let prole = GitProle::new()?; + prole.sh(r#" + mkdir -p my-repo.git + cd my-repo.git || exit + git init --bare + + git worktree add ../main + cd ../main || exit + echo "puppy doggy" > README.md + git add . + git commit -m "Initial commit" + + git worktree add ../puppy + git worktree add --detach ../doggy + "#)?; + + prole + .cd_cmd("my-repo.git") + .arg("convert") + .status_checked() + .into_diagnostic()?; + + prole + .repo_state("my-repo") + .worktrees([ + WorktreeState::new_bare(), + WorktreeState::new("main").branch("main"), + WorktreeState::new("puppy").branch("puppy"), + WorktreeState::new("doggy").detached("4023d080"), + ]) + .assert(); + + Ok(()) +} diff --git a/tests/convert_bare_no_dot.rs b/tests/convert_bare_no_dot.rs new file mode 100644 index 0000000..9946aff --- /dev/null +++ b/tests/convert_bare_no_dot.rs @@ -0,0 +1,41 @@ +use command_error::CommandExt; +use miette::IntoDiagnostic; +use test_harness::GitProle; +use test_harness::WorktreeState; + +#[test] +fn convert_bare_no_dot() -> miette::Result<()> { + let prole = GitProle::new()?; + prole.sh(r#" + mkdir -p my-repo + cd my-repo || exit + git init --bare + + git worktree add ../main + cd ../main || exit + echo "puppy doggy" > README.md + git add . + git commit -m "Initial commit" + + git worktree add ../puppy + git worktree add --detach ../doggy + "#)?; + + prole + .cd_cmd("main") + .arg("convert") + .status_checked() + .into_diagnostic()?; + + prole + .repo_state("my-repo") + .worktrees([ + WorktreeState::new_bare(), + WorktreeState::new("main").branch("main"), + WorktreeState::new("puppy").branch("puppy"), + WorktreeState::new("doggy").detached("4023d080"), + ]) + .assert(); + + Ok(()) +} diff --git a/tests/convert_bare_starts_with_dot.rs b/tests/convert_bare_starts_with_dot.rs new file mode 100644 index 0000000..f4b3c92 --- /dev/null +++ b/tests/convert_bare_starts_with_dot.rs @@ -0,0 +1,41 @@ +use command_error::CommandExt; +use miette::IntoDiagnostic; +use test_harness::GitProle; +use test_harness::WorktreeState; + +#[test] +fn convert_bare_starts_with_dot() -> miette::Result<()> { + let prole = GitProle::new()?; + prole.sh(r#" + mkdir -p my-repo/.bare + cd my-repo/.bare || exit + git init --bare + + git worktree add ../main + cd ../main || exit + echo "puppy doggy" > README.md + git add . + git commit -m "Initial commit" + + git worktree add ../puppy + git worktree add --detach ../doggy + "#)?; + + prole + .cd_cmd("my-repo/main") + .arg("convert") + .status_checked() + .into_diagnostic()?; + + prole + .repo_state("my-repo") + .worktrees([ + WorktreeState::new_bare(), + WorktreeState::new("main").branch("main"), + WorktreeState::new("puppy").branch("puppy"), + WorktreeState::new("doggy").detached("4023d080"), + ]) + .assert(); + + Ok(()) +} diff --git a/tests/convert_common_parent.rs b/tests/convert_common_parent.rs new file mode 100644 index 0000000..531a354 --- /dev/null +++ b/tests/convert_common_parent.rs @@ -0,0 +1,34 @@ +use command_error::CommandExt; +use miette::IntoDiagnostic; +use test_harness::GitProle; +use test_harness::WorktreeState; + +#[test] +fn convert_common_parent() -> miette::Result<()> { + let prole = GitProle::new()?; + prole.setup_repo("my-prefix/my-repo")?; + + prole.sh(r#" + cd my-prefix/my-repo + git worktree add ../puppy + git worktree add ../doggy + "#)?; + + prole + .cd_cmd("my-prefix/my-repo") + .arg("convert") + .status_checked() + .into_diagnostic()?; + + prole + .repo_state("my-prefix") + .worktrees([ + WorktreeState::new_bare(), + WorktreeState::new("main").branch("main"), + WorktreeState::new("puppy").branch("puppy"), + WorktreeState::new("doggy").branch("doggy"), + ]) + .assert(); + + Ok(()) +} diff --git a/tests/convert_common_parent_extra_dotfiles.rs b/tests/convert_common_parent_extra_dotfiles.rs new file mode 100644 index 0000000..c1c2a45 --- /dev/null +++ b/tests/convert_common_parent_extra_dotfiles.rs @@ -0,0 +1,47 @@ +use command_error::CommandExt; +use expect_test::expect; +use miette::IntoDiagnostic; +use test_harness::GitProle; +use test_harness::WorktreeState; + +#[test] +fn convert_common_parent_extra_dotfiles() -> miette::Result<()> { + let prole = GitProle::new()?; + prole.setup_repo("my-prefix/my-repo")?; + + prole.sh(r#" + cd my-prefix/my-repo + git worktree add ../puppy + git worktree add ../doggy + + # This non-worktree path will NOT prevent `my-prefix` from being used + # as the destination, because it's a dotfile. + echo 'puppy = "cute"' > ../.my-config-file.toml + "#)?; + + prole + .cd_cmd("my-prefix/my-repo") + .arg("convert") + .status_checked() + .into_diagnostic()?; + + prole + .repo_state("my-prefix") + .worktrees([ + WorktreeState::new_bare(), + WorktreeState::new("main").branch("main"), + WorktreeState::new("puppy").branch("puppy"), + WorktreeState::new("doggy").branch("doggy"), + ]) + .assert(); + + // The config file we write should be preserved! + prole.assert_contents(&[( + "my-prefix/.my-config-file.toml", + expect![[r#" + puppy = "cute" + "#]], + )]); + + Ok(()) +} diff --git a/tests/convert_common_parent_extra_files.rs b/tests/convert_common_parent_extra_files.rs new file mode 100644 index 0000000..7f190a3 --- /dev/null +++ b/tests/convert_common_parent_extra_files.rs @@ -0,0 +1,38 @@ +use command_error::CommandExt; +use miette::IntoDiagnostic; +use test_harness::GitProle; +use test_harness::WorktreeState; + +#[test] +fn convert_common_parent_extra_files() -> miette::Result<()> { + let prole = GitProle::new()?; + prole.setup_repo("my-prefix/my-repo")?; + + prole.sh(r#" + cd my-prefix/my-repo + git worktree add ../puppy + git worktree add ../doggy + + # This non-worktree path will prevent `my-prefix` from being used + # as the destination. + touch ../something-else + "#)?; + + prole + .cd_cmd("my-prefix/my-repo") + .arg("convert") + .status_checked() + .into_diagnostic()?; + + prole + .repo_state("my-prefix/my-repo") + .worktrees([ + WorktreeState::new_bare(), + WorktreeState::new("main").branch("main"), + WorktreeState::new("puppy").branch("puppy"), + WorktreeState::new("doggy").branch("doggy"), + ]) + .assert(); + + Ok(()) +} diff --git a/tests/convert_common_prefix.rs b/tests/convert_common_prefix.rs new file mode 100644 index 0000000..f798ba8 --- /dev/null +++ b/tests/convert_common_prefix.rs @@ -0,0 +1,38 @@ +use command_error::CommandExt; +use miette::IntoDiagnostic; +use test_harness::GitProle; +use test_harness::WorktreeState; + +#[test] +fn convert_common_prefix() -> miette::Result<()> { + let prole = GitProle::new()?; + prole.setup_repo("my-prefix/my-repo")?; + + prole.sh(r#" + cd my-prefix/my-repo + git worktree add ../puppy + git worktree add ../doggy + git worktree add silly + git worktree add silly/cutie + "#)?; + + prole + .cd_cmd("my-prefix/my-repo") + .arg("convert") + .status_checked() + .into_diagnostic()?; + + prole + .repo_state("my-prefix/my-repo") + .worktrees([ + WorktreeState::new_bare(), + WorktreeState::new("main").branch("main"), + WorktreeState::new("puppy").branch("puppy"), + WorktreeState::new("doggy").branch("doggy"), + WorktreeState::new("silly").branch("silly"), + WorktreeState::new("cutie").branch("cutie"), + ]) + .assert(); + + Ok(()) +} diff --git a/tests/convert_destination_explicit.rs b/tests/convert_destination_explicit.rs new file mode 100644 index 0000000..26159a2 --- /dev/null +++ b/tests/convert_destination_explicit.rs @@ -0,0 +1,28 @@ +use command_error::CommandExt; +use miette::IntoDiagnostic; +use test_harness::GitProle; +use test_harness::WorktreeState; + +#[test] +fn convert_destination_explicit() -> miette::Result<()> { + let prole = GitProle::new()?; + prole.setup_repo("my-repo")?; + + prole + .cd_cmd("my-repo") + .args(["convert", "../puppy"]) + .status_checked() + .into_diagnostic()?; + + prole.sh("ls -la && ls -la puppy")?; + + prole + .repo_state("puppy") + .worktrees([ + WorktreeState::new_bare(), + WorktreeState::new("main").branch("main"), + ]) + .assert(); + + Ok(()) +} diff --git a/tests/convert_explicit_default_branch.rs b/tests/convert_explicit_default_branch.rs new file mode 100644 index 0000000..9554808 --- /dev/null +++ b/tests/convert_explicit_default_branch.rs @@ -0,0 +1,35 @@ +use command_error::CommandExt; +use miette::IntoDiagnostic; +use test_harness::setup_repo_multiple_remotes; +use test_harness::GitProle; +use test_harness::WorktreeState; + +#[test] +fn convert_explicit_default_branch() -> miette::Result<()> { + let prole = GitProle::new()?; + setup_repo_multiple_remotes(&prole, "my-remotes/my-repo", "my-repo")?; + + prole.sh(r#" + cd my-repo || exit + git fetch a + "#)?; + + prole + .cd_cmd("my-repo") + .args(["convert", "--default-branch", "a/a"]) + .status_checked() + .into_diagnostic()?; + + prole + .repo_state("my-repo") + .worktrees([ + WorktreeState::new_bare(), + WorktreeState::new("main") + .branch("main") + .upstream("origin/main"), + WorktreeState::new("a").branch("a").upstream("a/a"), + ]) + .assert(); + + Ok(()) +} diff --git a/tests/convert_explicit_default_branch_not_found.rs b/tests/convert_explicit_default_branch_not_found.rs new file mode 100644 index 0000000..fe05367 --- /dev/null +++ b/tests/convert_explicit_default_branch_not_found.rs @@ -0,0 +1,17 @@ +use command_error::CommandExt; +use test_harness::setup_repo_multiple_remotes; +use test_harness::GitProle; + +#[test] +fn convert_explicit_default_branch_not_found() -> miette::Result<()> { + let prole = GitProle::new()?; + setup_repo_multiple_remotes(&prole, "my-remotes/my-repo", "my-repo")?; + + prole + .cd_cmd("my-repo") + .args(["convert", "--default-branch", "d/a"]) + .status_checked() + .unwrap_err(); + + Ok(()) +} diff --git a/tests/convert_multiple_worktrees.rs b/tests/convert_multiple_worktrees.rs index 485e45e..24935e6 100644 --- a/tests/convert_multiple_worktrees.rs +++ b/tests/convert_multiple_worktrees.rs @@ -1,6 +1,7 @@ use command_error::CommandExt; use miette::IntoDiagnostic; use test_harness::GitProle; +use test_harness::WorktreeState; #[test] fn convert_multiple_worktrees() -> miette::Result<()> { @@ -8,6 +9,8 @@ fn convert_multiple_worktrees() -> miette::Result<()> { prole.setup_repo("my-repo")?; prole.sh(" + # Another path here keeps `git-prole` from using the tempdir as the root. + mkdir my-other-repo cd my-repo || exit git worktree add ../puppy git worktree add ../doggy @@ -17,9 +20,17 @@ fn convert_multiple_worktrees() -> miette::Result<()> { .cd_cmd("my-repo") .arg("convert") .status_checked() - .into_diagnostic() - // Not implemented yet! - .unwrap_err(); + .into_diagnostic()?; + + prole + .repo_state("my-repo") + .worktrees([ + WorktreeState::new_bare(), + WorktreeState::new("main").branch("main"), + WorktreeState::new("puppy").branch("puppy"), + WorktreeState::new("doggy").branch("doggy"), + ]) + .assert(); Ok(()) } diff --git a/tests/convert_no_local_default_branch.rs b/tests/convert_no_local_default_branch.rs new file mode 100644 index 0000000..f49166c --- /dev/null +++ b/tests/convert_no_local_default_branch.rs @@ -0,0 +1,36 @@ +use command_error::CommandExt; +use miette::IntoDiagnostic; +use test_harness::setup_repo_multiple_remotes; +use test_harness::GitProle; +use test_harness::WorktreeState; + +#[test] +fn convert_no_local_default_branch() -> miette::Result<()> { + let prole = GitProle::new()?; + setup_repo_multiple_remotes(&prole, "my-remotes/my-repo", "my-repo")?; + + prole.sh(r#" + cd my-repo || exit + git switch -c puppy + git branch -D main + "#)?; + + prole + .cd_cmd("my-repo") + .arg("convert") + .status_checked() + .into_diagnostic()?; + + prole + .repo_state("my-repo") + .worktrees([ + WorktreeState::new_bare(), + WorktreeState::new("main") + .branch("main") + .upstream("origin/main"), + WorktreeState::new("puppy").branch("puppy"), + ]) + .assert(); + + Ok(()) +}