Skip to main content

git_sprout/
source.rs

1// ABOUTME: Picks which existing checkout a new worktree is grown from.
2// ABOUTME: Candidates come from `git worktree list`; the winner is the closest commit.
3
4use std::path::{Path, PathBuf};
5
6use crate::git::Git;
7
8/// How many candidates are worth scoring. Beyond this the scoring costs more than it saves.
9const CANDIDATE_LIMIT: usize = 5;
10
11/// One entry of `git worktree list --porcelain`.
12#[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
20/// Parses `git worktree list --porcelain`.
21pub 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/// The device a path lives on, where the platform reports one.
72#[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
83/// Orders the candidates the way ties should be broken: the checkout the command was run
84/// from, then the main worktree, then the rest, most recently modified first.
85fn 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
115/// Include the checkout the command is running from when Git's worktree list
116/// reports only its common git directory, as happens for absorbed submodules.
117fn 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
130/// Chooses the checkout to clone from, or `None` when none can serve.
131///
132/// Only worktrees of the same repository on the same device qualify, because a block
133/// clone cannot cross volumes. Among those, the one whose HEAD differs from the target
134/// commit in the fewest paths wins, since every differing path is one git has to write.
135pub 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    // Resolved with no directory of our own, exactly as the worktree listing was, so a
143    // caller's `-C` lands both in the same repository. A toplevel from somewhere else
144    // would name a checkout holding nothing the destination could clone.
145    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
187/// How many paths the candidate's HEAD differs from the target commit in.
188fn 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}