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/// Chooses the checkout to clone from, or `None` when none can serve.
116///
117/// Only worktrees of the same repository on the same device qualify, because a block
118/// clone cannot cross volumes. Among those, the one whose HEAD differs from the target
119/// commit in the fewest paths wins, since every differing path is one git has to write.
120pub 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
159/// How many paths the candidate's HEAD differs from the target commit in.
160fn 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}