1use crate::version::{normalize_pretty, UnsupportedVersion};
17use serde_json::Value;
18use std::path::Path;
19use std::process::Command;
20
21#[derive(Debug, Clone, PartialEq, Eq)]
22pub struct RootVersion {
23 pub pretty_version: String,
24 pub version: String,
26 pub reference: Option<String>,
27}
28
29#[derive(Debug, Clone, PartialEq, Eq)]
34pub struct GuessedVersion {
35 pub version: String,
36 pub pretty_version: String,
37 pub commit: Option<String>,
38 pub feature_version: Option<String>,
39 pub feature_pretty_version: Option<String>,
40}
41
42pub const DEFAULT_PRETTY_VERSION: &str = "1.0.0+no-version-set";
43
44fn normalize_or_raw(v: &str) -> String {
45 normalize_pretty(v).unwrap_or_else(|_| v.to_owned())
46}
47
48pub fn normalize_branch(name: &str) -> String {
51 let name = name.trim();
52 let stripped = name
53 .strip_prefix('v')
54 .or_else(|| name.strip_prefix('V'))
55 .unwrap_or(name);
56 let parts: Vec<&str> = stripped.split('.').collect();
57 let numeric_or_x = |s: &str| {
58 !s.is_empty() && (s.bytes().all(|b| b.is_ascii_digit()) || matches!(s, "x" | "X" | "*"))
59 };
60 if (1..=4).contains(&parts.len())
61 && parts[0].bytes().all(|b| b.is_ascii_digit())
62 && !parts[0].is_empty()
63 && parts[1..].iter().all(|p| numeric_or_x(p))
64 {
65 let mut out: Vec<String> = parts
66 .iter()
67 .map(|p| {
68 if matches!(*p, "x" | "X" | "*") {
69 "9999999".to_owned()
70 } else {
71 (*p).to_owned()
72 }
73 })
74 .collect();
75 while out.len() < 4 {
76 out.push("9999999".to_owned());
77 }
78 return format!("{}-dev", out.join("."));
79 }
80 format!("dev-{name}")
81}
82
83fn is_feature_branch(manifest: &Value, branch: &str) -> bool {
87 let custom: Vec<&str> = manifest
88 .get("non-feature-branches")
89 .and_then(Value::as_array)
90 .map(|a| a.iter().filter_map(Value::as_str).collect())
91 .unwrap_or_default();
92 let mut pattern = String::from("^(");
93 if !custom.is_empty() {
94 pattern.push_str(&custom.join("|"));
95 }
96 pattern
97 .push_str("|master|main|latest|next|current|support|tip|trunk|default|develop|\\d+\\..+)$");
98 match pcre2::bytes::RegexBuilder::new().build(&pattern) {
99 Ok(re) => !re.is_match(branch.as_bytes()).unwrap_or(false),
100 Err(_) => true,
103 }
104}
105
106type GitAnswers = std::collections::HashMap<(std::path::PathBuf, Vec<String>), Option<String>>;
114static GIT_ANSWERS: std::sync::Mutex<Option<GitAnswers>> = std::sync::Mutex::new(None);
115
116#[must_use = "git's answers are forgotten when the guard is dropped"]
119pub fn remember_git_answers() -> GitAnswersScope {
120 if let Ok(mut answers) = GIT_ANSWERS.lock() {
121 *answers = Some(GitAnswers::new());
122 }
123 GitAnswersScope(())
124}
125
126pub struct GitAnswersScope(());
128
129impl Drop for GitAnswersScope {
130 fn drop(&mut self) {
131 forget_git_answers();
132 }
133}
134
135pub fn forget_git_answers() {
137 if let Ok(mut answers) = GIT_ANSWERS.lock() {
138 *answers = None;
139 }
140}
141
142pub fn git(dir: &Path, args: &[&str]) -> Option<String> {
147 let key = (
148 dir.to_path_buf(),
149 args.iter().map(|a| (*a).to_owned()).collect::<Vec<_>>(),
150 );
151 if let Ok(answers) = GIT_ANSWERS.lock() {
152 if let Some(answer) = answers.as_ref().and_then(|a| a.get(&key)) {
153 return answer.clone();
154 }
155 }
156 let answer = run_git(dir, args);
157 if let Ok(mut answers) = GIT_ANSWERS.lock() {
158 if let Some(answers) = answers.as_mut() {
159 answers.insert(key, answer.clone());
160 }
161 }
162 answer
163}
164
165fn run_git(dir: &Path, args: &[&str]) -> Option<String> {
166 let out = Command::new("git")
167 .args(args)
168 .current_dir(dir)
169 .env_remove("GIT_DIR")
170 .env_remove("GIT_WORK_TREE")
171 .env_remove("GIT_INDEX_FILE")
172 .env("LANGUAGE", "C")
173 .env("LC_ALL", "C")
174 .env("GIT_TERMINAL_PROMPT", "0")
175 .output()
176 .ok()?;
177 if !out.status.success() {
178 return None;
179 }
180 Some(String::from_utf8_lossy(&out.stdout).into_owned())
181}
182
183const BRANCH_ARGS: &[&str] = &["branch", "-a", "--no-color", "--no-abbrev", "-v"];
185
186fn current_branch(line: &str) -> Option<(&str, &str)> {
193 let rest = line.strip_prefix("* ")?.trim_start();
194 let (name, tail) = if rest.starts_with('(') {
195 let i = rest.find(')')?;
196 (&rest[..=i], rest[i + 1..].trim_start())
197 } else {
198 let i = rest.find(' ')?;
199 (&rest[..i], rest[i..].trim_start())
200 };
201 let sha = tail.split_whitespace().next().unwrap_or("");
202 if sha.is_empty() || !sha.bytes().all(|b| b.is_ascii_hexdigit()) {
203 return None;
204 }
205 if name.starts_with('(')
206 && !(name == "(no branch)"
207 || name.starts_with("(detached from ")
208 || name.starts_with("(HEAD detached at "))
209 {
210 return None;
211 }
212 Some((name, sha))
213}
214
215pub fn version_guessable(project: &Path) -> bool {
221 let Some(output) = git(project, BRANCH_ARGS) else {
222 return false;
223 };
224 if output.lines().any(|l| current_branch(l).is_some()) {
225 return true;
226 }
227 git(project, &["describe", "--exact-match", "--tags"])
228 .is_some_and(|tag| normalize_pretty(tag.trim()).is_ok())
229}
230
231pub fn guess_version(manifest: &Value, project: &Path) -> Option<GuessedVersion> {
233 let output = git(project, BRANCH_ARGS)?;
234 let mut version: Option<String> = None;
235 let mut pretty: Option<String> = None;
236 let mut commit: Option<String> = None;
237 let mut is_feature = false;
238 let mut is_detached = false;
239 let mut branches: Vec<String> = Vec::new();
240 let is_hex = |s: &str| !s.is_empty() && s.bytes().all(|b| b.is_ascii_hexdigit());
241
242 for line in output.lines() {
243 if line.is_empty() {
244 continue;
245 }
246 if line.starts_with("* ") {
248 let Some((name, sha)) = current_branch(line) else {
252 continue;
253 };
254 if name.starts_with('(') {
255 version = Some(format!("dev-{sha}"));
256 pretty = version.clone();
257 is_feature = true;
258 is_detached = true;
259 } else {
260 version = Some(normalize_branch(name));
261 pretty = Some(format!("dev-{name}"));
262 is_feature = is_feature_branch(manifest, name);
263 }
264 commit = Some(sha.to_owned());
265 }
266 let trimmed = line.trim_start_matches("* ").trim_start();
268 let mut parts = trimmed.split_whitespace();
269 let (Some(name), Some(sha)) = (parts.next(), parts.next()) else {
270 continue;
271 };
272 if name.ends_with("/HEAD") || !is_hex(sha) {
273 continue;
274 }
275 let bare = name
276 .strip_prefix("remotes/origin/")
277 .or_else(|| name.strip_prefix("remotes/upstream/"))
278 .unwrap_or(name);
279 if bare.contains('/') {
280 continue;
281 }
282 branches.push(name.to_owned());
283 }
284
285 let mut feature: Option<(String, String)> = None;
286 if is_feature {
287 if let (Some(v), Some(p)) = (&version, &pretty) {
288 feature = Some((v.clone(), p.clone()));
289 let (nv, np) = guess_feature_version(manifest, v, &branches, project);
290 version = Some(nv);
291 pretty = Some(np);
292 }
293 }
294 if version.is_none() || is_detached {
295 if let Some(tag) = git(project, &["describe", "--exact-match", "--tags"]) {
296 let tag = tag.trim();
297 if let Ok(norm) = normalize_pretty(tag) {
298 version = Some(norm);
299 pretty = Some(tag.to_owned());
300 feature = None;
301 }
302 }
303 }
304 if commit.is_none() {
305 if let Some(out) = git(project, &["rev-list", "--format=%H", "-n1", "HEAD"]) {
306 commit = out
307 .lines()
308 .find(|l| !l.starts_with("commit "))
309 .map(|l| l.trim().to_owned())
310 .filter(|s| !s.is_empty());
311 }
312 }
313 let version = version?;
314 let pretty = pretty?;
315 let feature = feature.filter(|(fv, fp)| !(fv == &version && fp == &pretty));
318 let collapse = |version: &str, pretty: String| {
319 if version.ends_with("-dev") && version.contains(".9999999") {
320 collapse_nines(version)
321 } else {
322 pretty
323 }
324 };
325 let pretty = collapse(&version, pretty);
326 let (feature_version, feature_pretty_version) = match feature {
327 Some((fv, fp)) => {
328 let fp = collapse(&fv, fp);
329 (Some(fv), Some(fp))
330 }
331 None => (None, None),
332 };
333 Some(GuessedVersion {
334 version,
335 pretty_version: pretty,
336 commit,
337 feature_version,
338 feature_pretty_version,
339 })
340}
341
342fn guess_git(manifest: &Value, project: &Path) -> Option<RootVersion> {
344 let g = guess_version(manifest, project)?;
345 Some(RootVersion {
346 pretty_version: g.pretty_version,
347 version: g.version,
348 reference: g.commit,
349 })
350}
351
352fn collapse_nines(version: &str) -> String {
353 let mut out = version.replace(".9999999", "\u{0}");
354 while out.contains("\u{0}\u{0}") {
355 out = out.replace("\u{0}\u{0}", "\u{0}");
356 }
357 out.replace('\u{0}', ".x")
358}
359
360fn guess_feature_version(
363 manifest: &Value,
364 version: &str,
365 branches: &[String],
366 project: &Path,
367) -> (String, String) {
368 let has_alias = manifest
369 .get("extra")
370 .and_then(|e| e.get("branch-alias"))
371 .and_then(|b| b.get(version))
372 .is_some();
373 let has_self_version = manifest.to_string().contains("\"self.version\"");
374 if has_alias && !has_self_version {
375 return (version.to_owned(), version.to_owned());
376 }
377 let branch = version.strip_prefix("dev-").unwrap_or(version).to_owned();
378 if !is_feature_branch(manifest, &branch) {
379 return (version.to_owned(), version.to_owned());
380 }
381 let mut sorted: Vec<String> = branches.to_vec();
382 sorted.sort_by(|a, b| {
383 let (ar, br) = (a.starts_with("remotes/"), b.starts_with("remotes/"));
384 if ar != br {
385 return if ar {
386 std::cmp::Ordering::Greater
387 } else {
388 std::cmp::Ordering::Less
389 };
390 }
391 strnatcasecmp(b, a)
392 });
393 let mut best_len = usize::MAX;
394 let mut result = (version.to_owned(), version.to_owned());
395 for candidate in &sorted {
396 let candidate_version = candidate
397 .strip_prefix("remotes/")
398 .and_then(|r| r.split_once('/').map(|x| x.1))
399 .unwrap_or(candidate);
400 if candidate == &branch || is_feature_branch(manifest, candidate_version) {
401 continue;
402 }
403 let Some(out) = git(project, &["rev-list", &format!("{candidate}..{branch}")]) else {
404 continue;
405 };
406 if out.len() <= best_len {
408 best_len = out.len();
409 result = (
410 normalize_branch(candidate_version),
411 format!("dev-{candidate_version}"),
412 );
413 if best_len == 0 {
414 break;
415 }
416 }
417 }
418 result
419}
420
421fn strnatcasecmp(a: &str, b: &str) -> std::cmp::Ordering {
424 let (a, b) = (a.to_ascii_lowercase(), b.to_ascii_lowercase());
425 let (ab, bb) = (a.as_bytes(), b.as_bytes());
426 let (mut i, mut j) = (0, 0);
427 while i < ab.len() && j < bb.len() {
428 if ab[i].is_ascii_digit() && bb[j].is_ascii_digit() {
429 let si = i;
430 while i < ab.len() && ab[i].is_ascii_digit() {
431 i += 1;
432 }
433 let sj = j;
434 while j < bb.len() && bb[j].is_ascii_digit() {
435 j += 1;
436 }
437 let na: u128 = a[si..i].parse().unwrap_or(0);
438 let nb: u128 = b[sj..j].parse().unwrap_or(0);
439 if na != nb {
440 return na.cmp(&nb);
441 }
442 } else {
443 if ab[i] != bb[j] {
444 return ab[i].cmp(&bb[j]);
445 }
446 i += 1;
447 j += 1;
448 }
449 }
450 (ab.len() - i).cmp(&(bb.len() - j))
451}
452
453pub fn foreign_vcs_version(dir: &Path) -> Option<&'static str> {
464 let mut hg = false;
465 let mut fossil = false;
466 let mut svn = false;
467 for d in dir.ancestors() {
468 hg |= d.join(".hg").is_dir();
469 fossil |= d.join("_FOSSIL_").is_file() || d.join(".fslckout").is_file();
470 svn |= d.join(".svn").is_dir();
471 }
472 let answers = |program: &str, args: &[&str]| {
473 Command::new(program)
474 .args(args)
475 .current_dir(dir)
476 .stdin(std::process::Stdio::null())
477 .stdout(std::process::Stdio::null())
478 .stderr(std::process::Stdio::null())
479 .status()
480 .is_ok_and(|s| s.success())
481 };
482 if hg && answers("hg", &["branch"]) {
483 return Some("Mercurial (`hg branch`)");
484 }
485 if fossil && (answers("fossil", &["branch", "list"]) || answers("fossil", &["tag", "list"])) {
486 return Some("Fossil (`fossil branch list`)");
487 }
488 if svn && answers("svn", &["info", "--xml"]) {
489 return Some("Subversion (`svn info --xml`)");
490 }
491 None
492}
493
494pub fn root_version_from_foreign_vcs(manifest: &Value, project: &Path) -> Option<&'static str> {
500 if manifest.get("version").is_some_and(|v| !v.is_null()) || root_version_from_env().is_some() {
501 return None;
502 }
503 if !foreign_vcs_marked(project) || version_guessable(project) {
504 return None;
505 }
506 foreign_vcs_version(project)
507}
508
509pub fn foreign_vcs_marked(dir: &Path) -> bool {
512 dir.ancestors().any(|d| {
513 d.join(".hg").is_dir()
514 || d.join(".svn").is_dir()
515 || d.join("_FOSSIL_").is_file()
516 || d.join(".fslckout").is_file()
517 })
518}
519
520pub fn root_version_from_env() -> Option<String> {
523 let env = std::env::var("COMPOSER_ROOT_VERSION").ok()?;
524 if env.is_empty() || env == "0" {
526 return None;
527 }
528 let lower = env.to_ascii_lowercase();
529 Some(match lower.strip_suffix("-dev") {
530 Some(num)
531 if !num.is_empty()
532 && num
533 .split('.')
534 .all(|p| !p.is_empty() && p.bytes().all(|b| b.is_ascii_digit())) =>
535 {
536 format!("{}.x-dev", &env[..num.len()])
537 }
538 _ => env.clone(),
539 })
540}
541
542pub fn detect(manifest: &Value, project: &Path) -> RootVersion {
544 if let Some(v) = manifest.get("version").and_then(Value::as_str) {
545 return RootVersion {
546 pretty_version: v.to_owned(),
547 version: normalize_or_raw(v),
548 reference: None,
549 };
550 }
551 if let Some(v) = root_version_from_env() {
552 return RootVersion {
553 pretty_version: v.clone(),
554 version: normalize_or_raw(&v),
555 reference: None,
556 };
557 }
558 if let Some(g) = guess_git(manifest, project) {
559 return g;
560 }
561 RootVersion {
562 pretty_version: DEFAULT_PRETTY_VERSION.to_owned(),
563 version: "1.0.0.0".to_owned(),
564 reference: None,
565 }
566}
567
568pub const DEFAULT_BRANCH_ALIAS: &str = "9999999-dev";
570
571pub fn parse_numeric_alias_prefix(branch: &str) -> Option<String> {
574 let n = branch.len();
575 if n < 4 || !branch.is_char_boundary(n - 4) || !branch[n - 4..].eq_ignore_ascii_case("-dev") {
576 return None;
577 }
578 let mut rest = &branch[..n - 4];
579 if let Some(r) = rest.strip_suffix(".x").or_else(|| rest.strip_suffix(".X")) {
580 rest = r;
581 }
582 let numeric = !rest.is_empty()
583 && rest
584 .split('.')
585 .all(|p| !p.is_empty() && p.bytes().all(|b| b.is_ascii_digit()));
586 numeric.then(|| format!("{rest}."))
587}
588
589fn pretty_alias(normalized: &str) -> String {
592 const X: &str = ".9999999";
593 let mut out = String::with_capacity(normalized.len());
594 let mut rest = normalized;
595 while let Some(i) = rest.find(X) {
596 out.push_str(&rest[..i]);
597 out.push_str(".x");
598 rest = &rest[i + X.len()..];
599 while let Some(r) = rest.strip_prefix(X) {
600 rest = r;
601 }
602 }
603 out.push_str(rest);
604 out
605}
606
607pub fn branch_alias_of(
615 version: &str,
616 extra: Option<&Value>,
617 default_branch: bool,
618) -> Option<(String, String)> {
619 if !(version.starts_with("dev-") || version.ends_with("-dev")) {
620 return None;
621 }
622 if let Some(map) = extra
623 .and_then(|e| e.get("branch-alias"))
624 .and_then(Value::as_object)
625 {
626 for (source, target) in map {
627 let Some(target) = target.as_str() else {
628 continue;
629 };
630 let Some(target_base) = target.strip_suffix("-dev") else {
631 continue;
632 };
633 let validated = if target == DEFAULT_BRANCH_ALIAS {
634 target.to_owned()
635 } else {
636 normalize_branch(target_base)
637 };
638 if !validated.ends_with("-dev") {
639 continue;
640 }
641 if version.to_lowercase() != source.to_lowercase() {
642 continue;
643 }
644 if let (Some(sp), Some(tp)) = (
645 parse_numeric_alias_prefix(source),
646 parse_numeric_alias_prefix(target),
647 ) {
648 if !tp.to_lowercase().starts_with(&sp.to_lowercase()) {
649 continue;
650 }
651 }
652 let pretty = pretty_alias(&validated);
653 return Some((validated, pretty));
654 }
655 }
656 if default_branch {
657 let v = version.strip_prefix('v').unwrap_or(version);
658 if parse_numeric_alias_prefix(v).is_none() {
659 return Some((
660 DEFAULT_BRANCH_ALIAS.to_owned(),
661 DEFAULT_BRANCH_ALIAS.to_owned(),
662 ));
663 }
664 }
665 None
666}
667
668pub fn branch_alias(manifest: &Value, root: &RootVersion) -> Option<(String, String)> {
671 let default_branch = manifest
672 .get("default-branch")
673 .and_then(Value::as_bool)
674 .unwrap_or(false);
675 branch_alias_of(&root.pretty_version, manifest.get("extra"), default_branch)
676}
677
678impl std::fmt::Display for UnsupportedVersionAlias {
679 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
680 write!(f, "{}", self.0)
681 }
682}
683#[derive(Debug)]
684pub struct UnsupportedVersionAlias(pub UnsupportedVersion);
685
686#[cfg(test)]
687mod tests {
688 use super::*;
689 use serde_json::json;
690
691 #[test]
692 fn normalize_branch_matches_semver() {
693 assert_eq!(normalize_branch("main"), "dev-main");
694 assert_eq!(normalize_branch("2.2"), "2.2.9999999.9999999-dev");
695 assert_eq!(normalize_branch("1.x"), "1.9999999.9999999.9999999-dev");
696 assert_eq!(normalize_branch("v3"), "3.9999999.9999999.9999999-dev");
697 assert_eq!(normalize_branch("feature/x"), "dev-feature/x");
698 }
699
700 #[test]
701 fn feature_branches() {
702 let m = json!({});
703 assert!(!is_feature_branch(&m, "main"));
704 assert!(!is_feature_branch(&m, "develop"));
705 assert!(!is_feature_branch(&m, "2.2"));
706 assert!(is_feature_branch(&m, "feature-x"));
707 let m = json!({"non-feature-branches": ["release-.*"]});
708 assert!(!is_feature_branch(&m, "release-1"));
709 assert!(is_feature_branch(&m, "feature-1"));
710 }
711
712 #[test]
713 fn collapse() {
714 assert_eq!(collapse_nines("2.2.9999999.9999999-dev"), "2.2.x-dev");
715 assert_eq!(collapse_nines("1.9999999.9999999.9999999-dev"), "1.x-dev");
716 }
717
718 #[test]
719 fn detect_in_git_repo() {
720 let tmp = tempfile::tempdir().expect("tmp");
721 let p = tmp.path();
722 let run = |args: &[&str]| {
723 let st = Command::new("git")
724 .args(args)
725 .current_dir(p)
726 .env("GIT_AUTHOR_NAME", "t")
727 .env("GIT_AUTHOR_EMAIL", "t@t")
728 .env("GIT_COMMITTER_NAME", "t")
729 .env("GIT_COMMITTER_EMAIL", "t@t")
730 .status()
731 .expect("git");
732 assert!(st.success(), "git {args:?}");
733 };
734 run(&["init", "-q", "-b", "main"]);
735 std::fs::write(p.join("a.txt"), "a").expect("write");
736 run(&["add", "."]);
737 run(&["commit", "-q", "-m", "init"]);
738 let r = detect(&json!({}), p);
739 assert_eq!(r.pretty_version, "dev-main");
740 assert_eq!(r.version, "dev-main");
741 assert_eq!(r.reference.as_deref().map(str::len), Some(40));
742
743 run(&["checkout", "-q", "-b", "feature-x"]);
745 std::fs::write(p.join("b.txt"), "b").expect("write");
746 run(&["add", "."]);
747 run(&["commit", "-q", "-m", "feat"]);
748 let r = detect(&json!({}), p);
749 assert_eq!(r.pretty_version, "dev-main");
750 let g = guess_version(&json!({}), p).expect("guess");
751 assert_eq!(g.feature_pretty_version.as_deref(), Some("dev-feature-x"));
752 assert_eq!(g.feature_version.as_deref(), Some("dev-feature-x"));
753 std::fs::create_dir_all(p.join("packages/x")).expect("mkdir");
755 let g = guess_version(&json!({}), &p.join("packages/x")).expect("guess");
756 assert_eq!(g.pretty_version, "dev-main");
757 assert_eq!(g.commit.as_deref().map(str::len), Some(40));
758
759 run(&["checkout", "-q", "-b", "2.2"]);
761 let m = json!({"extra": {"branch-alias": {"dev-2.2": "2.2.x-dev"}}});
762 let r = detect(&m, p);
763 assert_eq!(r.version, "2.2.9999999.9999999-dev");
764 assert_eq!(r.pretty_version, "2.2.x-dev");
765 assert_eq!(
766 branch_alias(&m, &r),
767 None,
768 "the alias is indexed by dev-2.2, not by the pretty version x-dev"
769 );
770
771 run(&["tag", "v1.2.3"]);
773 run(&["checkout", "-q", "--detach", "HEAD"]);
774 let r = detect(&json!({}), p);
775 assert_eq!(r.pretty_version, "v1.2.3");
776 assert_eq!(r.version, "1.2.3.0");
777
778 std::fs::write(p.join("c.txt"), "c").expect("write");
782 run(&["add", "."]);
783 run(&["commit", "-q", "-m", "detached"]);
784 assert!(guess_version(&json!({}), p).is_none());
785 assert_eq!(detect(&json!({}), p).pretty_version, DEFAULT_PRETTY_VERSION);
786 }
787}