///|
/// Whether tracked files differ from what `HEAD` recorded.
///
/// `git status --porcelain --untracked-files=no` without the process. The index
/// already carries the blob id git found when the file was last staged, so a
/// tracked file is clean exactly when its content still hashes to that id —
/// `git diff-files` asked by hand, and the only comparison that cannot be
/// fooled by a copied timestamps. Untracked files are ignored on purpose:
/// `generated.mbt` is committed, and writing it must not mark every build
/// dirty.
///
/// The cost is hashing every tracked file. A module is a few hundred files and
/// the generator runs once per `moon.mod` change, so that is cheaper than the
/// `stat`-based shortcut plus the `mtime` races it introduces.
pub fn Repo::is_dirty(self : Repo) -> Bool {
  let index = match read_index(join_path(self.git_dir, "index")) {
    Some(entries) => entries
    None => return false
  }
  for entry in index {
    // A symlink hashes its target path, and `@fs` follows links rather than
    // reading them, so this entry cannot be checked here. Skipping it keeps a
    // clean checkout clean; the cost is that editing a link's own target is
    // missed, which is the one case a `git status` on the same files would
    // also have to re-read.
    if entry.mode == 0o120000 {
      continue
    }
    let path = join_path(self.work_dir, entry.path)
    // A deleted file is a change; so is one whose content no longer matches.
    let content = @fs.read_file_to_bytes(path) catch { _ => return true }
    if @core.blob_id(content) != entry.blob {
      return true
    }
  }
  false
}

///|
/// One entry of `.git/index`: the path, the blob it was staged as, and the
/// mode that tells a symlink from a regular file.
priv struct IndexEntry {
  path : String
  /// 40 hex characters, lower case.
  blob : String
  /// `0o120000` for a symlink, `0o100644` / `0o100755` for a file.
  mode : Int32
}

///|
/// `.git/index`, version 2 or 3.
///
/// Version 4 delta-compresses paths and is left out: it only appears when
/// someone sets `index.version=4`, and "not checked" beats half-reading it.
fn read_index(path : String) -> Array[IndexEntry]? {
  let bytes = @fs.read_file_to_bytes(path) catch { _ => return None }
  guard bytes.length() >= 12 else { return None }
  guard bytes[0] == b'D' &&
    bytes[1] == b'I' &&
    bytes[2] == b'R' &&
    bytes[3] == b'C' else {
    return None
  }
  let version = read_u32(bytes, 4)
  guard version == 2 || version == 3 else { return None }
  let count = read_u32(bytes, 8)
  let entries : Array[IndexEntry] = []
  let mut pos = 12
  for _ in 0.. String {
  let out = StringBuilder()
  for i in 0..> 4) & 0xF))
    out.write_char(@core.hex_digit(byte & 0xF))
  }
  out.to_string()
}

///|
/// `bytes[at..at+4]`, big-endian: `.git/index` is network byte order, unlike
/// the commit object's dates.
///
/// Folded as `high * 65536 + low` rather than shifted: under wasm-gc `Int` is
/// 64-bit, and a shift amount that is itself an `Int` is sized by that width,
/// which is one way to land the bytes on the wrong end.
fn read_u32(bytes : Bytes, at : Int32) -> Int32 {
  let high = read_u16(bytes, at)
  let low = read_u16(bytes, at + 2)
  let hi = high * 65536
  hi + low
}

///|
fn read_u16(bytes : Bytes, at : Int32) -> Int32 {
  let first : Int32 = bytes[at].to_int()
  let second : Int32 = bytes[at + 1].to_int()
  let scaled = first * 256
  scaled + second
}

///|
/// How far `HEAD` is ahead of, and behind, the branch it tracks.
///
/// `rev-list --left-right --count HEAD...@{upstream}` without the process: walk
/// both histories by first parent, mark what `HEAD` reaches, then count what
/// the upstream reaches that `HEAD` does not (behind) and the other way round
/// (ahead). `(0, 0)` when the branch tracks nothing — a branch nobody tracks is
/// neither ahead nor behind, which is what git reports too.
fn Repo::tracking(
  self : Repo,
  branch : String,
  head : Commit,
) -> (Int32, Int32) {
  guard !branch.is_empty() else { return (0, 0) }
  let upstream = match self.upstream_of(branch) {
    Some(name) => self.read_ref(name, 0)
    None => return (0, 0)
  }
  guard !upstream.is_empty() else { return (0, 0) }
  let theirs = match self.read_commit(upstream) {
    Some(commit) => commit
    None => return (0, 0)
  }
  let mine = self.walk(head.first_parent)
  let ours = self.walk(theirs.first_parent)
  let mut ahead = 0
  for id, _ in mine {
    if !ours.contains(id) {
      ahead = ahead + 1
    }
  }
  let mut behind = 0
  for id, _ in ours {
    if !mine.contains(id) {
      behind = behind + 1
    }
  }
  (ahead, behind)
}

///|
/// Every commit id reachable from `start` by first parent.
///
/// First parent only: `ahead` / `behind` are asked of a branch, and a branch
/// with merges is still one line of commits as far as its own history goes.
/// Stops at the repository root, at an unreadable object, and at `max_depth`, so
/// a corrupt or enormous history cannot make a build hang.
fn Repo::walk(self : Repo, start : String) -> Map[String, Unit] {
  let seen : Map[String, Unit] = Map([])
  let mut next = start
  let mut depth = 0
  while !next.is_empty() && depth < 10000 {
    if seen.contains(next) {
      break
    }
    seen[next] = ()
    let commit = match self.read_commit(next) {
      Some(commit) => commit
      None => break
    }
    next = commit.first_parent
    depth = depth + 1
  }
  seen
}

///|
/// The branch `branch` tracks, from `.git/config`.
///
/// `.git/config` is an INI file, and the one key that matters is
/// `branch..merge` under the section `[branch ""]`; `remote` says
/// which remote's refs to look under, defaulting to the one named in
/// `branch..remote` (usually `origin`).
fn Repo::upstream_of(self : Repo, branch : String) -> String? {
  let config = read_text(join_path(self.common_dir, "config"))
  let section = "branch \"" + branch + "\""
  let mut in_section = false
  let mut remote = ""
  let mut merge = ""
  for line in config.split("\n") {
    let entry = trim(line)
    if entry.is_empty() || entry.has_prefix("#") || entry.has_prefix(";") {
      continue
    }
    if entry.has_prefix("[") {
      in_section = entry[1:entry.length() - 1]
        .to_owned()
        .replace_all(old="\"", new="\"") ==
        section
      continue
    }
    guard in_section else { continue }
    let (key, value) = match entry.find("=") {
      Some(index) =>
        (trim(entry[:index]).to_lower(), trim(entry[index + 1:].to_owned()))
      None => continue
    }
    if key == "merge" {
      merge = value
    } else if key == "remote" {
      remote = value
    }
  }
  guard !merge.is_empty() else { return None }
  // `refs/heads/main` on `origin` -> `refs/remotes/origin/main`.
  let head = "refs/heads/"
  let name = if merge.has_prefix(head) {
    merge[head.length():].to_owned()
  } else {
    merge
  }
  if remote.is_empty() || remote == "." {
    Some("refs/heads/" + name)
  } else {
    Some("refs/remotes/" + remote + "/" + name)
  }
}