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
115pub fn choose(
121 git: &Git,
122 worktrees: &[Worktree],
123 destination: &Path,
124 target_commit: &str,
125) -> Option<PathBuf> {
126 let destination_device = destination.parent().and_then(device_of);
127 let current = std::env::current_dir().ok().and_then(|cwd| {
128 git.capture_line(
129 Some(&cwd),
130 ["rev-parse", "--path-format=absolute", "--show-toplevel"],
131 )
132 .ok()
133 .map(PathBuf::from)
134 });
135
136 let candidates: Vec<Worktree> = in_preference_order(worktrees, current.as_deref())
137 .into_iter()
138 .filter(|worktree| !worktree.bare && !worktree.prunable)
139 .filter(|worktree| worktree.path != destination)
140 .filter(|worktree| worktree.path.is_dir())
141 .filter(
142 |worktree| match (destination_device, device_of(&worktree.path)) {
143 (Some(destination), Some(candidate)) => destination == candidate,
144 _ => true,
145 },
146 )
147 .take(CANDIDATE_LIMIT)
148 .collect();
149
150 candidates
151 .iter()
152 .enumerate()
153 .min_by_key(|(position, worktree)| {
154 (differing_paths(git, worktree, target_commit), *position)
155 })
156 .map(|(_, worktree)| worktree.path.clone())
157}
158
159fn differing_paths(git: &Git, worktree: &Worktree, target_commit: &str) -> usize {
161 let Some(head) = worktree.head.as_deref() else {
162 return usize::MAX;
163 };
164 match git.capture(
165 Some(&worktree.path),
166 ["diff-tree", "-r", "-z", "--name-only", head, target_commit],
167 ) {
168 Ok(output) => output.iter().filter(|byte| **byte == 0).count(),
169 Err(_) => usize::MAX,
170 }
171}
172
173#[cfg(test)]
174mod tests {
175 use super::*;
176
177 #[test]
178 fn reads_the_porcelain_listing() {
179 let output = b"worktree /repo\nHEAD abc\nbranch refs/heads/main\n\n\
180 worktree /repo/wt\nHEAD def\ndetached\n\n\
181 worktree /repo/gone\nHEAD 123\nprunable gitdir file points to non-existent location\n\n\
182 worktree /repo/bare\nbare\n\n"
183 .as_slice();
184 let worktrees = parse_list(output);
185 assert_eq!(worktrees.len(), 4);
186 assert_eq!(worktrees[0].path, PathBuf::from("/repo"));
187 assert_eq!(worktrees[0].head.as_deref(), Some("abc"));
188 assert!(!worktrees[0].bare);
189 assert!(worktrees[2].prunable);
190 assert!(worktrees[3].bare);
191 }
192
193 #[test]
194 fn prefers_the_current_checkout_then_the_main_one() {
195 let worktrees = vec![
196 Worktree {
197 path: PathBuf::from("/repo"),
198 head: None,
199 bare: false,
200 prunable: false,
201 },
202 Worktree {
203 path: PathBuf::from("/repo/a"),
204 head: None,
205 bare: false,
206 prunable: false,
207 },
208 Worktree {
209 path: PathBuf::from("/repo/b"),
210 head: None,
211 bare: false,
212 prunable: false,
213 },
214 ];
215 let ordered = in_preference_order(&worktrees, Some(Path::new("/repo/b")));
216 assert_eq!(ordered[0].path, PathBuf::from("/repo/b"));
217 assert_eq!(ordered[1].path, PathBuf::from("/repo"));
218 assert_eq!(ordered[2].path, PathBuf::from("/repo/a"));
219 }
220}