1use serde::{Deserialize, Serialize};
4use std::{
5 ffi::OsStr,
6 path::{Path, PathBuf},
7};
8
9#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
10#[serde(rename_all = "camelCase")]
11pub struct Identity {
12 #[serde(
13 default,
14 skip_serializing_if = "Option::is_none",
15 with = "crate::platform::path_serde::option"
16 )]
17 pub common_dir: Option<PathBuf>,
18 #[serde(
19 default,
20 skip_serializing_if = "Option::is_none",
21 with = "crate::platform::path_serde::option"
22 )]
23 pub worktree_path: Option<PathBuf>,
24 #[serde(default, skip_serializing_if = "Option::is_none")]
25 pub branch: Option<String>,
26}
27
28fn git(path: &Path, args: &[&str]) -> Option<Vec<u8>> {
29 let args = [OsStr::new("-C"), path.as_os_str()]
30 .into_iter()
31 .chain(args.iter().map(OsStr::new));
32 crate::process::tool_output("git", args)
33}
34
35fn git_path(path: &Path, arg: &str) -> Option<PathBuf> {
36 let output = git(path, &["rev-parse", "--path-format=absolute", arg])?;
37 let path = crate::platform::path_from_line(&output);
39 crate::platform::canonicalize(&path).ok()
40}
41
42pub fn identify(path: &Path) -> Identity {
44 let common_dir = git_path(path, "--git-common-dir");
45 let worktree_path = git_path(path, "--show-toplevel");
46 if common_dir.is_none() || worktree_path.is_none() {
47 return Identity::default();
48 }
49 let branch = git(path, &["symbolic-ref", "--quiet", "--short", "HEAD"])
50 .and_then(|bytes| String::from_utf8(bytes).ok())
51 .map(|text| text.trim_end_matches(['\r', '\n']).to_owned());
52 Identity {
53 common_dir,
54 worktree_path,
55 branch,
56 }
57}
58
59#[derive(Debug, Clone, PartialEq, Eq)]
60pub struct Worktree {
61 pub path: PathBuf,
62 pub branch: Option<String>,
63}
64
65pub fn worktrees(common_dir: &Path) -> Vec<Worktree> {
69 let args = [OsStr::new("--git-dir"), common_dir.as_os_str()]
70 .into_iter()
71 .chain(["worktree", "list", "--porcelain", "-z"].map(OsStr::new));
72 crate::process::tool_output("git", args)
73 .map(|output| parse_worktrees(&output))
74 .unwrap_or_default()
75 .into_iter()
76 .filter(|tree| tree.path.try_exists().unwrap_or(false))
77 .collect()
78}
79
80fn parse_worktrees(bytes: &[u8]) -> Vec<Worktree> {
81 let mut trees: Vec<(Worktree, bool)> = Vec::new();
82 for field in bytes.split(|byte| *byte == 0) {
83 if let Some(path) = field.strip_prefix(b"worktree ") {
84 let path = crate::platform::path_from_bytes(path);
85 trees.push((Worktree { path, branch: None }, false));
86 } else if let Some((tree, _)) = trees.last_mut()
87 && let Some(branch) = field.strip_prefix(b"branch refs/heads/")
88 {
89 tree.branch = Some(String::from_utf8_lossy(branch).into_owned());
90 } else if let Some((_, prunable)) = trees.last_mut()
91 && (field == b"prunable" || field.starts_with(b"prunable "))
92 {
93 *prunable = true;
94 }
95 }
96 trees
97 .into_iter()
98 .filter_map(|(tree, prunable)| (!prunable).then_some(tree))
99 .collect()
100}
101
102#[cfg(test)]
103mod tests {
104 use super::*;
105
106 #[test]
107 fn git_identity_preserves_supported_path_characters() {
108 let root = crate::test_os::tempdir();
109 for path in crate::test_os::git_path_cases(root.path()) {
110 crate::test_os::git(&path, &["init", "--quiet"]);
111 let identity = identify(&path);
112 assert_eq!(
113 identity.common_dir,
114 Some(crate::test_os::canonical(&path.join(".git"))),
115 "{path:?}"
116 );
117 assert_eq!(
118 identity.worktree_path,
119 Some(crate::test_os::canonical(&path)),
120 "{path:?}"
121 );
122 }
123 }
124
125 #[test]
126 fn porcelain_paths_are_not_split_on_whitespace() {
127 let root = crate::test_os::tempdir();
128 let named = root.path().join("a space\nline");
130 let detached = root.path().join("detached");
131 let wire = format!(
132 "worktree {}\0HEAD abc\0branch refs/heads/main\0\0worktree {}\0HEAD def\0detached\0\0",
133 crate::platform::path_text(&named),
134 crate::platform::path_text(&detached),
135 );
136 let trees = parse_worktrees(wire.as_bytes());
137 assert_eq!(trees[0].path, named);
138 assert_eq!(trees[0].branch.as_deref(), Some("main"));
139 assert_eq!(trees[1].path, detached);
140 assert_eq!(trees[1].branch, None);
141 }
142
143 #[test]
144 fn prunable_worktrees_are_not_listed() {
145 let wire = "worktree /work/main\0HEAD abc\0branch refs/heads/main\0\0worktree /work/gone\0HEAD def\0branch refs/heads/gone\0prunable gitdir file points to non-existent location\0\0";
146 let trees = parse_worktrees(wire.as_bytes());
147 assert_eq!(trees.len(), 1);
148 assert_eq!(trees[0].branch.as_deref(), Some("main"));
149 }
150
151 #[test]
152 fn a_removed_worktree_folder_is_not_listed() {
153 let root = crate::test_os::tempdir();
154 let main = root.path().join("main");
155 std::fs::create_dir(&main).unwrap();
156 crate::test_os::git(&main, &["init", "--quiet", "--initial-branch=main"]);
157 crate::test_os::git(
158 &main,
159 &[
160 "-c",
161 "user.name=artifactize",
162 "-c",
163 "user.email=artifactize@example.invalid",
164 "commit",
165 "--quiet",
166 "--allow-empty",
167 "-m",
168 "first",
169 ],
170 );
171 let other = root.path().join("other");
172 crate::test_os::git(
173 &main,
174 &[
175 "worktree",
176 "add",
177 "--quiet",
178 "-b",
179 "other",
180 &crate::platform::path_text(&other),
181 ],
182 );
183 let common = crate::test_os::canonical(&main.join(".git"));
184 let branches = |trees: Vec<Worktree>| -> Vec<Option<String>> {
185 trees.into_iter().map(|tree| tree.branch).collect()
186 };
187 assert_eq!(
188 branches(worktrees(&common)),
189 [Some("main".into()), Some("other".into())]
190 );
191 std::fs::remove_dir_all(&other).unwrap();
192 assert_eq!(branches(worktrees(&common)), [Some("main".into())]);
193 }
194}