Skip to main content

vivacity_core/
root_version.rs

1//! Root package version: port of `RootPackageLoader::load` +
2//! `VersionGuesser::guessGitVersion` (docs/reference/RootPackageLoader.php,
3//! VersionGuesser.php, Composer 2.10.3). Order: `version` from composer.json,
4//! else `COMPOSER_ROOT_VERSION`, else git (current branch; detached HEAD ->
5//! `dev-<sha>` then exact tag; feature branch -> closest parent branch by
6//! `git rev-list`), else `1.0.0+no-version-set`.
7//! hg/fossil/svn are not ported (fallback to the default, as without a VCS).
8//!
9//! `guess_version` is the `VersionGuesser::guessVersion` used for the root
10//! and for the packages of a `path` repository: git is run in the directory
11//! with no `GIT_DIR` pin, so it walks up to the enclosing repository exactly
12//! as Composer's `git branch` does (a package without a repository of its
13//! own takes the project's branch).
14
15use crate::version::{normalize_pretty, UnsupportedVersion};
16use serde_json::Value;
17use std::path::Path;
18use std::process::Command;
19
20#[derive(Debug, Clone, PartialEq, Eq)]
21pub struct RootVersion {
22    pub pretty_version: String,
23    /// Normalised version (Composer's `version_normalized`).
24    pub version: String,
25    pub reference: Option<String>,
26}
27
28/// `VersionGuesser::guessVersion` result after `postprocess`: the version
29/// (the parent branch when HEAD is on a feature branch) and, on a feature
30/// branch, the feature branch itself (`feature_version`,
31/// `feature_pretty_version`) — a `path` repository loads both as packages.
32#[derive(Debug, Clone, PartialEq, Eq)]
33pub struct GuessedVersion {
34    pub version: String,
35    pub pretty_version: String,
36    pub commit: Option<String>,
37    pub feature_version: Option<String>,
38    pub feature_pretty_version: Option<String>,
39}
40
41pub const DEFAULT_PRETTY_VERSION: &str = "1.0.0+no-version-set";
42
43fn normalize_or_raw(v: &str) -> String {
44    normalize_pretty(v).unwrap_or_else(|_| v.to_owned())
45}
46
47/// `VersionParser::normalizeBranch` (composer/semver): `1.2` -> numeric
48/// `1.2.x.x-dev` (x -> 9999999), else `dev-<name>`.
49pub fn normalize_branch(name: &str) -> String {
50    let name = name.trim();
51    let stripped = name
52        .strip_prefix('v')
53        .or_else(|| name.strip_prefix('V'))
54        .unwrap_or(name);
55    let parts: Vec<&str> = stripped.split('.').collect();
56    let numeric_or_x = |s: &str| {
57        !s.is_empty() && (s.bytes().all(|b| b.is_ascii_digit()) || matches!(s, "x" | "X" | "*"))
58    };
59    if (1..=4).contains(&parts.len())
60        && parts[0].bytes().all(|b| b.is_ascii_digit())
61        && !parts[0].is_empty()
62        && parts[1..].iter().all(|p| numeric_or_x(p))
63    {
64        let mut out: Vec<String> = parts
65            .iter()
66            .map(|p| {
67                if matches!(*p, "x" | "X" | "*") {
68                    "9999999".to_owned()
69                } else {
70                    (*p).to_owned()
71                }
72            })
73            .collect();
74        while out.len() < 4 {
75            out.push("9999999".to_owned());
76        }
77        return format!("{}-dev", out.join("."));
78    }
79    format!("dev-{name}")
80}
81
82/// `VersionGuesser::isFeatureBranch`: the `non-feature-branches` entries
83/// are regex alternatives (`release-.*`), joined in front of the built-in
84/// names and the numeric `\d+\..+` form.
85fn is_feature_branch(manifest: &Value, branch: &str) -> bool {
86    let custom: Vec<&str> = manifest
87        .get("non-feature-branches")
88        .and_then(Value::as_array)
89        .map(|a| a.iter().filter_map(Value::as_str).collect())
90        .unwrap_or_default();
91    let mut pattern = String::from("^(");
92    if !custom.is_empty() {
93        pattern.push_str(&custom.join("|"));
94    }
95    pattern
96        .push_str("|master|main|latest|next|current|support|tip|trunk|default|develop|\\d+\\..+)$");
97    match pcre2::bytes::RegexBuilder::new().build(&pattern) {
98        Ok(re) => !re.is_match(branch.as_bytes()).unwrap_or(false),
99        // An invalid custom pattern: Composer's preg_match warns and
100        // returns false -> every branch is a feature branch.
101        Err(_) => true,
102    }
103}
104
105/// Runs git in `dir` like `ProcessExecutor` after `GitUtil::cleanEnv`: no
106/// `GIT_DIR`/`GIT_WORK_TREE` (git finds the repository by walking up),
107/// English output, never an interactive prompt. None when git fails.
108pub fn git(dir: &Path, args: &[&str]) -> Option<String> {
109    let out = Command::new("git")
110        .args(args)
111        .current_dir(dir)
112        .env_remove("GIT_DIR")
113        .env_remove("GIT_WORK_TREE")
114        .env_remove("GIT_INDEX_FILE")
115        .env("LANGUAGE", "C")
116        .env("LC_ALL", "C")
117        .env("GIT_TERMINAL_PROMPT", "0")
118        .output()
119        .ok()?;
120    if !out.status.success() {
121        return None;
122    }
123    Some(String::from_utf8_lossy(&out.stdout).into_owned())
124}
125
126/// `VersionGuesser::guessVersion` (git only) + `postprocess`.
127pub fn guess_version(manifest: &Value, project: &Path) -> Option<GuessedVersion> {
128    let output = git(
129        project,
130        &["branch", "-a", "--no-color", "--no-abbrev", "-v"],
131    )?;
132    let mut version: Option<String> = None;
133    let mut pretty: Option<String> = None;
134    let mut commit: Option<String> = None;
135    let mut is_feature = false;
136    let mut is_detached = false;
137    let mut branches: Vec<String> = Vec::new();
138    let is_hex = |s: &str| !s.is_empty() && s.bytes().all(|b| b.is_ascii_hexdigit());
139
140    for line in output.lines() {
141        if line.is_empty() {
142            continue;
143        }
144        // Current line: `* <name|(no branch)|(HEAD detached at X)> <sha> ...`
145        if let Some(rest) = line.strip_prefix("* ") {
146            let rest = rest.trim_start();
147            let (name, tail) = if rest.starts_with('(') {
148                match rest.find(')') {
149                    Some(i) => (&rest[..=i], rest[i + 1..].trim_start()),
150                    None => continue,
151                }
152            } else {
153                match rest.find(' ') {
154                    Some(i) => (&rest[..i], rest[i..].trim_start()),
155                    None => continue,
156                }
157            };
158            let sha = tail.split_whitespace().next().unwrap_or("");
159            if !is_hex(sha) {
160                continue;
161            }
162            if name.starts_with('(') {
163                // The regex only knows `(no branch)`, `(detached from X)` and
164                // `(HEAD detached at X)`: any other parenthesised form (`(HEAD
165                // detached from X)`) matches nothing, and the version stays
166                // unknown (tag lookup, then the caller's default).
167                if !(name == "(no branch)"
168                    || name.starts_with("(detached from ")
169                    || name.starts_with("(HEAD detached at "))
170                {
171                    continue;
172                }
173                version = Some(format!("dev-{sha}"));
174                pretty = version.clone();
175                is_feature = true;
176                is_detached = true;
177            } else {
178                version = Some(normalize_branch(name));
179                pretty = Some(format!("dev-{name}"));
180                is_feature = is_feature_branch(manifest, name);
181            }
182            commit = Some(sha.to_owned());
183        }
184        // Candidates: `[* ] <name|remotes/origin/name> <sha>` (name without `/`), excluding `*/HEAD`.
185        let trimmed = line.trim_start_matches("* ").trim_start();
186        let mut parts = trimmed.split_whitespace();
187        let (Some(name), Some(sha)) = (parts.next(), parts.next()) else {
188            continue;
189        };
190        if name.ends_with("/HEAD") || !is_hex(sha) {
191            continue;
192        }
193        let bare = name
194            .strip_prefix("remotes/origin/")
195            .or_else(|| name.strip_prefix("remotes/upstream/"))
196            .unwrap_or(name);
197        if bare.contains('/') {
198            continue;
199        }
200        branches.push(name.to_owned());
201    }
202
203    let mut feature: Option<(String, String)> = None;
204    if is_feature {
205        if let (Some(v), Some(p)) = (&version, &pretty) {
206            feature = Some((v.clone(), p.clone()));
207            let (nv, np) = guess_feature_version(manifest, v, &branches, project);
208            version = Some(nv);
209            pretty = Some(np);
210        }
211    }
212    if version.is_none() || is_detached {
213        if let Some(tag) = git(project, &["describe", "--exact-match", "--tags"]) {
214            let tag = tag.trim();
215            if let Ok(norm) = normalize_pretty(tag) {
216                version = Some(norm);
217                pretty = Some(tag.to_owned());
218                feature = None;
219            }
220        }
221    }
222    if commit.is_none() {
223        if let Some(out) = git(project, &["rev-list", "--format=%H", "-n1", "HEAD"]) {
224            commit = out
225                .lines()
226                .find(|l| !l.starts_with("commit "))
227                .map(|l| l.trim().to_owned())
228                .filter(|s| !s.is_empty());
229        }
230    }
231    let version = version?;
232    let pretty = pretty?;
233    // postprocess: a feature branch equal to its guess is dropped;
234    // `X.9999999...-dev` displays as `X.x-dev`.
235    let feature = feature.filter(|(fv, fp)| !(fv == &version && fp == &pretty));
236    let collapse = |version: &str, pretty: String| {
237        if version.ends_with("-dev") && version.contains(".9999999") {
238            collapse_nines(version)
239        } else {
240            pretty
241        }
242    };
243    let pretty = collapse(&version, pretty);
244    let (feature_version, feature_pretty_version) = match feature {
245        Some((fv, fp)) => {
246            let fp = collapse(&fv, fp);
247            (Some(fv), Some(fp))
248        }
249        None => (None, None),
250    };
251    Some(GuessedVersion {
252        version,
253        pretty_version: pretty,
254        commit,
255        feature_version,
256        feature_pretty_version,
257    })
258}
259
260/// `guessGitVersion` for the root package.
261fn guess_git(manifest: &Value, project: &Path) -> Option<RootVersion> {
262    let g = guess_version(manifest, project)?;
263    Some(RootVersion {
264        pretty_version: g.pretty_version,
265        version: g.version,
266        reference: g.commit,
267    })
268}
269
270fn collapse_nines(version: &str) -> String {
271    let mut out = version.replace(".9999999", "\u{0}");
272    while out.contains("\u{0}\u{0}") {
273        out = out.replace("\u{0}\u{0}", "\u{0}");
274    }
275    out.replace('\u{0}', ".x")
276}
277
278/// `guessFeatureVersion` with `git rev-list %candidate%..%branch%`: the
279/// non-feature parent branch with the shortest delta wins.
280fn guess_feature_version(
281    manifest: &Value,
282    version: &str,
283    branches: &[String],
284    project: &Path,
285) -> (String, String) {
286    let has_alias = manifest
287        .get("extra")
288        .and_then(|e| e.get("branch-alias"))
289        .and_then(|b| b.get(version))
290        .is_some();
291    let has_self_version = manifest.to_string().contains("\"self.version\"");
292    if has_alias && !has_self_version {
293        return (version.to_owned(), version.to_owned());
294    }
295    let branch = version.strip_prefix("dev-").unwrap_or(version).to_owned();
296    if !is_feature_branch(manifest, &branch) {
297        return (version.to_owned(), version.to_owned());
298    }
299    let mut sorted: Vec<String> = branches.to_vec();
300    sorted.sort_by(|a, b| {
301        let (ar, br) = (a.starts_with("remotes/"), b.starts_with("remotes/"));
302        if ar != br {
303            return if ar {
304                std::cmp::Ordering::Greater
305            } else {
306                std::cmp::Ordering::Less
307            };
308        }
309        strnatcasecmp(b, a)
310    });
311    let mut best_len = usize::MAX;
312    let mut result = (version.to_owned(), version.to_owned());
313    for candidate in &sorted {
314        let candidate_version = candidate
315            .strip_prefix("remotes/")
316            .and_then(|r| r.split_once('/').map(|x| x.1))
317            .unwrap_or(candidate);
318        if candidate == &branch || is_feature_branch(manifest, candidate_version) {
319            continue;
320        }
321        let Some(out) = git(project, &["rev-list", &format!("{candidate}..{branch}")]) else {
322            continue;
323        };
324        // At equal length, a candidate later in the order replaces the previous one.
325        if out.len() <= best_len {
326            best_len = out.len();
327            result = (
328                normalize_branch(candidate_version),
329                format!("dev-{candidate_version}"),
330            );
331            if best_len == 0 {
332                break;
333            }
334        }
335    }
336    result
337}
338
339/// Minimal strnatcasecmp (same rules as vivacity-autoload::natsort, duplicated
340/// to avoid a cross dependency); enough to sort branch names.
341fn strnatcasecmp(a: &str, b: &str) -> std::cmp::Ordering {
342    let (a, b) = (a.to_ascii_lowercase(), b.to_ascii_lowercase());
343    let (ab, bb) = (a.as_bytes(), b.as_bytes());
344    let (mut i, mut j) = (0, 0);
345    while i < ab.len() && j < bb.len() {
346        if ab[i].is_ascii_digit() && bb[j].is_ascii_digit() {
347            let si = i;
348            while i < ab.len() && ab[i].is_ascii_digit() {
349                i += 1;
350            }
351            let sj = j;
352            while j < bb.len() && bb[j].is_ascii_digit() {
353                j += 1;
354            }
355            let na: u128 = a[si..i].parse().unwrap_or(0);
356            let nb: u128 = b[sj..j].parse().unwrap_or(0);
357            if na != nb {
358                return na.cmp(&nb);
359            }
360        } else {
361            if ab[i] != bb[j] {
362                return ab[i].cmp(&bb[j]);
363            }
364            i += 1;
365            j += 1;
366        }
367    }
368    (ab.len() - i).cmp(&(bb.len() - j))
369}
370
371/// `VersionGuesser::getRootVersionFromEnv`: `COMPOSER_ROOT_VERSION` when
372/// set and non-empty, `1.2-dev` spelled `1.2.x-dev`.
373pub fn root_version_from_env() -> Option<String> {
374    let env = std::env::var("COMPOSER_ROOT_VERSION").ok()?;
375    // `if (Platform::getEnv(...))`: `0` is as falsy as an empty string.
376    if env.is_empty() || env == "0" {
377        return None;
378    }
379    let lower = env.to_ascii_lowercase();
380    Some(match lower.strip_suffix("-dev") {
381        Some(num)
382            if !num.is_empty()
383                && num
384                    .split('.')
385                    .all(|p| !p.is_empty() && p.bytes().all(|b| b.is_ascii_digit())) =>
386        {
387            format!("{}.x-dev", &env[..num.len()])
388        }
389        _ => env.clone(),
390    })
391}
392
393/// Determines the root version like RootPackageLoader.
394pub fn detect(manifest: &Value, project: &Path) -> RootVersion {
395    if let Some(v) = manifest.get("version").and_then(Value::as_str) {
396        return RootVersion {
397            pretty_version: v.to_owned(),
398            version: normalize_or_raw(v),
399            reference: None,
400        };
401    }
402    if let Some(v) = root_version_from_env() {
403        return RootVersion {
404            pretty_version: v.clone(),
405            version: normalize_or_raw(&v),
406            reference: None,
407        };
408    }
409    if let Some(g) = guess_git(manifest, project) {
410        return g;
411    }
412    RootVersion {
413        pretty_version: DEFAULT_PRETTY_VERSION.to_owned(),
414        version: "1.0.0.0".to_owned(),
415        reference: None,
416    }
417}
418
419/// `VersionParser::DEFAULT_BRANCH_ALIAS`.
420pub const DEFAULT_BRANCH_ALIAS: &str = "9999999-dev";
421
422/// `VersionParser::parseNumericAliasPrefix` (composer/semver): `1.2.x-dev` and
423/// `1.2-dev` -> `1.2.`, else None. Case-insensitive like the PCRE pattern.
424pub fn parse_numeric_alias_prefix(branch: &str) -> Option<String> {
425    let n = branch.len();
426    if n < 4 || !branch.is_char_boundary(n - 4) || !branch[n - 4..].eq_ignore_ascii_case("-dev") {
427        return None;
428    }
429    let mut rest = &branch[..n - 4];
430    if let Some(r) = rest.strip_suffix(".x").or_else(|| rest.strip_suffix(".X")) {
431        rest = r;
432    }
433    let numeric = !rest.is_empty()
434        && rest
435            .split('.')
436            .all(|p| !p.is_empty() && p.bytes().all(|b| b.is_ascii_digit()));
437    numeric.then(|| format!("{rest}."))
438}
439
440/// Pretty version of a normalised alias, like ArrayLoader:
441/// `preg_replace('{(\.9{7})+}', '.x', ...)`.
442fn pretty_alias(normalized: &str) -> String {
443    const X: &str = ".9999999";
444    let mut out = String::with_capacity(normalized.len());
445    let mut rest = normalized;
446    while let Some(i) = rest.find(X) {
447        out.push_str(&rest[..i]);
448        out.push_str(".x");
449        rest = &rest[i + X.len()..];
450        while let Some(r) = rest.strip_prefix(X) {
451            rest = r;
452        }
453    }
454    out.push_str(rest);
455    out
456}
457
458/// `ArrayLoader::getBranchAlias` (Composer 2.10.3): the alias Composer
459/// attaches to a package (root or locked) whose version is a branch (`dev-*`
460/// or `*-dev`). `extra.branch-alias` takes priority (`-dev` target,
461/// normalised by normalizeBranch, source equal to the version ignoring case,
462/// compatible numeric prefix), else `9999999-dev` if `default-branch` is
463/// true and the version has no numeric prefix.
464/// Returns (normalised alias, pretty alias); the pretty one is installed.php's.
465pub fn branch_alias_of(
466    version: &str,
467    extra: Option<&Value>,
468    default_branch: bool,
469) -> Option<(String, String)> {
470    if !(version.starts_with("dev-") || version.ends_with("-dev")) {
471        return None;
472    }
473    if let Some(map) = extra
474        .and_then(|e| e.get("branch-alias"))
475        .and_then(Value::as_object)
476    {
477        for (source, target) in map {
478            let Some(target) = target.as_str() else {
479                continue;
480            };
481            let Some(target_base) = target.strip_suffix("-dev") else {
482                continue;
483            };
484            let validated = if target == DEFAULT_BRANCH_ALIAS {
485                target.to_owned()
486            } else {
487                normalize_branch(target_base)
488            };
489            if !validated.ends_with("-dev") {
490                continue;
491            }
492            if version.to_lowercase() != source.to_lowercase() {
493                continue;
494            }
495            if let (Some(sp), Some(tp)) = (
496                parse_numeric_alias_prefix(source),
497                parse_numeric_alias_prefix(target),
498            ) {
499                if !tp.to_lowercase().starts_with(&sp.to_lowercase()) {
500                    continue;
501                }
502            }
503            let pretty = pretty_alias(&validated);
504            return Some((validated, pretty));
505        }
506    }
507    if default_branch {
508        let v = version.strip_prefix('v').unwrap_or(version);
509        if parse_numeric_alias_prefix(v).is_none() {
510            return Some((
511                DEFAULT_BRANCH_ALIAS.to_owned(),
512                DEFAULT_BRANCH_ALIAS.to_owned(),
513            ));
514        }
515    }
516    None
517}
518
519/// Branch alias of the root: getBranchAlias on the composer.json, the version
520/// being the pretty version retained by RootPackageLoader.
521pub fn branch_alias(manifest: &Value, root: &RootVersion) -> Option<(String, String)> {
522    let default_branch = manifest
523        .get("default-branch")
524        .and_then(Value::as_bool)
525        .unwrap_or(false);
526    branch_alias_of(&root.pretty_version, manifest.get("extra"), default_branch)
527}
528
529impl std::fmt::Display for UnsupportedVersionAlias {
530    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
531        write!(f, "{}", self.0)
532    }
533}
534#[derive(Debug)]
535pub struct UnsupportedVersionAlias(pub UnsupportedVersion);
536
537#[cfg(test)]
538mod tests {
539    use super::*;
540    use serde_json::json;
541
542    #[test]
543    fn normalize_branch_matches_semver() {
544        assert_eq!(normalize_branch("main"), "dev-main");
545        assert_eq!(normalize_branch("2.2"), "2.2.9999999.9999999-dev");
546        assert_eq!(normalize_branch("1.x"), "1.9999999.9999999.9999999-dev");
547        assert_eq!(normalize_branch("v3"), "3.9999999.9999999.9999999-dev");
548        assert_eq!(normalize_branch("feature/x"), "dev-feature/x");
549    }
550
551    #[test]
552    fn feature_branches() {
553        let m = json!({});
554        assert!(!is_feature_branch(&m, "main"));
555        assert!(!is_feature_branch(&m, "develop"));
556        assert!(!is_feature_branch(&m, "2.2"));
557        assert!(is_feature_branch(&m, "feature-x"));
558        let m = json!({"non-feature-branches": ["release-.*"]});
559        assert!(!is_feature_branch(&m, "release-1"));
560        assert!(is_feature_branch(&m, "feature-1"));
561    }
562
563    #[test]
564    fn collapse() {
565        assert_eq!(collapse_nines("2.2.9999999.9999999-dev"), "2.2.x-dev");
566        assert_eq!(collapse_nines("1.9999999.9999999.9999999-dev"), "1.x-dev");
567    }
568
569    #[test]
570    fn detect_in_git_repo() {
571        let tmp = tempfile::tempdir().expect("tmp");
572        let p = tmp.path();
573        let run = |args: &[&str]| {
574            let st = Command::new("git")
575                .args(args)
576                .current_dir(p)
577                .env("GIT_AUTHOR_NAME", "t")
578                .env("GIT_AUTHOR_EMAIL", "t@t")
579                .env("GIT_COMMITTER_NAME", "t")
580                .env("GIT_COMMITTER_EMAIL", "t@t")
581                .status()
582                .expect("git");
583            assert!(st.success(), "git {args:?}");
584        };
585        run(&["init", "-q", "-b", "main"]);
586        std::fs::write(p.join("a.txt"), "a").expect("write");
587        run(&["add", "."]);
588        run(&["commit", "-q", "-m", "init"]);
589        let r = detect(&json!({}), p);
590        assert_eq!(r.pretty_version, "dev-main");
591        assert_eq!(r.version, "dev-main");
592        assert_eq!(r.reference.as_deref().map(str::len), Some(40));
593
594        // Feature branch: the parent (main) is retained.
595        run(&["checkout", "-q", "-b", "feature-x"]);
596        std::fs::write(p.join("b.txt"), "b").expect("write");
597        run(&["add", "."]);
598        run(&["commit", "-q", "-m", "feat"]);
599        let r = detect(&json!({}), p);
600        assert_eq!(r.pretty_version, "dev-main");
601        let g = guess_version(&json!({}), p).expect("guess");
602        assert_eq!(g.feature_pretty_version.as_deref(), Some("dev-feature-x"));
603        assert_eq!(g.feature_version.as_deref(), Some("dev-feature-x"));
604        // A sub-directory without a repository of its own: git walks up.
605        std::fs::create_dir_all(p.join("packages/x")).expect("mkdir");
606        let g = guess_version(&json!({}), &p.join("packages/x")).expect("guess");
607        assert_eq!(g.pretty_version, "dev-main");
608        assert_eq!(g.commit.as_deref().map(str::len), Some(40));
609
610        // Numeric branch + alias.
611        run(&["checkout", "-q", "-b", "2.2"]);
612        let m = json!({"extra": {"branch-alias": {"dev-2.2": "2.2.x-dev"}}});
613        let r = detect(&m, p);
614        assert_eq!(r.version, "2.2.9999999.9999999-dev");
615        assert_eq!(r.pretty_version, "2.2.x-dev");
616        assert_eq!(
617            branch_alias(&m, &r),
618            None,
619            "the alias is indexed by dev-2.2, not by the pretty version x-dev"
620        );
621
622        // Exact tag on detached HEAD.
623        run(&["tag", "v1.2.3"]);
624        run(&["checkout", "-q", "--detach", "HEAD"]);
625        let r = detect(&json!({}), p);
626        assert_eq!(r.pretty_version, "v1.2.3");
627        assert_eq!(r.version, "1.2.3.0");
628
629        // A commit on the detached HEAD: `* (HEAD detached from v1.2.3)`
630        // matches none of the forms Composer's regex knows, no tag matches
631        // either: no version (the callers fall back to their default).
632        std::fs::write(p.join("c.txt"), "c").expect("write");
633        run(&["add", "."]);
634        run(&["commit", "-q", "-m", "detached"]);
635        assert!(guess_version(&json!({}), p).is_none());
636        assert_eq!(detect(&json!({}), p).pretty_version, DEFAULT_PRETTY_VERSION);
637    }
638}