1use std::path::{Component, Path, PathBuf};
16
17use anyhow::{Context, bail};
18use serde::Deserialize;
19
20use crate::client::Client;
21use crate::config::{origin, parse_server};
22use crate::local_links::{self, LocalLink};
23use crate::{checkout, exit, output, server_id};
24
25pub const FILE: &str = ".semora";
26
27pub fn shown(path: &Path) -> String {
29 let s = path.display().to_string();
30 match s.strip_prefix(r"\\?\") {
31 Some(rest) => match rest.strip_prefix(r"UNC\") {
32 Some(share) => format!(r"\\{share}"),
33 None => rest.to_string(),
34 },
35 None => s,
36 }
37}
38
39#[derive(Debug)]
42pub struct OtherServer(pub String);
43
44impl std::fmt::Display for OtherServer {
45 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
46 f.write_str(&self.0)
47 }
48}
49
50impl std::error::Error for OtherServer {}
51
52#[derive(Debug, Clone, PartialEq, Eq)]
54pub enum ServerRef {
55 Id(String),
57 Url(String),
59}
60
61#[derive(Debug, Clone, PartialEq, Eq)]
63pub struct FileLink {
64 pub root: PathBuf,
66 pub server: ServerRef,
67 pub slug: String,
68}
69
70#[derive(Debug, Clone, PartialEq, Eq)]
72pub struct Link {
73 pub root: PathBuf,
75 pub server: String,
77 pub slug: String,
79 pub file_slug: String,
81 pub local: Option<Mapped>,
85}
86
87#[derive(Debug, Clone, PartialEq, Eq)]
89pub struct Mapped {
90 pub config_dir: PathBuf,
91 pub record: LocalLink,
92}
93
94impl Link {
95 pub fn mapped(&self) -> bool {
96 self.local.is_some()
97 }
98}
99
100#[derive(Debug, Clone, PartialEq, Eq)]
102pub enum Fit {
103 Ours,
105 LegacyOurs,
107 LegacyOther(String),
109 Other,
111 Mapped(LocalLink),
113}
114
115pub fn server_value(server: &ServerRef) -> String {
117 match server {
118 ServerRef::Id(id) | ServerRef::Url(id) => id.clone(),
119 }
120}
121
122#[derive(Deserialize)]
123struct Raw {
124 server: Option<String>,
125 project: Option<String>,
126}
127
128pub fn valid_slug(s: &str) -> bool {
130 (1..=40).contains(&s.len())
131 && s.bytes()
132 .all(|b| b.is_ascii_lowercase() || b.is_ascii_digit() || b == b'-')
133 && !s.starts_with('-')
134}
135
136pub fn slug_from(dir_name: &str) -> Option<String> {
139 let mut out = String::new();
140 for c in dir_name.to_lowercase().chars() {
141 if c.is_ascii_lowercase() || c.is_ascii_digit() {
142 out.push(c);
143 } else if !out.ends_with('-') {
144 out.push('-');
145 }
146 }
147 let cut: String = out.trim_matches('-').chars().take(40).collect();
148 let slug = cut.trim_matches('-').to_string();
149 valid_slug(&slug).then_some(slug)
150}
151
152pub fn read_at(dir: &Path) -> anyhow::Result<Option<FileLink>> {
154 let file = dir.join(FILE);
155 if !file.is_file() {
156 return Ok(None);
157 }
158 let text =
159 std::fs::read_to_string(&file).with_context(|| format!("reading {}", shown(&file)))?;
160 let raw: Raw = toml::from_str(&text).with_context(|| format!("parsing {}", shown(&file)))?;
161 let server = raw
162 .server
163 .filter(|s| !s.trim().is_empty())
164 .with_context(|| format!("{} needs a `server` line", shown(&file)))?;
165 let slug = raw
166 .project
167 .filter(|s| !s.trim().is_empty())
168 .with_context(|| format!("{} needs a `project` line", shown(&file)))?;
169 if !valid_slug(&slug) {
170 bail!(
171 "{}: `{slug}` is not a valid project id (lowercase letters, digits and dashes, up to 40, starting with a letter or digit)",
172 shown(&file)
173 );
174 }
175 let server = if server_id::is_id(&server) {
176 ServerRef::Id(server_id::normal(&server))
177 } else {
178 let url =
179 parse_server(&server).with_context(|| format!("{}: bad `server`", shown(&file)))?;
180 ServerRef::Url(origin(&url))
181 };
182 Ok(Some(FileLink {
183 root: dir.to_path_buf(),
184 server,
185 slug,
186 }))
187}
188
189fn start_dir(full: &Path) -> Option<&Path> {
191 if full.is_dir() {
192 Some(full)
193 } else {
194 full.parent()
195 }
196}
197
198pub fn find(path: &Path) -> anyhow::Result<Option<FileLink>> {
201 let full =
202 std::fs::canonicalize(path).with_context(|| format!("cannot read {}", shown(path)))?;
203 let mut dir = start_dir(&full);
204 while let Some(d) = dir {
205 if let Some(link) = read_at(d)? {
206 return Ok(Some(link));
207 }
208 dir = d.parent();
209 }
210 Ok(None)
211}
212
213pub fn write(dir: &Path, server_id: &str, slug: &str) -> anyhow::Result<PathBuf> {
215 let file = dir.join(FILE);
216 let text = format!(
217 "# Semora project. Commit this file so every checkout publishes to the same place.\nserver = \"{server_id}\"\nproject = \"{slug}\"\n"
218 );
219 std::fs::write(&file, text).with_context(|| format!("writing {}", shown(&file)))?;
220 Ok(file)
221}
222
223pub fn set_line(dir: &Path, key: &str, value: &str) -> anyhow::Result<()> {
226 let file = dir.join(FILE);
227 let text =
228 std::fs::read_to_string(&file).with_context(|| format!("reading {}", shown(&file)))?;
229 let eol = if text.contains("\r\n") { "\r\n" } else { "\n" };
230 let mut found = false;
231 let mut lines: Vec<String> = Vec::new();
232 for line in text.lines() {
233 let is_key = line
234 .trim_start()
235 .strip_prefix(key)
236 .is_some_and(|rest| rest.trim_start().starts_with('='));
237 if is_key && !found {
238 found = true;
239 lines.push(format!("{key} = \"{value}\""));
240 } else {
241 lines.push(line.to_string());
242 }
243 }
244 if !found {
245 bail!("{} has no `{key}` line to update", shown(&file));
246 }
247 let new_text = lines.join(eol) + eol;
248 std::fs::write(&file, &new_text).with_context(|| format!("writing {}", shown(&file)))?;
249 let meant = |l: &FileLink| match key {
251 "project" => l.slug == value,
252 _ => l.server == ServerRef::Id(value.to_string()),
253 };
254 match read_at(dir) {
255 Ok(Some(l)) if meant(&l) => Ok(()),
256 _ => {
257 std::fs::write(&file, &text).ok();
258 bail!("could not update the `{key}` line in {}", shown(&file))
259 }
260 }
261}
262
263pub fn adopt(link: Link, slug: &str) -> Link {
267 if slug == link.slug || !valid_slug(slug) {
268 return link;
269 }
270 if let Some(m) = &link.local {
271 let record = LocalLink {
272 project: slug.to_string(),
273 ..m.record.clone()
274 };
275 if let Err(e) = local_links::save(&m.config_dir, record.clone()) {
276 output::warn(format!(
277 "project `{}` is now `{slug}` on {}, but this machine's link could not be updated: {e:#}. Run `semora project link --project {slug}`.",
278 link.slug, link.server
279 ));
280 return link;
281 }
282 let moved = Link {
283 slug: slug.to_string(),
284 local: Some(Mapped {
285 config_dir: m.config_dir.clone(),
286 record,
287 }),
288 ..link.clone()
289 };
290 if let Err(e) = checkout::move_to(&link, &moved) {
291 output::warn(format!(
292 "could not carry this folder's record over to the new id: {e:#}. Run `semora pull` to start it again."
293 ));
294 }
295 output::warn(format!(
296 "project '{}' is now '{slug}' on {}. Updated this machine's link.",
297 link.slug, link.server
298 ));
299 return moved;
300 }
301 if let Err(e) = set_line(&link.root, "project", slug) {
302 output::warn(format!(
303 "project `{}` is now `{slug}` on {}, but {e:#}. Change the `project` line yourself.",
304 link.slug, link.server
305 ));
306 return link;
307 }
308 let moved = Link {
309 slug: slug.to_string(),
310 file_slug: slug.to_string(),
311 ..link.clone()
312 };
313 if let Err(e) = checkout::move_to(&link, &moved) {
314 output::warn(format!(
315 "could not carry this folder's record over to the new id: {e:#}. Run `semora pull` to start it again."
316 ));
317 }
318 output::warn(format!(
319 "project `{}` is now `{slug}` on {}. Updated {}; commit it so other checkouts follow.",
320 link.slug,
321 link.server,
322 shown(&link.root.join(FILE))
323 ));
324 moved
325}
326
327pub async fn follow(client: &Client, link: Link) -> anyhow::Result<Link> {
332 let link = match client.project(&link.slug).await {
333 Ok(info) => adopt(link, &info.slug),
334 Err(e) if exit::is_not_found(&e) => recover(client, link).await,
335 Err(_) => link,
336 };
337 keep_record(client, &link).await;
338 Ok(link)
339}
340
341async fn keep_record(client: &Client, link: &Link) {
346 for id in checkout::ids_under_other_id(link)
347 .into_iter()
348 .take(RECOVER_TRIES)
349 {
350 let Ok(d) = client.doc(&id).await else {
351 continue;
352 };
353 if d.summary.project.as_deref() == Some(link.slug.as_str())
354 && let Err(e) = checkout::relabel(link)
355 {
356 output::warn(format!(
357 "could not carry this folder's record over to project `{}`: {e:#}",
358 link.slug
359 ));
360 }
361 return;
363 }
364}
365
366const RECOVER_TRIES: usize = 3;
368
369async fn recover(client: &Client, link: Link) -> Link {
372 let Ok(records) = checkout::load(&link) else {
373 return link;
374 };
375 let mut ids: Vec<String> = records.into_values().map(|r| r.id).collect();
376 ids.dedup();
377 for id in ids.into_iter().take(RECOVER_TRIES) {
378 if let Ok(d) = client.doc(&id).await
379 && let Some(project) = d.summary.project
380 {
381 return adopt(link, &project);
382 }
383 }
384 link
385}
386
387pub async fn fit(client: &Client, config_dir: &Path, file: &FileLink) -> anyhow::Result<Fit> {
391 let ours = match &file.server {
392 ServerRef::Id(id) => {
393 *id == server_id::of(client, config_dir, false).await?
394 || *id == server_id::of(client, config_dir, true).await?
395 }
396 ServerRef::Url(u) if *u == client.origin() => return Ok(Fit::LegacyOurs),
397 ServerRef::Url(_) => false,
398 };
399 if ours {
400 return Ok(Fit::Ours);
401 }
402 if let Some(record) = local_links::find(config_dir, &server_value(&file.server), &file.slug)? {
403 let id = server_id::of(client, config_dir, false).await?;
404 if record.server_id == id
405 || record.server_id == server_id::of(client, config_dir, true).await?
406 {
407 return Ok(Fit::Mapped(record));
408 }
409 }
410 Ok(match &file.server {
411 ServerRef::Url(u) => Fit::LegacyOther(u.clone()),
412 ServerRef::Id(_) => Fit::Other,
413 })
414}
415
416pub async fn resolve(client: &Client, config_dir: &Path, file: FileLink) -> anyhow::Result<Link> {
419 let path = shown(&file.root.join(FILE));
420 let have = client.origin();
421 let mut local = None;
422 let mut slug = file.slug.clone();
423 match fit(client, config_dir, &file).await? {
424 Fit::Ours => drop_shadowed(client, config_dir, &file, &path).await,
425 Fit::LegacyOurs => {
426 let id = server_id::of(client, config_dir, true).await?;
428 match set_line(&file.root, "server", &id) {
429 Ok(()) => output::warn(format!(
430 "{path} now names its server by id instead of address. Commit it. Older commits still hold the address."
431 )),
432 Err(e) => output::warn(format!(
433 "could not update {path}: {e:#}. Change the `server` line to \"{id}\" yourself."
434 )),
435 }
436 }
437 Fit::Mapped(record) => {
438 slug = record.project.clone();
439 local = Some(Mapped {
440 config_dir: config_dir.to_path_buf(),
441 record,
442 });
443 }
444 Fit::LegacyOther(theirs) => {
445 return Err(OtherServer(format!(
446 "{path} is for {theirs}, but this command uses {have}. To use that server, set SEMORA_URL or pass --server {theirs}."
447 ))
448 .into());
449 }
450 Fit::Other => return Err(OtherServer(other_server_message(&path, &have)).into()),
451 }
452 Ok(Link {
453 root: file.root,
454 server: have,
455 slug,
456 file_slug: file.slug,
457 local,
458 })
459}
460
461async fn drop_shadowed(client: &Client, config_dir: &Path, file: &FileLink, path: &str) {
465 let Ok(id) = server_id::of(client, config_dir, false).await else {
466 return;
467 };
468 match local_links::remove_shadowed(config_dir, &id, &file.slug, &file.root) {
469 Ok(gone) => {
470 for g in gone.iter().filter(|g| g.project != file.slug) {
471 output::warn(format!(
472 "{path} now names this server, so this machine's link to project '{}' is dropped; the file's project '{}' is used.",
473 g.project, file.slug
474 ));
475 }
476 }
477 Err(e) => output::warn(format!("{e:#}")),
478 }
479}
480
481fn other_server_message(path: &str, have: &str) -> String {
484 format!(
485 "{path} is for another Semora server than the one this CLI uses ({have}).
486The file does not say which server it is. Either:
487 - log in to the project's server: semora login --server ADDRESS (ask whoever set up the project)
488 - use your server for this project on this machine only: semora project link
489 (your documents drift from everyone else's; nobody else sees them)
490 - point .semora at your server: semora project link --rewrite
491 (once you commit it, every other checkout stops working until it does the same)"
492 )
493}
494
495impl Link {
496 pub fn relative_path(&self, target: &Path) -> anyhow::Result<String> {
498 let full = std::fs::canonicalize(target)
499 .with_context(|| format!("cannot read {}", shown(target)))?;
500 let rel = full.strip_prefix(&self.root).with_context(|| {
501 format!(
502 "{} is not inside the project at {}",
503 shown(&full),
504 shown(&self.root)
505 )
506 })?;
507 let mut parts = Vec::new();
508 for c in rel.components() {
509 let s = c.as_os_str().to_str().with_context(|| {
510 format!(
511 "{} isn't valid Unicode, so it can't be a project path",
512 shown(&full)
513 )
514 })?;
515 parts.push(s.to_string());
516 }
517 if parts.is_empty() {
518 bail!(
519 "{} is here: push a file or a folder inside this project, not the project root.",
520 shown(&self.root.join(FILE))
521 );
522 }
523 Ok(parts.join("/"))
524 }
525}
526
527pub fn git_branch(path: &Path) -> Option<String> {
533 let full = std::path::absolute(path).ok()?;
534 let mut dir = start_dir(&full);
535 while let Some(d) = dir {
536 let dot = d.join(".git");
537 if dot.is_dir() {
538 return branch_in(&dot);
539 }
540 if dot.is_file() {
541 let text = std::fs::read_to_string(&dot).ok()?;
542 let target = text
543 .lines()
544 .find_map(|l| l.trim().strip_prefix("gitdir:"))?
545 .trim();
546 let gitdir = if Path::new(target).is_absolute() {
547 PathBuf::from(target)
548 } else {
549 lexical(&d.join(target))
550 };
551 return branch_in(&gitdir);
552 }
553 dir = d.parent();
554 }
555 None
556}
557
558fn lexical(path: &Path) -> PathBuf {
560 let mut out = PathBuf::new();
561 for c in path.components() {
562 match c {
563 Component::CurDir => {}
564 Component::ParentDir => {
565 if !out.pop() {
566 out.push("..");
567 }
568 }
569 other => out.push(other.as_os_str()),
570 }
571 }
572 out
573}
574
575fn branch_in(gitdir: &Path) -> Option<String> {
576 let head = std::fs::read_to_string(gitdir.join("HEAD")).ok()?;
577 head.trim()
578 .strip_prefix("ref: refs/heads/")
579 .map(str::to_string)
580 .filter(|b| !b.is_empty())
581}
582
583pub fn origin_for(path: &Path) -> String {
585 let machine = crate::commands::login::hostname();
586 match git_branch(path) {
587 Some(b) => format!("{machine} \u{b7} {b}"),
588 None => machine,
589 }
590}
591
592#[cfg(test)]
593mod tests {
594 use super::*;
595
596 fn dir() -> tempfile::TempDir {
597 tempfile::tempdir().unwrap()
598 }
599
600 fn linked(f: FileLink) -> Link {
602 Link {
603 root: f.root,
604 server: "https://a.example.com".into(),
605 slug: f.slug.clone(),
606 file_slug: f.slug,
607 local: None,
608 }
609 }
610
611 fn put(root: &Path, rel: &str, text: &str) -> PathBuf {
612 let p = root.join(rel);
613 std::fs::create_dir_all(p.parent().unwrap()).unwrap();
614 std::fs::write(&p, text).unwrap();
615 p
616 }
617
618 #[test]
619 fn slugs_follow_the_rule() {
620 let (x40, x41) = ("x".repeat(40), "x".repeat(41));
621 for ok in ["semora", "a", "9-a", x40.as_str()] {
622 assert!(valid_slug(ok), "{ok}");
623 }
624 for bad in ["", "Semora", "-x", "a b", x41.as_str()] {
625 assert!(!valid_slug(bad), "{bad}");
626 }
627 }
628
629 #[test]
630 fn a_slug_is_made_from_a_directory_name() {
631 assert_eq!(slug_from("My Docs_v2").as_deref(), Some("my-docs-v2"));
632 assert_eq!(slug_from(".tmpAbC123").as_deref(), Some("tmpabc123"));
633 assert_eq!(slug_from("--a--b--").as_deref(), Some("a-b"));
634 assert_eq!(slug_from("semora").as_deref(), Some("semora"));
635 assert_eq!(slug_from(&"a".repeat(50)).unwrap().len(), 40);
636 let a39 = "a".repeat(39);
637 assert_eq!(
638 slug_from(&format!("{a39}-b")).as_deref(),
639 Some(a39.as_str()),
640 "no dash left at the cut"
641 );
642 assert_eq!(slug_from("___"), None);
643 assert_eq!(slug_from(""), None);
644 }
645
646 #[test]
647 fn dot_semora_is_parsed_and_checked() {
648 let d = dir();
649 put(
650 d.path(),
651 ".semora",
652 "# note\nserver = \"https://Semora.example.com/\"\nproject = \"semora\"\nextra = 1\n",
653 );
654 let link = read_at(d.path()).unwrap().unwrap();
655 assert_eq!(link.slug, "semora");
656 assert_eq!(
657 link.server,
658 ServerRef::Url("https://semora.example.com".into()),
659 "the origin, whatever the spelling"
660 );
661 put(
662 d.path(),
663 ".semora",
664 "server = \" 3F9C0E7A5B1D4E2F8A6C9B0D1E2F3A4B \"\nproject = \"semora\"\n",
665 );
666 assert_eq!(
667 read_at(d.path()).unwrap().unwrap().server,
668 ServerRef::Id("3f9c0e7a5b1d4e2f8a6c9b0d1e2f3a4b".into()),
669 "an id, normalized"
670 );
671
672 for (text, why) in [
673 ("project = \"x\"\n", "server"),
674 ("server = \"https://a.example.com\"\n", "project"),
675 (
676 "server = \"https://a.example.com\"\nproject = \"Bad Id\"\n",
677 "valid project id",
678 ),
679 ("server = \n", "parsing"),
680 ] {
681 put(d.path(), ".semora", text);
682 let err = read_at(d.path()).unwrap_err().to_string();
683 assert!(
684 err.contains(why) && err.contains(".semora"),
685 "{text:?} -> {err}"
686 );
687 }
688 std::fs::remove_file(d.path().join(".semora")).unwrap();
689 assert_eq!(read_at(d.path()).unwrap(), None);
690 }
691
692 #[test]
693 fn find_walks_up_from_the_path_and_the_nearest_file_wins() {
694 let d = dir();
695 put(
696 d.path(),
697 ".semora",
698 "server = \"https://a.example.com\"\nproject = \"outer\"\n",
699 );
700 put(
701 d.path(),
702 "inner/.semora",
703 "server = \"https://a.example.com\"\nproject = \"inner\"\n",
704 );
705 let file = put(d.path(), "docs/deep/x.md", "# x");
706 put(d.path(), "inner/y.md", "# y");
707 assert_eq!(find(&file).unwrap().unwrap().slug, "outer");
708 assert_eq!(
709 find(&d.path().join("docs")).unwrap().unwrap().slug,
710 "outer",
711 "a folder"
712 );
713 assert_eq!(
714 find(&d.path().join("inner/y.md")).unwrap().unwrap().slug,
715 "inner"
716 );
717 assert_eq!(
718 find(&d.path().join("inner")).unwrap().unwrap().slug,
719 "inner",
720 "the folder's own file"
721 );
722 let lonely = tempfile::tempdir().unwrap();
723 let f = put(lonely.path(), "z.md", "# z");
724 assert_eq!(find(&f).unwrap(), None);
725 }
726
727 #[test]
728 fn the_key_path_is_relative_with_forward_slashes() {
729 let d = dir();
730 put(
731 d.path(),
732 ".semora",
733 "server = \"https://a.example.com\"\nproject = \"p\"\n",
734 );
735 let file = put(d.path(), "docs/deep/x.md", "# x");
736 let link = linked(find(&file).unwrap().unwrap());
737 assert_eq!(link.relative_path(&file).unwrap(), "docs/deep/x.md");
738 assert_eq!(link.relative_path(&d.path().join("docs")).unwrap(), "docs");
739 let err = link.relative_path(d.path()).unwrap_err().to_string();
740 assert!(err.contains("not the project root"), "{err}");
741 }
742
743 #[test]
744 fn the_message_for_another_servers_id_names_only_this_one_and_the_ways_out() {
745 let m = other_server_message("/r/.semora", "https://mine.example.com");
746 for part in [
747 "/r/.semora",
748 "https://mine.example.com",
749 "semora login --server ADDRESS",
750 "semora project link",
751 "semora project link --rewrite",
752 ] {
753 assert!(m.contains(part), "{part}: {m}");
754 }
755 assert!(
756 !m.contains("--id") && !m.contains("--new"),
757 "push flags are push's to mention, not every command's: {m}"
758 );
759 }
760
761 #[test]
762 fn write_makes_a_file_that_reads_back() {
763 let d = dir();
764 let id = "3f9c0e7a5b1d4e2f8a6c9b0d1e2f3a4b";
765 let path = write(d.path(), id, "semora").unwrap();
766 let text = std::fs::read_to_string(&path).unwrap();
767 assert!(text.starts_with("# Semora project."), "{text}");
768 let link = read_at(d.path()).unwrap().unwrap();
769 assert_eq!(
770 (link.server, link.slug.as_str()),
771 (ServerRef::Id(id.into()), "semora")
772 );
773 }
774
775 #[test]
776 fn paths_in_messages_lose_the_windows_verbatim_prefix() {
777 assert_eq!(
778 shown(Path::new(r"\\?\C:\Development\x\.semora")),
779 r"C:\Development\x\.semora"
780 );
781 assert_eq!(shown(Path::new(r"\\?\UNC\nas\share\x")), r"\\nas\share\x");
782 assert_eq!(shown(Path::new("/home/a/x")), "/home/a/x");
783 }
784
785 #[test]
786 fn dots_are_resolved_without_the_file_system() {
787 assert_eq!(lexical(Path::new("/a/b/../c/./d")), PathBuf::from("/a/c/d"));
788 assert_eq!(lexical(Path::new("a/../../b")), PathBuf::from("../b"));
789 }
790
791 #[test]
792 fn the_branch_comes_from_head() {
793 let d = dir();
794 put(d.path(), ".git/HEAD", "ref: refs/heads/feat/projects\n");
795 let file = put(d.path(), "docs/x.md", "# x");
796 assert_eq!(git_branch(&file).as_deref(), Some("feat/projects"));
797 put(
798 d.path(),
799 ".git/HEAD",
800 "0123456789abcdef0123456789abcdef01234567\n",
801 );
802 assert_eq!(git_branch(&file), None, "a detached HEAD has no branch");
803 }
804
805 #[test]
806 fn a_worktree_points_at_its_git_folder_absolute_or_relative() {
807 let d = dir();
808 let main = put(
809 d.path(),
810 "main/.git/worktrees/wt/HEAD",
811 "ref: refs/heads/feat/wt\n",
812 );
813 let abs = main.parent().unwrap().to_string_lossy().replace('\\', "/");
814 put(d.path(), "abs/.git", &format!("gitdir: {abs}\n"));
815 let file = put(d.path(), "abs/x.md", "# x");
816 assert_eq!(
817 git_branch(&file).as_deref(),
818 Some("feat/wt"),
819 "absolute gitdir"
820 );
821 put(d.path(), "rel/.git", "gitdir: ../main/.git/worktrees/wt\n");
823 let file = put(d.path(), "rel/x.md", "# x");
824 assert_eq!(
825 git_branch(&file).as_deref(),
826 Some("feat/wt"),
827 "relative gitdir"
828 );
829 }
830
831 #[test]
832 fn a_verbatim_path_still_reads_a_relative_gitdir() {
833 let d = dir();
836 put(
837 d.path(),
838 "main/.git/worktrees/wt/HEAD",
839 "ref: refs/heads/feat/wt\n",
840 );
841 put(d.path(), "rel/.git", "gitdir: ../main/.git/worktrees/wt\n");
842 let file = put(d.path(), "rel/x.md", "# x");
843 let canonical = std::fs::canonicalize(&file).unwrap();
844 if cfg!(windows) {
845 assert!(
846 canonical.to_string_lossy().starts_with(r"\\?\"),
847 "{canonical:?}"
848 );
849 }
850 assert_eq!(git_branch(&canonical).as_deref(), Some("feat/wt"));
851 }
852
853 #[test]
854 fn origin_is_the_machine_and_the_branch_when_there_is_one() {
855 let d = dir();
856 let file = put(d.path(), "x.md", "# x");
857 let machine = origin_for(&file);
858 assert!(!machine.contains('\u{b7}'), "no branch: {machine}");
859 put(d.path(), ".git/HEAD", "ref: refs/heads/main\n");
860 let with = origin_for(&file);
861 assert_eq!(with, format!("{machine} \u{b7} main"));
862 }
863}