1use std::fs;
9use std::path::{Path, PathBuf};
10
11use crate::config::ConfigSet;
12use crate::error::{Error, Result};
13use crate::repo::{common_git_dir_for_config, Repository};
14use crate::state::{resolve_head, HeadState};
15
16#[derive(Debug, Clone, PartialEq, Eq)]
18pub struct WorktreeEntry {
19 pub path: PathBuf,
21 pub head: HeadState,
23 pub is_bare: bool,
25 pub is_locked: bool,
27 pub lock_reason: Option<String>,
29 pub admin_dir: PathBuf,
31}
32
33#[must_use]
35pub fn common_git_dir(git_dir: &Path) -> PathBuf {
36 common_git_dir_for_config(git_dir)
37}
38
39#[must_use]
41pub fn resolve_linked_head(admin: &Path, _common: &Path) -> HeadState {
42 resolve_head(admin).unwrap_or(HeadState::Invalid)
43}
44
45#[must_use]
47pub fn registered_worktree_count(common: &Path) -> usize {
48 let worktrees_dir = common.join("worktrees");
49 if !worktrees_dir.is_dir() {
50 return 1;
51 }
52 let linked = fs::read_dir(&worktrees_dir)
53 .into_iter()
54 .flatten()
55 .flatten()
56 .filter(|e| e.file_type().map(|t| t.is_dir()).unwrap_or(false))
57 .count();
58 1 + linked
59}
60
61#[must_use]
63pub fn is_bare_repository(common: &Path) -> bool {
64 ConfigSet::load(Some(common), true)
65 .ok()
66 .and_then(|cfg| cfg.get_bool("core.bare"))
67 .and_then(|r| r.ok())
68 .unwrap_or_else(|| {
69 !common.ends_with(".git") && common.join("config").is_file()
71 })
72}
73
74pub fn list_worktrees(repo: &Repository) -> Result<Vec<WorktreeEntry>> {
78 let common = common_git_dir(&repo.git_dir);
79 let mut entries = Vec::new();
80
81 let bare = is_bare_repository(&common);
82 let main_path = if bare {
83 common.clone()
84 } else if let Some(wt) = repo.work_tree.as_ref() {
85 if repo.git_dir == common || !repo.git_dir.starts_with(common.join("worktrees")) {
87 wt.clone()
88 } else {
89 common.parent().unwrap_or(&common).to_path_buf()
90 }
91 } else {
92 common.parent().unwrap_or(&common).to_path_buf()
93 };
94
95 let main_head = resolve_head(&common).unwrap_or(HeadState::Invalid);
96 entries.push(WorktreeEntry {
97 path: main_path,
98 head: main_head,
99 is_bare: bare,
100 is_locked: false,
101 lock_reason: None,
102 admin_dir: common.clone(),
103 });
104
105 let worktrees_dir = common.join("worktrees");
106 if !worktrees_dir.is_dir() {
107 return Ok(entries);
108 }
109
110 let mut names: Vec<String> = fs::read_dir(&worktrees_dir)
111 .map_err(Error::Io)?
112 .filter_map(|e| e.ok())
113 .filter(|e| e.file_type().map(|t| t.is_dir()).unwrap_or(false))
114 .map(|e| e.file_name().to_string_lossy().into_owned())
115 .collect();
116 names.sort();
117
118 for name in names {
119 let admin = worktrees_dir.join(&name);
120 let wt_head = resolve_linked_head(&admin, &common);
121 let wt_path = read_worktree_path(&admin)?;
122 let (is_locked, lock_reason) = read_lock_state(&admin)?;
123 entries.push(WorktreeEntry {
124 path: wt_path,
125 head: wt_head,
126 is_bare: false,
127 is_locked,
128 lock_reason,
129 admin_dir: admin,
130 });
131 }
132
133 Ok(entries)
134}
135
136#[must_use]
138pub fn worktree_path_basename(path: &Path) -> String {
139 let s = path.to_string_lossy();
140 let trimmed = s.trim_end_matches(['/', '\\']);
141 trimmed
142 .rsplit(['/', '\\'])
143 .next()
144 .unwrap_or(trimmed)
145 .to_owned()
146}
147
148#[must_use]
150pub fn sanitize_worktree_id_component(name: &str) -> String {
151 if name == "@" {
152 return "-".to_owned();
153 }
154
155 let mut out = String::new();
156 let mut last = '\0';
157 let chars: Vec<char> = name.chars().collect();
158 let mut i = 0;
159 while i < chars.len() {
160 let ch = chars[i];
161 if ch.is_ascii_control()
162 || matches!(ch, ':' | '?' | '[' | '\\' | '^' | '~' | ' ' | '\t' | '*')
163 {
164 if out.is_empty() && last != '-' {
165 out.push('-');
166 } else if !out.is_empty() {
167 out.push('-');
168 }
169 last = '-';
170 i += 1;
171 continue;
172 }
173 if ch == '.' && i + 1 < chars.len() && chars[i + 1] == '.' {
174 if last == '.' {
175 out.pop();
176 } else {
177 out.push('.');
178 last = '.';
179 }
180 i += 2;
181 continue;
182 }
183 if ch == '@' && i + 1 < chars.len() && chars[i + 1] == '{' {
184 if let Some(last_ch) = out.pop() {
185 if last_ch != '-' {
186 out.push('-');
187 }
188 }
189 last = '-';
190 i += 2;
191 continue;
192 }
193 if ch == '.' && out.is_empty() {
194 out.push('-');
195 last = '-';
196 i += 1;
197 continue;
198 }
199 out.push(ch);
200 last = ch;
201 i += 1;
202 }
203
204 const LOCK_SUFFIX: &str = ".lock";
205 while out.ends_with(LOCK_SUFFIX) {
206 out.truncate(out.len() - LOCK_SUFFIX.len());
207 }
208 while out.ends_with('.') {
209 out.pop();
210 }
211 out
212}
213
214#[must_use]
218pub fn allocate_worktree_admin_dir(common: &Path, wt_path: &Path) -> PathBuf {
219 let worktrees_dir = common.join("worktrees");
220 let base = sanitize_worktree_id_component(&worktree_path_basename(wt_path));
221 let base = if base.is_empty() {
222 "worktree".to_owned()
223 } else {
224 base
225 };
226
227 let mut counter = 0u32;
228 loop {
229 let id = if counter == 0 {
230 base.clone()
231 } else {
232 format!("{base}{counter}")
233 };
234 let admin = worktrees_dir.join(&id);
235 if !admin.exists() {
236 return admin;
237 }
238 counter = counter.saturating_add(1);
239 if counter == 0 {
240 break;
241 }
242 }
243 worktrees_dir.join(format!("{base}{}", std::process::id()))
244}
245
246pub fn copy_filtered_worktree_config(source_git_dir: &Path, admin_dir: &Path) -> Result<()> {
249 let src = source_git_dir.join("config.worktree");
250 if !src.is_file() {
251 return Ok(());
252 }
253 let dst = admin_dir.join("config.worktree");
254 fs::copy(&src, &dst).map_err(Error::Io)?;
255 strip_worktree_config_keys(&dst, &["core.bare", "core.worktree"])?;
256 Ok(())
257}
258
259fn strip_worktree_config_keys(path: &Path, keys: &[&str]) -> Result<()> {
260 let content = fs::read_to_string(path).map_err(Error::Io)?;
261 let mut kept = Vec::new();
262 let mut section: Option<String> = None;
263 for line in content.lines() {
264 let trimmed = line.trim();
265 if trimmed.is_empty() || trimmed.starts_with('#') || trimmed.starts_with(';') {
266 kept.push(line);
267 continue;
268 }
269 if trimmed.starts_with('[') {
270 let end = trimmed.find(']').unwrap_or(trimmed.len());
271 let name = trimmed[1..end].trim().to_ascii_lowercase();
272 section = Some(name);
273 kept.push(line);
274 continue;
275 }
276 if let Some((key, _)) = trimmed.split_once('=') {
277 let key = key.trim().to_ascii_lowercase();
278 let full = match section.as_deref() {
279 Some(sec) => format!("{sec}.{key}"),
280 None => key.clone(),
281 };
282 if keys.iter().any(|k| full.eq_ignore_ascii_case(k)) {
283 continue;
284 }
285 } else if keys.iter().any(|k| trimmed.eq_ignore_ascii_case(k)) {
286 continue;
287 }
288 kept.push(line);
289 }
290 let mut out = kept.join("\n");
291 if !out.is_empty() {
292 out.push('\n');
293 }
294 fs::write(path, out).map_err(Error::Io)
295}
296
297pub fn read_worktree_path(admin: &Path) -> Result<PathBuf> {
299 let gitdir_path = admin.join("gitdir");
300 if !gitdir_path.is_file() {
301 return Ok(admin.to_path_buf());
302 }
303 let raw = fs::read_to_string(&gitdir_path).map_err(Error::Io)?;
304 let mut p = PathBuf::from(raw.trim());
305 if p.is_relative() {
306 p = admin.join(p);
307 }
308 let parent = p.parent().unwrap_or(&p).to_path_buf();
309 Ok(parent.canonicalize().unwrap_or(parent))
310}
311
312fn read_lock_state(admin: &Path) -> Result<(bool, Option<String>)> {
313 let locked_file = admin.join("locked");
314 if !locked_file.is_file() {
315 return Ok((false, None));
316 }
317 let content = fs::read_to_string(&locked_file).map_err(Error::Io)?;
318 let reason = content.trim();
319 if reason.is_empty() {
320 Ok((true, None))
321 } else {
322 Ok((true, Some(reason.to_owned())))
323 }
324}
325
326#[cfg(test)]
327mod tests {
328 use super::*;
329 use crate::repo::Repository;
330 use std::fs;
331 use tempfile::TempDir;
332
333 #[test]
334 fn list_main_worktree_only() {
335 let tmp = TempDir::new().unwrap();
336 let root = tmp.path().join("repo");
337 fs::create_dir_all(root.join(".git")).unwrap();
338 fs::write(root.join(".git/HEAD"), "ref: refs/heads/main\n").unwrap();
339 fs::create_dir_all(root.join(".git/objects")).unwrap();
340 fs::write(
341 root.join(".git/config"),
342 "[core]\n\trepositoryformatversion = 0\n",
343 )
344 .unwrap();
345
346 let repo = Repository::open(&root.join(".git"), Some(&root)).unwrap();
347 let list = list_worktrees(&repo).unwrap();
348 assert_eq!(list.len(), 1);
349 assert_eq!(list[0].path, root.canonicalize().unwrap());
350 assert!(!list[0].is_bare);
351 }
352
353 #[test]
354 fn allocate_unique_worktree_id() {
355 let tmp = TempDir::new().unwrap();
356 let common = tmp.path().join("git");
357 fs::create_dir_all(common.join("worktrees/here")).unwrap();
358 let admin = allocate_worktree_admin_dir(&common, Path::new("/tmp/sub/here"));
359 assert_eq!(admin, common.join("worktrees/here1"));
360 }
361
362 #[test]
363 fn strip_worktree_config_removes_core_bare_and_worktree() {
364 let tmp = TempDir::new().unwrap();
365 let path = tmp.path().join("config.worktree");
366 fs::write(
367 &path,
368 "[core]\n\tbare = true\n\tworktree = /wt\n[bogus]\n\tkey = value\n",
369 )
370 .unwrap();
371 strip_worktree_config_keys(&path, &["core.bare", "core.worktree"]).unwrap();
372 let out = fs::read_to_string(&path).unwrap();
373 assert!(out.contains("bogus"));
374 assert!(!out.contains("bare"));
375 assert!(!out.contains("worktree"));
376 }
377
378 #[test]
379 fn sanitize_funny_worktree_name() {
380 assert_eq!(
381 sanitize_worktree_id_component(". weird*..?.lock.lock"),
382 "---weird-.-"
383 );
384 }
385
386 #[test]
387 fn read_worktree_path_from_gitdir_file() {
388 let tmp = TempDir::new().unwrap();
389 let admin = tmp.path().join("wt-admin");
390 fs::create_dir_all(&admin).unwrap();
391 let wt = tmp.path().join("linked");
392 fs::create_dir_all(wt.join(".git")).unwrap();
393 fs::write(
394 admin.join("gitdir"),
395 format!("{}\n", wt.join(".git").display()),
396 )
397 .unwrap();
398 let path = read_worktree_path(&admin).unwrap();
399 assert_eq!(path, wt.canonicalize().unwrap());
400 }
401}