///|
/// Where `.git` is.
///
/// Public because reading a checkout is this package's job: a caller that only
/// wants a ref or a commit object gets the layout from `discover` and asks,
/// instead of re-deriving the three directories itself.
pub(all) struct Repo {
/// `objects/`, `refs/` and `packed-refs` live here.
common_dir : String
/// `HEAD` lives here; differs from `common_dir` in a linked worktree.
git_dir : String
/// The checkout the search started from, for the index and the work tree.
work_dir : String
}
///|
/// `HEAD`, resolved.
priv enum Head {
/// A branch (`refs/heads/main`) and the commit it points at.
Branch(String, String)
/// A commit id, with no branch naming it.
Detached(String)
}
///|
/// The checkout at `cwd`, read straight out of the files `git` would read.
///
/// This is the whole point of the library: the machine that runs the software
/// has no `.git` and maybe no `git`, and the build machine should not need a
/// shell either. `HEAD`, the ref it names, `packed-refs`, `refs/tags/*`, the
/// commit object and the index are all plain files — that is what git reads
/// too.
///
/// `locate` comes back beside the value because it is the one thing worth
/// printing: "no repository" and "a `.git` that points nowhere" are different
/// mistakes, and a version string is baked into the artifact, not logged.
pub fn read_checkout(cwd : String) -> Checkout {
let repo = match discover(cwd) {
Some(repo) => repo
None => return { locate: Missing, repo: None, }
}
// A `HEAD` that names nothing is a half-written clone. Its refs are stale by
// definition, and a stale version number is worse than none.
let head = match repo.read_head() {
Some(head) => head
None => return { locate: Unreadable, repo: None, }
}
{ locate: Ok, repo: Some(Head::to_repo_info(head, repo)), }
}
///|
/// A checkout, or the reason there is none.
///
/// The reader does not build `GitInfo`: `gitinfo` imports this package for
/// `RepoInfo`, so the other direction would be an import loop. What comes back
/// is what it read; narrowing that to `GitInfo`, and trimming the line endings
/// git leaves in ref files, stays in `gitinfo`.
pub(all) struct Checkout {
locate : RepoLocate
/// `None` exactly when `locate` is not `Ok`.
repo : RepoInfo?
}
///|
pub fn Checkout::to_repo_info(self : Checkout) -> RepoInfo {
match self.repo {
Some(info) => info
None => RepoInfo::empty(locate=self.locate)
}
}
///|
/// Walk up from `cwd` to the first `.git`, the way git does.
///
/// Two shapes to handle: `/.git` as a directory, and `/.git` as a
/// file holding `gitdir: ` — a linked worktree (`/.git/worktrees/
/// `, which has a `commondir` file pointing back) or a submodule
/// (`.git/modules/`, which has none).
pub fn discover(cwd : String) -> Repo? {
let mut dir = normalize(cwd)
while true {
let candidate = join_path(dir, ".git")
if is_dir(candidate) {
return make_repo(candidate, dir)
}
if is_file(candidate) {
let pointer = read_text(candidate)
guard pointer.has_prefix("gitdir:") else { return None }
let target = normalize(join_path(dir, trim(pointer[7:])))
guard is_dir(target) else { return None }
return make_repo(target, dir)
}
let up = normalize(join_path(dir, ".."))
if up == dir {
break None
}
dir = up
} nobreak {
None
}
}
///|
fn make_repo(git_dir : String, work_dir : String) -> Repo? {
let mut common_dir = git_dir
let pointer = join_path(git_dir, "commondir")
if is_file(pointer) {
let target = read_text(pointer)
if !target.is_empty() {
common_dir = normalize(join_path(git_dir, target))
}
}
Some({ common_dir, git_dir, work_dir, })
}
///|
/// `HEAD` as a commit, and the ref that named it.
///
/// A symbolic `HEAD` is followed by hand: a repository fresh out of `git init`,
/// or a worktree that has not checked anything out, has `HEAD` naming a branch
/// whose file does not exist yet. That is a normal state, not an error.
fn Repo::read_head(self : Repo) -> Head? {
let text = read_text(join_path(self.git_dir, "HEAD"))
self.resolve_ref_text(text, depth=0)
}
///|
fn Repo::resolve_ref_text(self : Repo, text : String, depth~ : Int32) -> Head? {
guard depth < 5 else { return None }
if text.has_prefix("ref:") {
let name = trim(text[4:])
guard !name.is_empty() else { return None }
let commit = self.read_ref(name, depth)
guard !commit.is_empty() else { return None }
return Some(Branch(name, commit))
}
// Anything else that is not an object id is a `HEAD` this reader cannot use.
if text.length() >= 40 && is_hex(text[:40]) {
return Some(Detached(text))
}
None
}
///|
/// One ref: the loose file first, then `packed-refs`.
///
/// A ref that exists in both places is the loose one: `git pack-refs` deletes
/// the loose file when it packs, so the two agree, and an edit leaves the newer
/// value loose.
pub fn Repo::read_ref(self : Repo, name : String, depth : Int32) -> String {
let loose = read_text(join_path(self.common_dir, name))
if !loose.is_empty() {
// A ref file never holds `ref:`, but a broken one holding it is better
// followed than returned as if it were a commit id.
if loose.has_prefix("ref:") {
let next = trim(loose[4:])
guard depth < 5 && !next.is_empty() else { return "" }
return self.read_ref(next, depth + 1)
}
return loose
}
self.read_packed_ref(name)
}
///|
fn Repo::read_packed_ref(self : Repo, name : String) -> String {
for entry in self.packed_ref_entries() {
if entry.1 == name {
return entry.0
}
}
""
}
///|
/// `(commit, ref name)` for every entry of `packed-refs`, peeled tags
/// included: `^` belongs to the entry above it, which is how an
/// annotated tag comes to name a commit without the tag object being read.
fn Repo::packed_ref_entries(self : Repo) -> Array[(String, String)] {
let entries : Array[(String, String)] = []
let packed = read_text(join_path(self.common_dir, "packed-refs"))
let mut last = -1
for line in packed.split("\n") {
let entry = trim(line)
// `# pack-refs with: peeled fully-peeled` carries no ref of its own.
if entry.is_empty() || entry.has_prefix("#") {
continue
}
if entry.has_prefix("^") {
guard last >= 0 else { continue }
let (commit, name) = entries[last]
let _ = (commit, name)
entries[last] = (entry[1:].to_owned(), name)
continue
}
match entry.rev_find(" ") {
Some(index) => {
entries.push((entry[:index].to_owned(), entry[index + 1:].to_owned()))
last = entries.length() - 1
}
None => ()
}
}
entries
}
///|
/// Every tag pointing at `commit`, sorted by name: two tags on one commit are
/// equally exact, and the generated file may not shuffle between builds.
fn Repo::tags_at(self : Repo, commit : String) -> Array[String] {
let names : Array[String] = []
for name in self.list_refs("refs/tags") {
if self.read_ref(name, 0) == commit {
// `refs/tags/v1.0.0` -> `v1.0.0`.
names.push(name[10:].to_owned())
}
}
names.sort()
names
}
///|
/// Ref names below `prefix`, from `refs/` and from `packed-refs`.
fn Repo::list_refs(self : Repo, prefix : String) -> Array[String] {
let names : Array[String] = []
walk_refs(join_path(self.common_dir, prefix), prefix, names)
for entry in self.packed_ref_entries() {
let name = entry.1
if name.has_prefix(prefix + "/") && !names.contains(name) {
names.push(name)
}
}
names
}
///|
/// `dir` as a ref prefix. A ref is a file at the end of a path, so directories
/// are walked recursively; `.git/refs` is small enough for that to cost
/// nothing.
fn walk_refs(dir : String, prefix : String, names : Array[String]) -> Unit {
let entries = @fs.read_dir(dir) catch { _ => return }
for entry in entries {
let path = join_path(dir, entry)
let name = prefix + "/" + entry
if is_dir(path) {
walk_refs(path, name, names)
} else if is_file(path) {
names.push(name)
}
}
}
///|
fn Head::to_repo_info(self : Head, repo : Repo) -> RepoInfo {
let (branch, commit) = match self {
Branch(name, commit) => (short_branch(name), commit)
Detached(commit) => ("", commit)
}
let tag = match repo.tags_at(commit) {
[] => ""
tags => tags[0]
}
let commit_object = repo.read_commit(commit)
let (commit_date, author, email) = match commit_object {
Some(object) => (object.date, object.author, object.email)
None => ("", "", "")
}
let (ahead, behind) = match commit_object {
Some(object) => repo.tracking(branch, object)
None => (0, 0)
}
{
available: true,
locate: Ok,
branch,
tag,
describe: if !tag.is_empty() {
tag
} else {
short_id(commit)
},
commit,
short_commit: short_id(commit),
commit_date,
author,
email,
dirty: repo.is_dirty(),
ahead,
behind,
}
}
///|
/// `refs/heads/main` -> `main`.
fn short_branch(name : String) -> String {
let prefix = "refs/heads/"
if name.has_prefix(prefix) {
name[prefix.length():].to_owned()
} else {
name
}
}
///|
fn short_id(commit : String) -> String {
if commit.length() > 7 {
commit[:7].to_owned()
} else {
commit
}
}
///|
fn is_hex(text : StringView) -> Bool {
for ch in text {
let ok = (ch >= '0' && ch <= '9') ||
(ch >= 'a' && ch <= 'f') ||
(ch >= 'A' && ch <= 'F')
guard ok else { return false }
}
true
}
///|
/// A checkout read straight out of `.git`, before it is narrowed to a
/// `GitInfo`.
///
/// Everything here is a file's contents, produced by `gen` and understood by
/// this package: the generator knows where a ref lives, this side knows what a
/// ref means. `locate` matters because `.git` is a *file* in a worktree and in
/// a submodule, and a reader that walks into one and gives up looks exactly
/// like "no repository here".
pub(all) struct RepoInfo {
available : Bool
locate : RepoLocate
branch : String
tag : String
describe : String
commit : String
short_commit : String
commit_date : String
author : String
email : String
dirty : Bool
ahead : Int32
behind : Int32
} derive(Eq, Debug)
///|
#deprecated
pub extend RepoInfo with Eq::{not_equal, equal}
///|
#deprecated
pub extend RepoInfo with Debug::{to_repr}
///|
/// What the reader did with `.git`.
pub(all) enum RepoLocate {
/// Found it, and it is a work tree.
Ok
/// No `.git` on the way up.
Missing
/// `.git` is a file naming a directory that is not there — a worktree left
/// behind by a checkout that has been pruned.
Unreadable
} derive(Eq, Debug)
///|
#deprecated
pub extend RepoLocate with Eq::{not_equal, equal}
///|
#deprecated
pub extend RepoLocate with Debug::{to_repr}
///|
/// The "nothing here" value, with the reason it is empty.
pub fn RepoInfo::empty(locate~ : RepoLocate = Missing) -> RepoInfo {
{
available: false,
locate,
branch: "",
tag: "",
describe: "",
commit: "",
short_commit: "",
commit_date: "",
author: "",
email: "",
dirty: false,
ahead: 0,
behind: 0,
}
}