1use std::path::{Path, PathBuf};
19
20use schemars::JsonSchema;
21use serde::{Deserialize, Serialize};
22
23use super::error::{Result, WorktreeError};
24use super::git;
25use super::topology::{self, WorktreeRecord};
26
27#[derive(Debug, Clone, PartialEq, Eq)]
29pub struct ResolvedPath {
30 pub path: PathBuf,
32 pub real: PathBuf,
34}
35
36impl ResolvedPath {
37 pub fn of(path: impl Into<PathBuf>) -> Self {
42 let path = path.into();
43 let real = std::fs::canonicalize(&path).unwrap_or_else(|_| partial_canonical(&path));
44 ResolvedPath { path, real }
45 }
46
47 pub fn same_as(&self, other: &ResolvedPath) -> bool {
49 self.real == other.real
50 }
51
52 pub fn is_inside(&self, root: &ResolvedPath) -> bool {
57 self.real != root.real && self.real.starts_with(&root.real)
58 }
59}
60
61fn partial_canonical(p: &Path) -> PathBuf {
66 let normalised = lexical(p);
67 let mut existing = normalised.clone();
68 let mut rest: Vec<std::ffi::OsString> = Vec::new();
69 loop {
70 if let Ok(real) = std::fs::canonicalize(&existing) {
71 let mut out = real;
72 for c in rest.iter().rev() {
73 out.push(c);
74 }
75 return out;
76 }
77 match (existing.file_name(), existing.parent()) {
78 (Some(name), Some(parent)) if !parent.as_os_str().is_empty() => {
79 rest.push(name.to_os_string());
80 existing = parent.to_path_buf();
81 }
82 _ => return normalised,
83 }
84 }
85}
86
87fn lexical(p: &Path) -> PathBuf {
90 let mut out = PathBuf::new();
91 for c in p.components() {
92 match c {
93 std::path::Component::CurDir => {}
94 std::path::Component::ParentDir => {
95 let at_root = out.parent().is_none() && out.has_root();
96 if !at_root && !out.pop() {
97 out.push("..");
98 }
99 }
100 other => out.push(other.as_os_str()),
101 }
102 }
103 out
104}
105
106#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
108#[serde(rename_all = "snake_case")]
109pub enum TrunkSource {
110 RemoteHead,
112 DefaultBranchConfig,
114 ConventionalName,
116 PrimaryCheckout,
118 Unknown,
120}
121
122#[derive(Debug, Clone, PartialEq, Eq)]
125pub struct Trunk {
126 pub branch: Option<String>,
128 pub source: TrunkSource,
130}
131
132impl Trunk {
133 pub fn is(&self, branch: &str) -> bool {
135 self.branch.as_deref() == Some(branch)
136 }
137}
138
139#[derive(Debug, Clone)]
144pub struct RepositoryIdentity {
145 git_common_dir: ResolvedPath,
146 primary: ResolvedPath,
147 current: ResolvedPath,
148 records: Vec<WorktreeRecord>,
149 trunk: Trunk,
150}
151
152impl RepositoryIdentity {
153 pub fn discover(start: &Path) -> Result<Self> {
155 let probe = git::try_run(
156 start,
157 &["rev-parse", "--is-bare-repository", "--git-common-dir"],
158 )?;
159 if probe.status != Some(0) {
160 return Err(WorktreeError::NotInGitRepository {
161 start: start.to_path_buf(),
162 });
163 }
164 let text = probe.text()?;
165 let mut lines = text.lines();
166 let bare = lines.next().unwrap_or("false").trim() == "true";
167 let common_raw = lines.next().unwrap_or("").trim().to_string();
168 if common_raw.is_empty() {
169 return Err(WorktreeError::NotInGitRepository {
170 start: start.to_path_buf(),
171 });
172 }
173 let common = {
176 let p = PathBuf::from(&common_raw);
177 if p.is_absolute() {
178 p
179 } else {
180 start.join(p)
181 }
182 };
183 let git_common_dir = ResolvedPath::of(common);
184 if bare {
185 return Err(WorktreeError::BareRepositoryUnsupported {
186 git_dir: git_common_dir.path.clone(),
187 });
188 }
189
190 let top = git::run(start, &["rev-parse", "--show-toplevel"])?.text()?;
193 let current = ResolvedPath::of(PathBuf::from(top));
194
195 let records = topology::read(start)?;
196 let primary = ResolvedPath::of(topology::primary(&records, &git_common_dir.path)?);
197 let trunk = detect_trunk(&primary.path, &records)?;
198 Ok(RepositoryIdentity {
199 git_common_dir,
200 primary,
201 current,
202 records,
203 trunk,
204 })
205 }
206
207 pub fn refresh(&mut self) -> Result<()> {
210 self.records = topology::read(&self.primary.path)?;
211 Ok(())
212 }
213
214 pub fn git_common_dir(&self) -> &ResolvedPath {
217 &self.git_common_dir
218 }
219
220 pub fn primary_worktree(&self) -> &ResolvedPath {
223 &self.primary
224 }
225
226 pub fn current_worktree(&self) -> &ResolvedPath {
228 &self.current
229 }
230
231 pub fn trunk(&self) -> &Trunk {
233 &self.trunk
234 }
235
236 pub fn repository_name(&self) -> String {
238 self.primary
239 .path
240 .file_name()
241 .map(|n| n.to_string_lossy().to_string())
242 .unwrap_or_default()
243 }
244
245 pub fn registered_worktrees(&self) -> &[WorktreeRecord] {
248 &self.records
249 }
250
251 pub fn is_primary(&self, record: &WorktreeRecord) -> bool {
253 ResolvedPath::of(&record.path).same_as(&self.primary)
254 }
255
256 pub fn is_current(&self, record: &WorktreeRecord) -> bool {
258 ResolvedPath::of(&record.path).same_as(&self.current)
259 }
260
261 pub fn current_record(&self) -> Option<&WorktreeRecord> {
263 self.records.iter().find(|r| self.is_current(r))
264 }
265
266 pub fn record_of_branch(&self, branch: &str) -> Option<&WorktreeRecord> {
268 self.records
269 .iter()
270 .find(|r| r.branch.as_deref() == Some(branch))
271 }
272
273 pub fn record_at(&self, path: &ResolvedPath) -> Option<&WorktreeRecord> {
275 self.records
276 .iter()
277 .find(|r| ResolvedPath::of(&r.path).same_as(path))
278 }
279}
280
281pub fn detect_trunk(primary: &Path, records: &[WorktreeRecord]) -> Result<Trunk> {
285 let remotes = git::try_run(primary, &["remote"])?;
287 if remotes.status == Some(0) {
288 let text = remotes.text()?;
289 for remote in text.lines().map(str::trim).filter(|r| !r.is_empty()) {
290 let head = git::try_run(
291 primary,
292 &["symbolic-ref", "-q", &format!("refs/remotes/{remote}/HEAD")],
293 )?;
294 if head.status == Some(0) {
295 let full = head.text()?;
296 if let Some(short) = full.strip_prefix(&format!("refs/remotes/{remote}/")) {
297 if !short.is_empty() {
298 return Ok(Trunk {
299 branch: Some(short.to_string()),
300 source: TrunkSource::RemoteHead,
301 });
302 }
303 }
304 }
305 }
306 }
307 let configured = git::try_run(primary, &["config", "--get", "init.defaultBranch"])?;
309 if configured.status == Some(0) {
310 let name = configured.text()?;
311 if !name.is_empty() && git::branch_exists(primary, &name)? {
312 return Ok(Trunk {
313 branch: Some(name),
314 source: TrunkSource::DefaultBranchConfig,
315 });
316 }
317 }
318 let conventional: Vec<&str> = ["main", "master"]
320 .into_iter()
321 .filter(|b| git::branch_exists(primary, b).unwrap_or(false))
322 .collect();
323 if conventional.len() == 1 {
324 return Ok(Trunk {
325 branch: Some(conventional[0].to_string()),
326 source: TrunkSource::ConventionalName,
327 });
328 }
329 if let Some(branch) = records.first().and_then(|r| r.branch.clone()) {
331 return Ok(Trunk {
332 branch: Some(branch),
333 source: TrunkSource::PrimaryCheckout,
334 });
335 }
336 Ok(Trunk {
337 branch: None,
338 source: TrunkSource::Unknown,
339 })
340}
341
342#[cfg(test)]
343mod tests {
344 use super::*;
345
346 #[test]
347 fn lexical_normalisation_resolves_dot_and_dotdot_without_the_filesystem() {
348 assert_eq!(lexical(Path::new("/a/b/../c/./d")), PathBuf::from("/a/c/d"));
349 assert_eq!(lexical(Path::new("/a/../..")), PathBuf::from("/"));
350 assert_eq!(lexical(Path::new("../x")), PathBuf::from("../x"));
351 }
352
353 #[test]
354 fn containment_is_decided_on_the_resolved_form_and_excludes_the_root_itself() {
355 let root = ResolvedPath {
356 path: "/a/foo-wt".into(),
357 real: "/a/foo-wt".into(),
358 };
359 let inside = ResolvedPath {
360 path: "/a/foo-wt/feature/x".into(),
361 real: "/a/foo-wt/feature/x".into(),
362 };
363 let escaped = ResolvedPath {
364 path: "/a/foo-wt/link".into(),
365 real: "/tmp/elsewhere".into(),
366 };
367 assert!(inside.is_inside(&root));
368 assert!(!root.is_inside(&root));
369 assert!(!escaped.is_inside(&root));
370 }
371
372 #[test]
373 fn a_sibling_prefix_is_not_containment() {
374 let root = ResolvedPath {
375 path: "/a/foo-wt".into(),
376 real: "/a/foo-wt".into(),
377 };
378 let sibling = ResolvedPath {
379 path: "/a/foo-wt2".into(),
380 real: "/a/foo-wt2".into(),
381 };
382 assert!(!sibling.is_inside(&root));
383 }
384
385 #[test]
386 fn a_path_that_does_not_exist_yet_resolves_through_its_existing_parent() {
387 let dir = tempfile::tempdir().unwrap();
388 let real_dir = dir.path().canonicalize().unwrap();
389 let target = dir.path().join("not/yet/here");
390 let r = ResolvedPath::of(&target);
391 assert_eq!(r.real, real_dir.join("not/yet/here"));
392 #[cfg(unix)]
395 {
396 std::os::unix::fs::symlink(&real_dir, dir.path().join("link")).unwrap();
397 let via = ResolvedPath::of(dir.path().join("link/deeper/x"));
398 assert_eq!(via.real, real_dir.join("deeper/x"));
399 }
400 }
401}