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#[derive(Debug)]
19pub struct ResolveUniqueNameOpts<'a> {
20 pub worktrees: Worktrees,
22 pub names: FxHashSet<String>,
24 pub directory_names: &'a FxHashSet<&'a str>,
29}
30
31#[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
72fn 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#[derive(Debug, Clone, PartialEq, Eq)]
119pub struct RenamedWorktree {
120 pub name: String,
123 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}