diff --git a/src/convert.rs b/src/convert.rs index 10061c0..7b72ff1 100644 --- a/src/convert.rs +++ b/src/convert.rs @@ -1,26 +1,22 @@ -use std::collections::HashSet; use std::fmt::Display; -use camino::Utf8Path; use camino::Utf8PathBuf; -use fs_err as fs; use miette::miette; -use miette::IntoDiagnostic; use owo_colors::OwoColorize; use owo_colors::Stream; -use tap::Tap; +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::topological_sort::topological_sort; use crate::utf8tempdir::Utf8TempDir; use crate::AddWorktreeOpts; -use crate::ResolvedCommitish; -use crate::UniqueWorktrees; +use crate::RenamedWorktree; use crate::Worktree; use crate::WorktreeHead; use crate::Worktrees; @@ -33,15 +29,30 @@ pub struct ConvertPlanOpts { #[derive(Debug)] pub struct ConvertPlan<'a> { + /// A Git instance in the repository to convert. git: AppGit<'a>, + /// 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 bare. + /// If this is `Some`, the main worktree is not yet bare. make_bare: Option, - destination: Utf8PathBuf, + /// 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, } @@ -58,7 +69,7 @@ impl Display for ConvertPlan<'_> { format!( "{} -> {}", NormalPath::try_display_cwd(&worktree.worktree.path), - NormalPath::try_display_cwd(&worktree.destination), + NormalPath::try_display_cwd(worktree.destination(self)), ) })) )?; @@ -72,17 +83,17 @@ impl Display for ConvertPlan<'_> { format!( "{} in {}", worktree - .branch + .start_point .qualified_branch_name() .if_supports_color(Stream::Stdout, |text| text.cyan()), - NormalPath::try_display_cwd(&worktree.destination), + NormalPath::try_display_cwd(worktree.destination(self)), ) })) )?; } if let Some(main_plan) = &self.make_bare { - if main_plan.git_dir != main_plan.git_destination { + if main_plan.git_dir() != main_plan.git_destination(self) { write!( f, "\nAdditionally, I'll move the Git directory and convert the repository to a bare repository:\n\ @@ -90,8 +101,8 @@ impl Display for ConvertPlan<'_> { format_bulleted_list_multiline([ format!( "{} -> {}", - NormalPath::try_display_cwd(&main_plan.git_dir), - NormalPath::try_display_cwd(&main_plan.git_destination), + NormalPath::try_display_cwd(main_plan.git_dir()), + NormalPath::try_display_cwd(main_plan.git_destination(self)), ) ]) )?; @@ -108,6 +119,7 @@ impl Display for ConvertPlan<'_> { } 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: // - We might already have worktrees. (`convert_multiple_worktrees`) @@ -152,80 +164,88 @@ impl<'a> ConvertPlan<'a> { let has_worktree_for_default_branch = worktrees.for_branch(&default_branch.as_local()).is_some(); - let default_branch_worktree = if has_worktree_for_default_branch { - None + let new_worktrees = if has_worktree_for_default_branch { + Vec::new() } else { - Some(( - git.worktree() - .dirname_for(default_branch.branch_name()) - .to_owned(), - default_branch, - )) - }; - - let new_worktree_names = { - let names = match &default_branch_worktree { - Some((name, _)) => HashSet::from([name.clone()]), - None => HashSet::new(), + 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())? { + (None, BranchRef::Local(remote_branch.as_local())) + } else { + 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) + } + } }; - UniqueWorktrees::new(&git, &worktrees, names)? + vec![NewWorktreePlan { + name, + create_branch, + start_point, + }] }; + let mut worktrees = git.worktree().resolve_unique_names( + worktrees, + new_worktrees + .iter() + .map(|plan| plan.name.to_owned()) + .collect(), + )?; + tracing::debug!( "Worktree names resolved:\n{}", - format_bulleted_list(new_worktree_names.iter().map(|(path, name)| { + format_bulleted_list(worktrees.iter().map(|(path, worktree)| { format!( "{} → {}", NormalPath::try_display_cwd(path), - NormalPath::try_display_cwd(destination.join(name)), + NormalPath::try_display_cwd(destination.join(&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| { - Self::worktree_plan( - &tempdir, - &destination, - &new_worktree_names, - &worktrees, - path, - ) - }) - .collect::, _>>()?; + let renamed = worktrees + .remove(&path) + .expect("Topological sort will not invent worktrees"); - let make_bare = if worktrees.main().head.is_bare() { - None - } else { - let main_worktree = worktrees.main(); - Some(MainWorktreePlan { - git_dir: main_worktree.path.join(".git"), - temp_git_destination: tempdir.join(".git"), - git_destination: destination.join(".git"), - inner: Self::worktree_plan( - &tempdir, - &destination, - &new_worktree_names, - &worktrees, - main_worktree.path.clone(), - )?, - }) - }; + let plan = WorktreePlan::from(renamed); - let new_worktrees = match default_branch_worktree { - None => Vec::new(), - Some((name, branch)) => { - vec![NewWorktreePlan { - destination: destination.join(&name), - name, - branch, - }] - } - }; + if plan.worktree.is_main && !plan.worktree.head.is_bare() { + make_bare = Some(MainWorktreePlan { + inner: plan.clone(), + }); + } - let mut ret = Self { + plan + }) + .collect::>(); + + Ok(Self { git, tempdir, destination, @@ -233,13 +253,10 @@ impl<'a> ConvertPlan<'a> { repo: repo.to_owned(), make_bare, new_worktrees, - }; - - ret.fill_steps()?; - - Ok(ret) + }) } + #[instrument(level = "trace")] fn destination_plan( worktrees: &Worktrees, opts: &ConvertPlanOpts, @@ -276,289 +293,90 @@ impl<'a> ConvertPlan<'a> { } } - fn worktree_plan( - tempdir: &Utf8Path, - destination: &Utf8Path, - names: &UniqueWorktrees, - worktrees: &Worktrees, - path: Utf8PathBuf, - ) -> miette::Result { - let worktree = worktrees - .get(&path) - .ok_or_else(|| miette!("No worktree found for {path}"))? - .clone(); - let name = names - .get(&path) - .ok_or_else(|| miette!("No destination name found for {path}"))? - .clone(); - - Ok(WorktreePlan { - temp_destination: tempdir.join(&name), - destination: destination.join(&name), - name, - worktree, - }) - } + #[instrument(level = "trace")] + pub fn execute(&self) -> miette::Result<()> { + tracing::debug!("{self}"); - fn fill_steps(&mut self) -> miette::Result<()> { - self.steps.clear(); - - // The path in the `tempdir` where we'll place the `.git` directory while we're setting up - // worktrees. - let temp_git_dir = self.tempdir.clone().tap_mut(|p| p.push(".git")); - - let main_worktree = self.worktrees.main(); - if main_worktree.head.is_bare() { - // We can move the git directory as a worktree in this case. - // If you have a worktree inside your `.git` directory you are evil and horrible. - self.steps.push(Step::MoveWorktree { - from: NormalPath::from_cwd(&main_worktree.path)?, - to: temp_git_dir.clone(), - is_main: true, - }); - } else { - self.steps.extend([ - Step::Move { - from: self - .git - .path() - .git_common_dir() - .and_then(NormalPath::from_cwd)?, - to: temp_git_dir.clone(), - }, - Step::SetConfig { - repo: temp_git_dir.clone(), - key: "core.bare".to_owned(), - value: "true".to_owned(), - }, - ]); + if self.git.config.cli.dry_run { + return Ok(()); } - // Move all worktrees to $tmp. - // - // 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! - for path in topological_sort(&self.worktrees.keys().collect::>())? { - let worktree = self - .worktrees - .get(&path) - .expect("Topological sort will not invent new worktrees"); - - // A bare worktree is the git directory! - if worktree.head.is_bare() { - continue; - } - - let name = self - .new_worktree_names - .get(&path) - .ok_or_else(|| miette!("No destination name found for worktree {worktree}"))?; - self.steps.push(Step::Move { - from: NormalPath::from_cwd(path)?, - to: self.tempdir.clone().tap_mut(|p| p.push(name)), - }); - } + // TODO: Ask the user before we start messing around with their repo layout! - // Create destination if it doesn't exist. - let destination_git_dir = NormalPath::from_cwd(self.destination.join(".git"))?; - self.steps.extend([ - Step::CreateDir { - path: NormalPath::from_cwd(&self.destination)?, - }, - Step::Move { - from: temp_git_dir.clone(), - to: destination_git_dir.clone(), - }, - ]); - - // Reassociate the main worktree if we've just converted to a bare repo. - if !main_worktree.head.is_bare() { - let name = self - .new_worktree_names - .get(&main_worktree.path) - .ok_or_else(|| miette!("No destination name found for worktree {main_worktree}"))?; - let worktree_destination = NormalPath::from_cwd(self.destination.join(name))?; - - self.steps.extend([ - Step::CreateWorktreeNoCheckout { - repo: destination_git_dir.clone(), - // TODO: Eugh! - path: worktree_destination.clone(), - commitish: main_worktree - .head - .commitish() - .expect("Only bare worktrees lack a commitish"), - }, - Step::Reset { - repo: worktree_destination.clone(), - }, - Step::Move { - from: worktree_destination.clone().tap_mut(|p| p.push(".git")), - to: self.tempdir.clone().tap_mut(|p| { - // Wow I really need a `NormalPath::join` method! - p.push(name); - p.push(".git"); - }), - }, - Step::RemoveDirectory { - path: worktree_destination.clone(), - }, - ]); + // If the repository isn't already bare, separate the `.git` directory from its worktree + // and make it bare. + 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 back from the tempdir. - for (path, worktree) in self.worktrees.iter() { - // A bare worktree is the git directory! - if worktree.head.is_bare() { - continue; - } - - let name = self - .new_worktree_names - .get(path) - .ok_or_else(|| miette!("No destination name found for worktree {worktree}"))?; - - let destination = NormalPath::from_cwd(&self.destination)?.tap_mut(|p| p.push(name)); - - self.steps.push(Step::Move { - from: self.tempdir.clone().tap_mut(|p| p.push(name)), - to: destination.clone(), - }); - self.steps.push(Step::Repair { - repo: destination_git_dir.clone(), - worktree: destination.clone(), - }); + // Move worktrees to the tempdir. + for plan in &self.worktrees { + fs::rename(&plan.worktree.path, plan.temp_destination(self))?; } - // Create new worktree for default branch if needed. - if let Some((name, branch)) = &self.default_branch_worktree { - let default_worktree_destination = - NormalPath::from_cwd(&self.destination)?.tap_mut(|p| p.push(name)); - self.steps.push(Step::CreateWorktree { - repo: destination_git_dir.clone(), - path: default_worktree_destination, - branch: branch.clone(), - }); + // Create the destination if it doesn't exist. + if !self.destination.exists() { + fs::create_dir_all(&self.destination)?; } - Ok(()) - } + // 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), + )?; - pub fn execute(&self) -> miette::Result<()> { - tracing::debug!("{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() + }, + )?; - if self.git.config.cli.dry_run { - return Ok(()); + 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))?; } - // TODO: Ask the user before we start messing around with their repo layout! + // Move worktrees back from the tempdir. + for plan in &self.worktrees { + fs::rename(plan.temp_destination(self), plan.destination(self))?; + } - 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).into_diagnostic()?; - 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).into_diagnostic()?; - } - Step::Move { from, to } => { - fs::rename(from, to).into_diagnostic()?; - } - 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.to_string()), - ..Default::default() - }, - )?; - } - Step::Reset { repo } => { - self.git.with_directory(repo.as_path().to_owned()).reset()?; - } - Step::RemoveDirectory { path } => { - fs::remove_dir(path).into_diagnostic()?; - } - Step::Repair { repo, worktree } => self - .git - .with_directory(repo.as_path().to_owned()) - .worktree() - .repair(&[worktree])?, - } + // 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!( @@ -572,138 +390,81 @@ impl<'a> ConvertPlan<'a> { } /// A plan for converting one worktree into a worktree repo. +/// +/// **Note:** This is isomorphic to [`RenamedWorktree`]. #[derive(Debug, Clone)] struct WorktreePlan { - /// Where we'll place the worktree in the temporary directory. - temp_destination: Utf8PathBuf, - /// Where we'll place the worktree when we're done. - destination: Utf8PathBuf, /// The name of the worktree; this is the last component of the destination path. name: String, /// The worktree itself. worktree: Worktree, } +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 { - /// Where we'll place the worktree when we're done. - destination: Utf8PathBuf, /// 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. - /// - /// If this is a remote branch, we'll create a corresponding local branch. - branch: BranchRef, + 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 path of the `.git` directory before meddling. - git_dir: Utf8PathBuf, - /// Where we'll place the `.git` directory in the temporary directory. - temp_git_destination: Utf8PathBuf, - /// Where we'll place the `.git` directory when we're done. - git_destination: Utf8PathBuf, /// The plan for the main worktree. inner: WorktreePlan, } -#[derive(Debug, Clone)] -enum Step { - Move { - from: NormalPath, - to: NormalPath, - }, - SetConfig { - repo: NormalPath, - key: String, - value: String, - }, - CreateWorktreeNoCheckout { - repo: NormalPath, - path: NormalPath, - commitish: ResolvedCommitish, - }, - Reset { - repo: NormalPath, - }, - RemoveDirectory { - path: NormalPath, - }, - /// Will be needed for multiple worktree support. - MoveWorktree { - from: NormalPath, - to: NormalPath, - is_main: bool, - }, - CreateDir { - path: NormalPath, - }, - CreateWorktree { - repo: NormalPath, - path: NormalPath, - branch: BranchRef, - }, - Repair { - repo: NormalPath, - worktree: NormalPath, - }, -} +impl MainWorktreePlan { + /// The path of the `.git` directory before meddling. + fn git_dir(&self) -> Utf8PathBuf { + self.inner.worktree.path.join(".git") + } -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 worktree {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}") - } - Step::Repair { repo: _, worktree } => write!(f, "Repair worktree {worktree}"), - } + /// 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/mod.rs b/src/git/mod.rs index 55df111..0169f73 100644 --- a/src/git/mod.rs +++ b/src/git/mod.rs @@ -38,7 +38,7 @@ pub use status::StatusCode; pub use status::StatusEntry; pub use worktree::AddWorktreeOpts; pub use worktree::GitWorktree; -pub use worktree::UniqueWorktrees; +pub use worktree::RenamedWorktree; pub use worktree::Worktree; pub use worktree::WorktreeHead; pub use worktree::Worktrees; diff --git a/src/git/worktree/mod.rs b/src/git/worktree/mod.rs index 343c15c..fee5770 100644 --- a/src/git/worktree/mod.rs +++ b/src/git/worktree/mod.rs @@ -1,3 +1,6 @@ +use std::collections::HashMap; +use std::collections::HashSet; +use std::ffi::OsStr; use std::fmt::Debug; use std::process::Command; @@ -15,14 +18,14 @@ use winnow::Parser; use super::Git; use super::LocalBranchRef; -mod uniquify; +mod resolve_unique_names; mod parse; pub use parse::Worktree; pub use parse::WorktreeHead; pub use parse::Worktrees; -pub use uniquify::UniqueWorktrees; +pub use resolve_unique_names::RenamedWorktree; /// Git methods for dealing with worktrees. #[repr(transparent)] @@ -149,7 +152,10 @@ impl<'a> GitWorktree<'a> { } #[instrument(level = "trace")] - pub fn repair(&self, paths: &[&Utf8Path]) -> miette::Result<()> { + pub fn repair( + &self, + paths: impl IntoIterator> + Debug, + ) -> miette::Result<()> { self.0 .command() .args(["worktree", "repair"]) @@ -182,6 +188,16 @@ impl<'a> GitWorktree<'a> { .container()? .tap_mut(|p| p.push(self.dirname_for(branch)))) } + + /// Resolves a set of worktrees into a map from worktree paths to unique names. + #[instrument(level = "trace")] + pub fn resolve_unique_names( + &self, + worktrees: Worktrees, + names: HashSet, + ) -> miette::Result> { + resolve_unique_names::resolve_unique_worktree_names(self.0, worktrees, names) + } } /// Options for `git worktree add`. diff --git a/src/git/worktree/parse.rs b/src/git/worktree/parse.rs index a76ea92..f5f9c19 100644 --- a/src/git/worktree/parse.rs +++ b/src/git/worktree/parse.rs @@ -53,6 +53,10 @@ impl Worktrees { self.inner.remove(&self.main).unwrap() } + pub fn into_inner(self) -> HashMap { + self.inner + } + pub fn for_branch(&self, branch: &LocalBranchRef) -> Option<&Worktree> { self.iter() .map(|(_path, worktree)| worktree) diff --git a/src/git/worktree/resolve_unique_names.rs b/src/git/worktree/resolve_unique_names.rs new file mode 100644 index 0000000..0043d8f --- /dev/null +++ b/src/git/worktree/resolve_unique_names.rs @@ -0,0 +1,136 @@ +use std::borrow::Cow; +use std::collections::HashMap; +use std::collections::HashSet; + +use camino::Utf8PathBuf; +use miette::miette; +use tracing::instrument; + +use crate::Git; + +use super::Worktree; +use super::Worktrees; + +/// When we convert a repository into a worktree checkout, we put all the worktrees in one +/// directory. +/// +/// This means that we have to make sure all their names are unique, and we want their names to +/// match their branches as much as possible. +/// +/// - For worktrees with a detached `HEAD`, we'll use the existing directory name. +/// We can maybe also check which branches the checked out commit is on? +/// - We also only use the last component of a branch. If names aren't unique, we can use previous +/// components from the branch as well. +/// - Alternatively, we can check the upstream names (different remotes / different branches). +/// +/// We also might want to strip common prefixes from the directory names? When I had worktrees in +/// the same directory as the rest of my repository checkouts, they would often have a suffix, +/// like `my-repo` and `my-repo-feature1`. +/// +/// And what about bare repository checkouts? +/// +/// Anyways, this function resolves a bunch of worktrees into unique names. +/// +/// # Parameters +/// +/// - `names`: A starting set of unique names that the resolved names will not conflict with. +#[instrument(level = "trace")] +pub fn resolve_unique_worktree_names( + git: &Git, + worktrees: Worktrees, + mut names: HashSet, +) -> miette::Result> { + let mut resolved = HashMap::new(); + + for (path, worktree) in worktrees.into_inner().into_iter() { + let name = WorktreeNames::new(git, &worktree) + .names()? + .find(|name| !names.contains(name.as_ref())) + .expect("There are an infinite number of possible resolved names for any worktree") + .into_owned(); + + names.insert(name.clone()); + resolved.insert(path, RenamedWorktree { name, worktree }); + } + + Ok(resolved) +} + +/// A worktree with a new name. +pub struct RenamedWorktree { + /// The name of the worktree; this will be the last component of the destination path when the + /// worktree is moved. + pub name: String, + /// The worktree itself. + pub worktree: Worktree, +} + +struct WorktreeNames<'a> { + git: &'a Git, + worktree: &'a Worktree, +} + +impl<'a> WorktreeNames<'a> { + fn new(git: &'a Git, worktree: &'a Worktree) -> Self { + Self { git, worktree } + } + + fn names(&self) -> miette::Result>> { + Ok(self + .branch_last_component() + .chain(self.branch_full()) + .chain(self.bare_git_dir().into_iter().flatten()) + .chain(self.detached_work_numbers().into_iter().flatten()) + .chain(self.directory_name()) + .chain( + self.directory_name_numbers().ok_or_else(|| { + miette!("Worktree path has no basename: {}", self.worktree.path) + })?, + )) + } + + fn directory_name(&self) -> impl Iterator> { + self.worktree.path.file_name().map(Into::into).into_iter() + } + + fn directory_name_numbers(&self) -> Option>> { + self.worktree.path.file_name().map(|directory_name| { + (2..).map(move |number| format!("{directory_name}-{number}").into()) + }) + } + + fn bare_git_dir(&self) -> Option>> { + if self.worktree.head.is_bare() { + Some(std::iter::once(".git".into())) + } else { + None + } + } + + fn detached_work_numbers(&self) -> Option>> { + if self.worktree.head.is_detached() { + Some( + std::iter::once("work".into()) + .chain((2..).map(|number| format!("work-{number}").into())), + ) + } else { + None + } + } + + fn branch_last_component(&self) -> impl Iterator> { + self.worktree + .head + .branch() + .map(|branch| self.git.worktree().dirname_for(branch.branch_name()).into()) + .into_iter() + } + + fn branch_full(&self) -> impl Iterator> { + self.worktree + .head + .branch() + .map(|branch| branch.branch_name().replace('/', "-").into()) + .into_iter() + } +} diff --git a/src/git/worktree/uniquify.rs b/src/git/worktree/uniquify.rs deleted file mode 100644 index 5611579..0000000 --- a/src/git/worktree/uniquify.rs +++ /dev/null @@ -1,138 +0,0 @@ -use std::borrow::Cow; -use std::collections::HashMap; -use std::collections::HashSet; -use std::ops::Deref; - -use camino::Utf8PathBuf; -use miette::miette; - -use crate::Git; - -use super::Worktree; -use super::Worktrees; - -#[derive(Clone, Debug)] -pub struct UniqueWorktrees { - /// A map from worktree paths to names. - inner: HashMap, - /// A set of worktree names, which may include entries not listed in `inner`. - names: HashSet, -} - -impl UniqueWorktrees { - /// When we convert a repository into a worktree checkout, we put all the worktrees in one - /// directory. - /// - /// This means that we have to make sure all their names are unique, and we want their names to - /// match their branches as much as possible. - /// - /// - For worktrees with a detached `HEAD`, we'll use the existing directory name. - /// We can maybe also check which branches the checked out commit is on? - /// - We also only use the last component of a branch. If names aren't unique, we can use previous - /// components from the branch as well. - /// - Alternatively, we can check the upstream names (different remotes / different branches). - /// - /// We also might want to strip common prefixes from the directory names? When I had worktrees in - /// the same directory as the rest of my repository checkouts, they would often have a suffix, - /// like `my-repo` and `my-repo-feature1`. - /// - /// And what about bare repository checkouts? - /// - /// Anyways, this function resolves a bunch of worktrees into unique names. - pub fn new( - git: &Git, - worktrees: &Worktrees, - mut names: HashSet, - ) -> miette::Result { - let mut inner = HashMap::new(); - - for (_path, worktree) in worktrees.iter() { - let mut inserted = false; - - for name in WorktreeNames::new(git, worktree).names()? { - if !names.contains(name.as_ref()) { - let name = name.into_owned(); - names.insert(name.clone()); - inner.insert(worktree.path.clone(), name); - inserted = true; - break; - } - } - - if !inserted { - unreachable!() - } - } - - Ok(Self { inner, names }) - } -} - -impl Deref for UniqueWorktrees { - type Target = HashMap; - - fn deref(&self) -> &Self::Target { - &self.inner - } -} - -struct WorktreeNames<'a> { - git: &'a Git, - worktree: &'a Worktree, -} - -impl<'a> WorktreeNames<'a> { - fn new(git: &'a Git, worktree: &'a Worktree) -> Self { - Self { git, worktree } - } - - fn names(&self) -> miette::Result>> { - Ok(self - .branch_last_component() - .chain(self.branch_full()) - .chain(self.detached_work_numbers().into_iter().flatten()) - .chain(self.directory_name()) - .chain( - self.directory_name_numbers().ok_or_else(|| { - miette!("Worktree path has no basename: {}", self.worktree.path) - })?, - )) - } - - fn directory_name(&self) -> impl Iterator> { - self.worktree.path.file_name().map(Into::into).into_iter() - } - - fn directory_name_numbers(&self) -> Option>> { - self.worktree.path.file_name().map(|directory_name| { - (2..).map(move |number| format!("{directory_name}-{number}").into()) - }) - } - - fn detached_work_numbers(&self) -> Option>> { - if self.worktree.head.is_detached() { - Some( - std::iter::once("work".into()) - .chain((2..).map(|number| format!("work-{number}").into())), - ) - } else { - None - } - } - - fn branch_last_component(&self) -> impl Iterator> { - self.worktree - .head - .branch() - .map(|branch| self.git.worktree().dirname_for(branch.branch_name()).into()) - .into_iter() - } - - fn branch_full(&self) -> impl Iterator> { - self.worktree - .head - .branch() - .map(|branch| branch.branch_name().replace('/', "-").into()) - .into_iter() - } -} diff --git a/src/lib.rs b/src/lib.rs index 2f84fcb..7a4240e 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -45,11 +45,11 @@ pub use git::HeadKind; pub use git::LocalBranchRef; pub use git::Ref; pub use git::RemoteBranchRef; +pub use git::RenamedWorktree; pub use git::ResolvedCommitish; pub use git::Status; pub use git::StatusCode; pub use git::StatusEntry; -pub use git::UniqueWorktrees; pub use git::Worktree; pub use git::WorktreeHead; pub use git::Worktrees;