git_prole/git/worktree/
resolve_unique_names.rs

1use std::borrow::Cow;
2
3use camino::Utf8Path;
4use camino::Utf8PathBuf;
5use rustc_hash::FxHashMap;
6use rustc_hash::FxHashSet;
7use tracing::instrument;
8
9use crate::git::GitLike;
10use crate::AppGit;
11
12#[cfg(doc)]
13use super::GitWorktree;
14use super::Worktree;
15use super::Worktrees;
16
17/// Options for [`GitWorktree::resolve_unique_names`].
18#[derive(Debug)]
19pub struct ResolveUniqueNameOpts<'a> {
20    /// The worktrees to resolve into unique names.
21    pub worktrees: Worktrees,
22    /// A starting set of unique names that the resolved names will not conflict with.
23    pub names: FxHashSet<String>,
24    /// A set of directory names that the resolved names will not include.
25    ///
26    /// This is used to prevent worktree paths like `my-repo/my-repo` for detached `HEAD`
27    /// worktrees.
28    pub directory_names: &'a FxHashSet<&'a str>,
29}
30
31/// When we convert a repository into a worktree checkout, we put all the worktrees in one
32/// directory.
33///
34/// This means that we have to make sure all their names are unique, and we want their names to
35/// match their branches as much as possible.
36///
37/// We try the following names in order:
38///
39/// - For a bare worktree, `.git` is always used.
40/// - The last component of the worktree's branch.
41/// - The worktree's branch, with `/` replaced with `-`.
42/// - The worktree's directory name.
43/// - The worktree's directory name with numbers appended (e.g. for `puppy`, this tries `puppy-2`,
44///   `puppy-3`, etc.)
45/// - For a worktree with a detached `HEAD`, we try `work`, `work-2`, `work-3`, etc.
46///
47/// Anyways, this function resolves a bunch of worktrees into unique names.
48#[instrument(level = "trace")]
49pub fn resolve_unique_worktree_names<C>(
50    git: &AppGit<'_, C>,
51    mut opts: ResolveUniqueNameOpts<'_>,
52) -> miette::Result<FxHashMap<Utf8PathBuf, RenamedWorktree>>
53where
54    C: AsRef<Utf8Path>,
55{
56    let (mut resolved, worktrees) = handle_bare_main_worktree(&mut opts.names, opts.worktrees);
57
58    for (path, worktree) in worktrees.into_iter() {
59        let name = WorktreeNames::new(git, &worktree, opts.directory_names)
60            .names()?
61            .find(|name| !opts.names.contains(name.as_ref()))
62            .expect("There are an infinite number of possible resolved names for any worktree")
63            .into_owned();
64
65        opts.names.insert(name.clone());
66        resolved.insert(path, RenamedWorktree { name, worktree });
67    }
68
69    Ok(resolved)
70}
71
72/// If the main worktree is bare, we want to rename it to `.git`.
73///
74/// Otherwise, we want to convert the main worktree to a bare worktree, so we don't want anything
75/// else to be named `.git`.
76///
77/// This removes a bare main worktree from `worktrees` if it exists, naming it `.git`, and reserves
78/// the `.git` name otherwise.
79///
80/// Returns the set of resolved names and the remaining worktrees.
81fn handle_bare_main_worktree(
82    names: &mut FxHashSet<String>,
83    mut worktrees: Worktrees,
84) -> (
85    FxHashMap<Utf8PathBuf, RenamedWorktree>,
86    FxHashMap<Utf8PathBuf, Worktree>,
87) {
88    let mut resolved = FxHashMap::default();
89    debug_assert!(
90        !names.contains(".git"),
91        "`.git` cannot be a reserved worktree name"
92    );
93    names.insert(".git".into());
94
95    let worktrees = if worktrees.main().head.is_bare() {
96        let (path, worktree) = worktrees
97            .inner
98            .remove_entry(&worktrees.main)
99            .expect("There is always a main worktree");
100
101        resolved.insert(
102            path,
103            RenamedWorktree {
104                name: ".git".into(),
105                worktree,
106            },
107        );
108
109        worktrees.inner
110    } else {
111        worktrees.into_inner()
112    };
113
114    (resolved, worktrees)
115}
116
117/// A worktree with a new name.
118#[derive(Debug, Clone, PartialEq, Eq)]
119pub struct RenamedWorktree {
120    /// The name of the worktree; this will be the last component of the destination path when the
121    /// worktree is moved.
122    pub name: String,
123    /// The worktree itself.
124    pub worktree: Worktree,
125}
126
127struct WorktreeNames<'a, C> {
128    git: &'a AppGit<'a, C>,
129    worktree: &'a Worktree,
130    directory_names: &'a FxHashSet<&'a str>,
131}
132
133impl<'a, C> WorktreeNames<'a, C>
134where
135    C: AsRef<Utf8Path>,
136{
137    fn new(
138        git: &'a AppGit<'a, C>,
139        worktree: &'a Worktree,
140        directory_names: &'a FxHashSet<&'a str>,
141    ) -> Self {
142        Self {
143            git,
144            worktree,
145            directory_names,
146        }
147    }
148
149    fn names(&self) -> miette::Result<impl Iterator<Item = Cow<'a, str>>> {
150        Ok(self
151            .branch_last_component()
152            .chain(self.branch_full())
153            .chain(self.bare_git_dir().into_iter().flatten())
154            .chain(self.directory_name())
155            .chain(self.directory_name_numbers().into_iter().flatten())
156            .chain(self.detached_work_numbers().into_iter().flatten()))
157    }
158
159    fn maybe_directory_name(&self) -> Option<&'a str> {
160        self.worktree
161            .path
162            .file_name()
163            .filter(|name| !self.directory_names.contains(*name))
164    }
165
166    fn directory_name(&self) -> impl Iterator<Item = Cow<'a, str>> {
167        self.maybe_directory_name().map(Into::into).into_iter()
168    }
169
170    fn directory_name_numbers(&self) -> Option<impl Iterator<Item = Cow<'a, str>>> {
171        self.maybe_directory_name().map(|directory_name| {
172            (2..).map(move |number| format!("{directory_name}-{number}").into())
173        })
174    }
175
176    fn bare_git_dir(&self) -> Option<impl Iterator<Item = Cow<'a, str>>> {
177        if self.worktree.head.is_bare() {
178            Some(std::iter::once(".git".into()))
179        } else {
180            None
181        }
182    }
183
184    fn detached_work_numbers(&self) -> Option<impl Iterator<Item = Cow<'a, str>>> {
185        if self.worktree.head.is_detached() {
186            Some(
187                std::iter::once("work".into())
188                    .chain((2..).map(|number| format!("work-{number}").into())),
189            )
190        } else {
191            None
192        }
193    }
194
195    fn branch_last_component(&self) -> impl Iterator<Item = Cow<'a, str>> {
196        self.worktree
197            .head
198            .branch()
199            .map(|branch| self.git.worktree().dirname_for(branch.branch_name()))
200            .into_iter()
201    }
202
203    fn branch_full(&self) -> impl Iterator<Item = Cow<'a, str>> {
204        self.worktree
205            .head
206            .branch()
207            .map(|branch| branch.branch_name().replace('/', "-").into())
208            .into_iter()
209    }
210}
211
212#[cfg(test)]
213mod tests {
214    use expect_test::expect;
215    use expect_test::Expect;
216    use itertools::Itertools;
217
218    use crate::CommitHash;
219    use crate::Config;
220    use crate::Git;
221
222    use super::*;
223
224    struct Opts<const WS: usize, N = Option<String>, D = Option<String>> {
225        worktrees: [Worktree; WS],
226        names: N,
227        directory_names: D,
228        expect: Expect,
229    }
230
231    impl<const WS: usize, N, D> Opts<WS, N, D>
232    where
233        N: IntoIterator<Item = &'static str>,
234        D: IntoIterator<Item = &'static str>,
235    {
236        #[track_caller]
237        fn assert(mut self) {
238            let config = Config::test_stub();
239            let git = Git::from_current_dir().unwrap().with_config(&config);
240
241            self.worktrees[0].is_main = true;
242
243            let worktrees = Worktrees {
244                main: self.worktrees[0].path.clone(),
245                inner: self
246                    .worktrees
247                    .into_iter()
248                    .map(|worktree| (worktree.path.clone(), worktree))
249                    .collect::<FxHashMap<_, _>>(),
250            };
251
252            let mut worktrees = resolve_unique_worktree_names(
253                &git,
254                ResolveUniqueNameOpts {
255                    worktrees,
256                    names: self.names.into_iter().map(|name| name.to_owned()).collect(),
257                    directory_names: &self.directory_names.into_iter().collect(),
258                },
259            )
260            .unwrap()
261            .into_iter()
262            .map(|(path, renamed)| (path, renamed.name))
263            .collect::<Vec<_>>();
264
265            worktrees.sort_by_key(|(path, _name)| path.clone());
266
267            let mut worktrees_formatted = worktrees
268                .iter()
269                .map(|(path, name)| format!("{path} -> {name}"))
270                .join("\n");
271
272            if worktrees.len() > 1 {
273                worktrees_formatted.push('\n');
274            }
275
276            self.expect.assert_eq(&worktrees_formatted);
277        }
278    }
279
280    #[test]
281    fn test_resolve_unique_names_branch_last_component() {
282        Opts {
283            worktrees: [Worktree::new_branch(
284                "/softy",
285                CommitHash::fake(),
286                "doggy/puppy",
287            )],
288            expect: expect!["/softy -> puppy"],
289            names: None,
290            directory_names: None,
291        }
292        .assert();
293    }
294
295    #[test]
296    fn test_resolve_unique_names_branch_full() {
297        Opts {
298            worktrees: [Worktree::new_branch(
299                "/softy",
300                CommitHash::fake(),
301                "doggy/puppy",
302            )],
303            expect: expect!["/softy -> doggy-puppy"],
304            names: ["puppy"],
305            directory_names: None,
306        }
307        .assert();
308    }
309
310    #[test]
311    fn test_resolve_unique_names_bare_git_dir() {
312        Opts {
313            worktrees: [Worktree::new_bare("/puppy")],
314            expect: expect!["/puppy -> .git"],
315            names: None,
316            directory_names: None,
317        }
318        .assert();
319    }
320
321    #[test]
322    fn test_resolve_unique_names_directory_name() {
323        Opts {
324            worktrees: [Worktree::new_detached("/puppy", CommitHash::fake())],
325            expect: expect!["/puppy -> puppy"],
326            names: None,
327            directory_names: None,
328        }
329        .assert();
330    }
331
332    #[test]
333    fn test_resolve_unique_names_directory_name_numbers() {
334        Opts {
335            worktrees: [Worktree::new_detached("/puppy", CommitHash::fake())],
336            expect: expect!["/puppy -> puppy-2"],
337            names: ["puppy"],
338            directory_names: None,
339        }
340        .assert();
341    }
342
343    #[test]
344    fn test_resolve_unique_names_directory_name_skips_directory_names() {
345        Opts {
346            worktrees: [Worktree::new_detached("/puppy", CommitHash::fake())],
347            expect: expect!["/puppy -> work"],
348            names: None,
349            directory_names: ["puppy"],
350        }
351        .assert();
352    }
353
354    #[test]
355    fn test_resolve_unique_names_detached_work_numbers() {
356        Opts {
357            worktrees: [Worktree::new_detached("/puppy", CommitHash::fake())],
358            expect: expect!["/puppy -> work-2"],
359            names: ["work"],
360            directory_names: ["puppy"],
361        }
362        .assert();
363    }
364
365    #[test]
366    fn test_resolve_unique_names_many() {
367        Opts {
368            worktrees: [
369                Worktree::new_bare("/puppy.git"),
370                Worktree::new_detached("/puppy", CommitHash::fake()),
371                Worktree::new_detached("/silly/puppy", CommitHash::fake()),
372                Worktree::new_detached("/my-repo", CommitHash::fake()),
373                Worktree::new_detached("/silly/my-repo", CommitHash::fake()),
374                Worktree::new_branch("/a", CommitHash::fake(), "puppy/doggy"),
375                Worktree::new_branch("/b", CommitHash::fake(), "puppy/doggy"),
376                Worktree::new_branch("/c", CommitHash::fake(), "puppy/doggy"),
377                Worktree::new_branch("/d/c", CommitHash::fake(), "puppy/doggy"),
378                Worktree::new_branch("/e/c", CommitHash::fake(), "puppy/doggy"),
379                Worktree::new_branch("/f/c", CommitHash::fake(), "puppy/doggy"),
380            ],
381            expect: expect![[r#"
382                /a -> puppy-doggy
383                /b -> b
384                /c -> c
385                /d/c -> c-3
386                /e/c -> doggy
387                /f/c -> c-2
388                /my-repo -> work
389                /puppy -> puppy
390                /puppy.git -> .git
391                /silly/my-repo -> work-2
392                /silly/puppy -> puppy-2
393            "#]],
394            names: ["main"],
395            directory_names: ["my-repo"],
396        }
397        .assert();
398    }
399}