1use std::path::{Path, PathBuf};
5
6use crate::git::Git;
7
8const CANDIDATE_LIMIT: usize = 5;
10
11#[derive(Debug, Clone, PartialEq, Eq)]
13pub struct Worktree {
14 pub path: PathBuf,
15 pub head: Option<String>,
16 pub bare: bool,
17 pub prunable: bool,
18}
19
20pub fn parse_list(output: &[u8]) -> Vec<Worktree> {
22 let mut worktrees = Vec::new();
23 let mut current: Option<Worktree> = None;
24 for line in output.split(|byte| *byte == b'\n') {
25 if line.is_empty() {
26 if let Some(worktree) = current.take() {
27 worktrees.push(worktree);
28 }
29 continue;
30 }
31 if let Some(path) = line.strip_prefix(b"worktree ") {
32 if let Some(worktree) = current.take() {
33 worktrees.push(worktree);
34 }
35 current = Some(Worktree {
36 path: bytes_to_path(path),
37 head: None,
38 bare: false,
39 prunable: false,
40 });
41 continue;
42 }
43 let Some(worktree) = current.as_mut() else {
44 continue;
45 };
46 if let Some(head) = line.strip_prefix(b"HEAD ") {
47 worktree.head = String::from_utf8(head.to_vec()).ok();
48 } else if line == b"bare" {
49 worktree.bare = true;
50 } else if line == b"prunable" || line.starts_with(b"prunable ") {
51 worktree.prunable = true;
52 }
53 }
54 if let Some(worktree) = current.take() {
55 worktrees.push(worktree);
56 }
57 worktrees
58}
59
60#[cfg(unix)]
61fn bytes_to_path(bytes: &[u8]) -> PathBuf {
62 use std::os::unix::ffi::OsStrExt;
63 PathBuf::from(std::ffi::OsStr::from_bytes(bytes))
64}
65
66#[cfg(not(unix))]
67fn bytes_to_path(bytes: &[u8]) -> PathBuf {
68 PathBuf::from(String::from_utf8_lossy(bytes).into_owned())
69}
70
71#[cfg(unix)]
73fn device_of(path: &Path) -> Option<u64> {
74 use std::os::unix::fs::MetadataExt;
75 std::fs::metadata(path).ok().map(|meta| meta.dev())
76}
77
78#[cfg(not(unix))]
79fn device_of(_path: &Path) -> Option<u64> {
80 None
81}
82
83fn in_preference_order(worktrees: &[Worktree], current: Option<&Path>) -> Vec<Worktree> {
86 let mut ordered: Vec<Worktree> = Vec::new();
87 let take = |worktree: &Worktree, ordered: &mut Vec<Worktree>| {
88 if !ordered.iter().any(|taken| taken.path == worktree.path) {
89 ordered.push(worktree.clone());
90 }
91 };
92 if let Some(current) = current {
93 if let Some(worktree) = worktrees.iter().find(|worktree| worktree.path == current) {
94 take(worktree, &mut ordered);
95 }
96 }
97 if let Some(main) = worktrees.first() {
98 take(main, &mut ordered);
99 }
100 let mut rest: Vec<Worktree> = worktrees
101 .iter()
102 .filter(|worktree| !ordered.iter().any(|taken| taken.path == worktree.path))
103 .cloned()
104 .collect();
105 rest.sort_by_key(|worktree| {
106 std::fs::metadata(&worktree.path)
107 .and_then(|meta| meta.modified())
108 .ok()
109 });
110 rest.reverse();
111 ordered.extend(rest);
112 ordered
113}
114
115fn include_current_checkout(worktrees: &[Worktree], current: Option<Worktree>) -> Vec<Worktree> {
118 let mut candidates = worktrees.to_vec();
119 if let Some(current) = current {
120 if !candidates
121 .iter()
122 .any(|worktree| worktree.path == current.path)
123 {
124 candidates.push(current);
125 }
126 }
127 candidates
128}
129
130pub fn choose(
136 git: &Git,
137 worktrees: &[Worktree],
138 destination: &Path,
139 target_commit: &str,
140) -> Option<PathBuf> {
141 let destination_device = destination.parent().and_then(device_of);
142 let current = git
146 .capture_line(
147 None,
148 ["rev-parse", "--path-format=absolute", "--show-toplevel"],
149 )
150 .ok()
151 .map(PathBuf::from);
152 let current_worktree = current.as_ref().and_then(|path| {
153 git.capture_line(Some(path), ["rev-parse", "HEAD"])
154 .ok()
155 .map(|head| Worktree {
156 path: path.clone(),
157 head: Some(head),
158 bare: false,
159 prunable: false,
160 })
161 });
162 let worktrees = include_current_checkout(worktrees, current_worktree);
163
164 let candidates: Vec<Worktree> = in_preference_order(&worktrees, current.as_deref())
165 .into_iter()
166 .filter(|worktree| !worktree.bare && !worktree.prunable)
167 .filter(|worktree| worktree.path != destination)
168 .filter(|worktree| worktree.path.is_dir())
169 .filter(
170 |worktree| match (destination_device, device_of(&worktree.path)) {
171 (Some(destination), Some(candidate)) => destination == candidate,
172 _ => true,
173 },
174 )
175 .take(CANDIDATE_LIMIT)
176 .collect();
177
178 candidates
179 .iter()
180 .enumerate()
181 .min_by_key(|(position, worktree)| {
182 (differing_paths(git, worktree, target_commit), *position)
183 })
184 .map(|(_, worktree)| worktree.path.clone())
185}
186
187fn differing_paths(git: &Git, worktree: &Worktree, target_commit: &str) -> usize {
189 let Some(head) = worktree.head.as_deref() else {
190 return usize::MAX;
191 };
192 match git.capture(
193 Some(&worktree.path),
194 ["diff-tree", "-r", "-z", "--name-only", head, target_commit],
195 ) {
196 Ok(output) => output.iter().filter(|byte| **byte == 0).count(),
197 Err(_) => usize::MAX,
198 }
199}
200
201#[cfg(test)]
202mod tests {
203 use super::*;
204
205 #[test]
206 fn reads_the_porcelain_listing() {
207 let output = b"worktree /repo\nHEAD abc\nbranch refs/heads/main\n\n\
208 worktree /repo/wt\nHEAD def\ndetached\n\n\
209 worktree /repo/gone\nHEAD 123\nprunable gitdir file points to non-existent location\n\n\
210 worktree /repo/bare\nbare\n\n"
211 .as_slice();
212 let worktrees = parse_list(output);
213 assert_eq!(worktrees.len(), 4);
214 assert_eq!(worktrees[0].path, PathBuf::from("/repo"));
215 assert_eq!(worktrees[0].head.as_deref(), Some("abc"));
216 assert!(!worktrees[0].bare);
217 assert!(worktrees[2].prunable);
218 assert!(worktrees[3].bare);
219 }
220
221 #[test]
222 fn prefers_the_current_checkout_then_the_main_one() {
223 let worktrees = vec![
224 Worktree {
225 path: PathBuf::from("/repo"),
226 head: None,
227 bare: false,
228 prunable: false,
229 },
230 Worktree {
231 path: PathBuf::from("/repo/a"),
232 head: None,
233 bare: false,
234 prunable: false,
235 },
236 Worktree {
237 path: PathBuf::from("/repo/b"),
238 head: None,
239 bare: false,
240 prunable: false,
241 },
242 ];
243 let ordered = in_preference_order(&worktrees, Some(Path::new("/repo/b")));
244 assert_eq!(ordered[0].path, PathBuf::from("/repo/b"));
245 assert_eq!(ordered[1].path, PathBuf::from("/repo"));
246 assert_eq!(ordered[2].path, PathBuf::from("/repo/a"));
247 }
248
249 #[test]
250 fn includes_an_absorbed_submodule_checkout_missing_from_gits_list() {
251 let listed = vec![Worktree {
252 path: PathBuf::from("/repo/.git/modules/sub"),
253 head: Some("abc".into()),
254 bare: false,
255 prunable: false,
256 }];
257 let current = Worktree {
258 path: PathBuf::from("/repo/sub"),
259 head: Some("abc".into()),
260 bare: false,
261 prunable: false,
262 };
263
264 let candidates = include_current_checkout(&listed, Some(current));
265 let ordered = in_preference_order(&candidates, Some(Path::new("/repo/sub")));
266
267 assert_eq!(ordered[0].path, PathBuf::from("/repo/sub"));
268 assert_eq!(ordered[1].path, PathBuf::from("/repo/.git/modules/sub"));
269 }
270}