///| Repository maintenance: repack + gc
///|
pub struct RepackResult {
pack_id : @bit.ObjectId
object_count : Int
pack_bytes : Int
}
///|
pub struct GcResult {
pack : RepackResult?
dangling : Array[@bit.ObjectId]
}
///|
pub struct PruneResult {
pruned : Array[@bit.ObjectId]
}
///|
/// Resolve the git dir that owns refs and objects for maintenance. Follows a
/// `.git` gitfile (linked worktree / submodule) and the worktree "commondir"
/// indirection so gc always sees the shared repository's refs; also accepts a
/// bare repository root.
fn gc_resolve_git_dir(fs : &@bit.RepoFileSystem, root : String) -> String {
let marker = join_path(root, ".git")
if !fs.is_dir(marker) &&
!fs.is_file(marker) &&
fs.is_dir(join_path(root, "objects")) &&
fs.is_dir(join_path(root, "refs")) {
return root
}
let git_dir = resolve_gitdir(fs, marker)
match resolve_ref_commondir(fs, git_dir) {
Some(common) => common
None => git_dir
}
}
///|
/// Repack reachable objects from refs under the repository root.
pub fn repack_repo(
fs : &@bit.FileSystem,
rfs : &@bit.RepoFileSystem,
root : String,
) -> RepackResult? raise @bit.GitError {
repack_git_dir(fs, rfs, gc_resolve_git_dir(rfs, root))
}
///|
/// Run a lightweight gc: repack reachable objects and report unreachable ids.
pub fn gc_repo(
fs : &@bit.FileSystem,
rfs : &@bit.RepoFileSystem,
root : String,
) -> GcResult raise @bit.GitError {
gc_git_dir(fs, rfs, gc_resolve_git_dir(rfs, root))
}
///|
/// Prune unreachable loose objects (skip if there are no refs).
pub fn prune_repo(
fs : &@bit.FileSystem,
rfs : &@bit.RepoFileSystem,
root : String,
) -> PruneResult raise @bit.GitError {
prune_git_dir(fs, rfs, gc_resolve_git_dir(rfs, root))
}
///|
fn repack_git_dir(
fs : &@bit.FileSystem,
rfs : &@bit.RepoFileSystem,
git_dir : String,
) -> RepackResult? raise @bit.GitError {
let ref_ids = collect_ref_ids(rfs, git_dir)
if ref_ids.length() == 0 {
return None
}
let db = ObjectDb::load(rfs, git_dir)
let roots = resolve_commit_roots(db, rfs, ref_ids)
if roots.length() == 0 {
return None
}
let objects = collect_reachable_objects_from_commits(db, rfs, roots)
collect_reachable_tag_objects(db, rfs, ref_ids, objects)
// Reflog-referenced objects are roots too (git keeps them); repacking without
// them would drop reflog-only objects from the new pack.
collect_reflog_referenced_objects(db, rfs, git_dir, objects)
repack_from_objects(fs, git_dir, objects)
}
///|
fn gc_git_dir(
fs : &@bit.FileSystem,
rfs : &@bit.RepoFileSystem,
git_dir : String,
) -> GcResult raise @bit.GitError {
let ref_ids = collect_ref_ids(rfs, git_dir)
let db = ObjectDb::load(rfs, git_dir)
let roots = resolve_commit_roots(db, rfs, ref_ids)
let prune_enabled = roots.length() > 0
let reachable = if roots.length() == 0 {
[]
} else {
collect_reachable_objects_from_commits(db, rfs, roots)
}
// Also include tag objects that refs point to directly (annotated tags).
collect_reachable_tag_objects(db, rfs, ref_ids, reachable)
// Also include objects referenced by reflogs (intermediate merge commits etc.)
collect_reflog_referenced_objects(db, rfs, git_dir, reachable)
let reachable_hex : Map[String, Bool] = Map([])
for obj in reachable {
let id = @bit.hash_object_content(obj.obj_type, obj.data)
reachable_hex[id.to_hex()] = true
}
let store = load_object_store_from_fs(rfs, git_dir)
let unreachable_hex : Array[String] = []
for hex in store.objects.keys() {
if !reachable_hex.contains(hex) {
unreachable_hex.push(hex)
}
}
unreachable_hex.sort()
let dangling : Array[@bit.ObjectId] = []
for hex in unreachable_hex {
dangling.push(@bit.ObjectId::from_hex(hex))
}
let pack = repack_from_objects(fs, git_dir, reachable)
if prune_enabled {
let remove_reachable = match pack {
Some(_) => true
None => false
}
prune_loose_objects(fs, rfs, git_dir, reachable_hex, remove_reachable)
match pack {
Some(info) => prune_packfiles(fs, rfs, git_dir, info.pack_id)
None => ()
}
}
{ pack, dangling }
}
///|
fn prune_git_dir(
fs : &@bit.FileSystem,
rfs : &@bit.RepoFileSystem,
git_dir : String,
) -> PruneResult raise @bit.GitError {
let ref_ids = collect_ref_ids(rfs, git_dir)
let db = ObjectDb::load(rfs, git_dir)
let roots = resolve_commit_roots(db, rfs, ref_ids)
if roots.length() == 0 {
return { pruned: [] }
}
let reachable = collect_reachable_objects_from_commits(db, rfs, roots)
collect_reachable_tag_objects(db, rfs, ref_ids, reachable)
// Match gc_git_dir: reflog-referenced objects are protected roots, so a bare
// `bit gc --prune` must not delete reflog-only loose objects.
collect_reflog_referenced_objects(db, rfs, git_dir, reachable)
let reachable_hex : Map[String, Bool] = Map([])
for obj in reachable {
let id = @bit.hash_object_content(obj.obj_type, obj.data)
reachable_hex[id.to_hex()] = true
}
let loose_hex = list_loose_object_hex(rfs, git_dir)
let pruned : Array[@bit.ObjectId] = []
for hex in loose_hex {
if !reachable_hex.contains(hex) {
pruned.push(@bit.ObjectId::from_hex(hex))
}
}
prune_loose_objects(fs, rfs, git_dir, reachable_hex, false)
{ pruned, }
}
///|
fn repack_from_objects(
fs : &@bit.FileSystem,
git_dir : String,
objects : Array[@bit.PackObject],
) -> RepackResult? raise @bit.GitError {
if objects.length() == 0 {
return None
}
let pack = @pack.create_packfile(objects)
// Force index rebuild from the newly created pack to avoid stale offsets/CRC
// carried from source packs.
let objects_for_index : Array[@bit.PackObject] = []
for obj in objects {
objects_for_index.push(
@bit.PackObject::with_metadata(obj.obj_type, obj.data, obj.id, -1, 0U),
)
}
@pack.write_packfile_with_index(fs, git_dir, pack, objects_for_index)
let pack_id = pack_trailer_id(pack)
Some({ pack_id, object_count: objects.length(), pack_bytes: pack.length() })
}
///|
fn pack_trailer_id(pack : Bytes) -> @bit.ObjectId raise @bit.GitError {
if pack.length() < 20 {
raise @bit.GitError::PackfileError("Packfile too short")
}
let start = pack.length() - 20
let bytes = FixedArray::makei(20, i => pack[start + i])
@bit.ObjectId::new(bytes)
}
///|
fn collect_ref_ids(
fs : &@bit.RepoFileSystem,
git_dir : String,
) -> Array[@bit.ObjectId] raise @bit.GitError {
let refs : Map[String, @bit.ObjectId] = Map([])
// Every namespace under refs/ is a reachability root (heads, tags, remotes,
// notes, agent, stash, replace, ...). Matching git, which roots ALL refs.
let refs_dir = join_path(git_dir, "refs")
if fs.is_dir(refs_dir) {
collect_loose_refs(fs, refs_dir, "refs", refs)
}
let packed_path = join_path(git_dir, "packed-refs")
if fs.is_file(packed_path) {
collect_packed_refs(fs, packed_path, refs)
}
let (head, _) = list_branches(fs, git_dir)
match head {
Detached(id) => refs["HEAD"] = id
Branch(_) => ()
}
collect_worktree_roots(fs, git_dir, refs)
refs.values().to_array()
}
///|
/// Linked worktrees keep per-worktree state under worktrees// in the
/// common git dir. Both the (possibly detached) HEAD and the per-worktree refs
/// (refs/bisect/*, refs/worktree/*, refs/rewritten/*) are reachability roots
/// git honors, so collect them all.
fn collect_worktree_roots(
fs : &@bit.RepoFileSystem,
git_dir : String,
out : Map[String, @bit.ObjectId],
) -> Unit raise @bit.GitError {
let worktrees_dir = join_path(git_dir, "worktrees")
if !fs.is_dir(worktrees_dir) {
return
}
for name in fs.readdir(worktrees_dir) {
let wt_dir = join_path(worktrees_dir, name)
collect_worktree_head(fs, wt_dir, name, out)
let wt_refs = join_path(wt_dir, "refs")
if fs.is_dir(wt_refs) {
collect_loose_refs(fs, wt_refs, "worktrees/" + name + "/refs", out)
}
}
}
///|
/// A detached worktree HEAD may be the only root keeping its checkout alive.
fn collect_worktree_head(
fs : &@bit.RepoFileSystem,
wt_dir : String,
name : String,
out : Map[String, @bit.ObjectId],
) -> Unit {
let head_path = join_path(wt_dir, "HEAD")
if !fs.is_file(head_path) {
return
}
let line = read_ref_line_gc(fs, head_path) catch { _ => return }
if line.has_prefix("ref: ") {
return
}
let id = @bit.ObjectId::from_hex(line) catch { _ => return }
out["worktrees/" + name + "/HEAD"] = id
}
///|
fn collect_loose_refs(
fs : &@bit.RepoFileSystem,
dir : String,
prefix : String,
out : Map[String, @bit.ObjectId],
) -> Unit raise @bit.GitError {
let entries = fs.readdir(dir)
for name in entries {
let path = join_path(dir, name)
let refname = if prefix == "" { name } else { prefix + "/" + name }
if fs.is_dir(path) {
collect_loose_refs(fs, path, refname, out)
} else if fs.is_file(path) {
// Skip symrefs (e.g. refs/remotes/origin/HEAD) — their targets are
// walked directly — and tolerate unparseable content instead of
// aborting the whole collection.
let line = read_ref_line_gc(fs, path) catch { _ => continue }
if line.has_prefix("ref: ") {
continue
}
let id = @bit.ObjectId::from_hex(line) catch { _ => continue }
out[refname] = id
}
}
}
///|
fn collect_packed_refs(
fs : &@bit.RepoFileSystem,
packed_path : String,
out : Map[String, @bit.ObjectId],
) -> Unit raise @bit.GitError {
let text = @utf8.decode_lossy(fs.read_file(packed_path)[:])
for line_view in text.split("\n") {
let line = trim_line_gc(line_view.to_owned())
if line.length() == 0 {
continue
}
if line.has_prefix("#") || line.has_prefix("^") {
continue
}
let space = line.find(" ")
match space {
None => ()
Some(idx) => {
if idx + 1 >= line.length() {
continue
}
let id_hex = String::unsafe_substring(line, start=0, end=idx)
let refname = String::unsafe_substring(
line,
start=idx + 1,
end=line.length(),
)
// Loose refs win over stale packed entries; accept every refs/
// namespace as a root.
if refname.has_prefix("refs/") && !out.contains(refname) {
let id = @bit.ObjectId::from_hex(id_hex) catch { _ => continue }
out[refname] = id
}
}
}
}
}
///|
fn read_ref_line_gc(
fs : &@bit.RepoFileSystem,
path : String,
) -> String raise @bit.GitError {
let text = @utf8.decode_lossy(fs.read_file(path)[:])
for line_view in text.split("\n") {
let line = trim_line_gc(line_view.to_owned())
if line.length() > 0 {
return line
}
}
raise @bit.GitError::InvalidObject("Empty ref: \{path}")
}
///|
fn trim_line_gc(line : String) -> String {
let mut s = line
if s.has_suffix("\r") {
s = String::unsafe_substring(s, start=0, end=s.length() - 1)
}
s
}
///|
fn collect_reflog_referenced_objects(
db : ObjectDb,
fs : &@bit.RepoFileSystem,
git_dir : String,
objects : Array[@bit.PackObject],
) -> Unit raise @bit.GitError {
let seen : Map[String, Bool] = Map([])
for obj in objects {
seen[@bit.hash_object_content(obj.obj_type, obj.data).to_hex()] = true
}
// Walk all reflog files and collect referenced OIDs
let reflog_ids = collect_all_reflog_ids(fs, git_dir)
for id in reflog_ids {
let hex = id.to_hex()
if seen.contains(hex) || id.is_zero() {
continue
}
match db.get(fs, id) {
Some(obj) => {
seen[hex] = true
objects.push(obj)
// If it's a commit, also collect its tree and blobs
if obj.obj_type == @bit.ObjectType::Commit {
let sub = collect_reachable_objects_from_commits(db, fs, [id])
for s in sub {
let sh = @bit.hash_object_content(s.obj_type, s.data).to_hex()
if !seen.contains(sh) {
seen[sh] = true
objects.push(s)
}
}
}
}
None => ()
}
}
}
///|
fn collect_all_reflog_ids(
fs : &@bit.RepoFileSystem,
git_dir : String,
) -> Array[@bit.ObjectId] raise @bit.GitError {
let ids : Array[@bit.ObjectId] = []
let logs_dir = git_dir + "/logs"
if fs.is_dir(logs_dir) {
collect_reflog_ids_recursive(fs, logs_dir, ids)
}
// Linked worktrees keep their own reflogs under worktrees//logs.
let worktrees_dir = git_dir + "/worktrees"
if fs.is_dir(worktrees_dir) {
for name in fs.readdir(worktrees_dir) {
let wt_logs = worktrees_dir + "/" + name + "/logs"
if fs.is_dir(wt_logs) {
collect_reflog_ids_recursive(fs, wt_logs, ids)
}
}
}
ids
}
///|
fn collect_reflog_ids_recursive(
fs : &@bit.RepoFileSystem,
dir : String,
ids : Array[@bit.ObjectId],
) -> Unit raise @bit.GitError {
let entries = fs.readdir(dir) catch { _ => return }
for name in entries {
let path = dir + "/" + name
if fs.is_dir(path) {
collect_reflog_ids_recursive(fs, path, ids)
} else if fs.is_file(path) {
let content = @utf8.decode_lossy(
(fs.read_file(path) catch { _ => continue })[:],
).to_string()
for line_view in content.split("\n") {
let line = line_view.to_owned()
if line.length() < 82 {
continue
}
// Format: old_hex new_hex ...
let old_hex = String::unsafe_substring(line, start=0, end=40)
let new_hex = String::unsafe_substring(line, start=41, end=81)
if @bithash.is_hex_string(old_hex) {
ids.push(@bit.ObjectId::from_hex(old_hex))
}
if @bithash.is_hex_string(new_hex) {
ids.push(@bit.ObjectId::from_hex(new_hex))
}
}
}
}
}
///|
fn collect_reachable_tag_objects(
db : ObjectDb,
fs : &@bit.RepoFileSystem,
ref_ids : Array[@bit.ObjectId],
objects : Array[@bit.PackObject],
) -> Unit raise @bit.GitError {
let seen : Map[String, Bool] = Map([])
for obj in objects {
seen[@bit.hash_object_content(obj.obj_type, obj.data).to_hex()] = true
}
for id in ref_ids {
let hex = id.to_hex()
if seen.contains(hex) {
continue
}
match db.get(fs, id) {
Some(obj) if obj.obj_type == @bit.ObjectType::Tag => {
seen[hex] = true
objects.push(obj)
}
_ => ()
}
}
}
///|
fn resolve_commit_roots(
db : ObjectDb,
fs : &@bit.RepoFileSystem,
ref_ids : Array[@bit.ObjectId],
) -> Array[@bit.ObjectId] raise @bit.GitError {
let seen : Map[String, Bool] = Map([])
let roots : Array[@bit.ObjectId] = []
for id in ref_ids {
let local_seen : Map[String, Bool] = Map([])
match resolve_commit_from_ref(db, fs, id, local_seen) {
None => ()
Some(commit_id) => {
let hex = commit_id.to_hex()
if !seen.contains(hex) {
seen[hex] = true
roots.push(commit_id)
}
}
}
}
roots
}
///|
fn resolve_commit_from_ref(
db : ObjectDb,
fs : &@bit.RepoFileSystem,
id : @bit.ObjectId,
seen : Map[String, Bool],
) -> @bit.ObjectId? raise @bit.GitError {
let hex = id.to_hex()
if seen.contains(hex) {
return None
}
seen[hex] = true
match db.get(fs, id) {
None => None
Some(obj) =>
match obj.obj_type {
@bit.ObjectType::Commit => Some(id)
@bit.ObjectType::Tag =>
match parse_tag_target(obj.data) {
None => None
Some((target, tag_type)) =>
if tag_type == "commit" {
Some(target)
} else if tag_type == "tag" {
resolve_commit_from_ref(db, fs, target, seen)
} else {
None
}
}
_ => None
}
}
}
///|
fn parse_tag_target(
data : Bytes,
) -> (@bit.ObjectId, String)? raise @bit.GitError {
let text = @utf8.decode_lossy(data[:])
let mut object_hex : String? = None
let mut object_type : String? = None
for line_view in text.split("\n") {
let line = line_view.to_owned()
if line.length() == 0 {
break
}
if line.has_prefix("object ") {
let hex = String::unsafe_substring(line, start=7, end=line.length())
object_hex = Some(hex)
} else if line.has_prefix("type ") {
let typ = String::unsafe_substring(line, start=5, end=line.length())
object_type = Some(typ)
}
}
match object_hex {
None => None
Some(hex) =>
match object_type {
None => None
Some(typ) => Some((@bit.ObjectId::from_hex(hex), typ))
}
}
}
///|
fn prune_loose_objects(
fs : &@bit.FileSystem,
rfs : &@bit.RepoFileSystem,
git_dir : String,
keep : Map[String, Bool],
remove_reachable : Bool,
) -> Unit raise @bit.GitError {
let objects_dir = join_path(git_dir, "objects")
if !rfs.is_dir(objects_dir) {
return
}
let entries = rfs.readdir(objects_dir)
for entry in entries {
if entry == "info" || entry == "pack" {
continue
}
if entry.length() != 2 {
continue
}
let dir = join_path(objects_dir, entry)
if !rfs.is_dir(dir) {
continue
}
let files = rfs.readdir(dir)
for name in files {
if name.length() != 38 {
continue
}
let path = join_path(dir, name)
if !rfs.is_file(path) {
continue
}
let hex = entry + name
if !remove_reachable && keep.contains(hex) {
continue
}
fs.remove_file(path)
}
}
}
///|
fn list_loose_object_hex(
fs : &@bit.RepoFileSystem,
git_dir : String,
) -> Array[String] raise @bit.GitError {
let out : Array[String] = []
let objects_dir = join_path(git_dir, "objects")
if !fs.is_dir(objects_dir) {
return out
}
let entries = fs.readdir(objects_dir)
for entry in entries {
if entry == "info" || entry == "pack" {
continue
}
if entry.length() != 2 {
continue
}
let dir = join_path(objects_dir, entry)
if !fs.is_dir(dir) {
continue
}
let files = fs.readdir(dir)
for name in files {
if name.length() != 38 {
continue
}
let path = join_path(dir, name)
if !fs.is_file(path) {
continue
}
out.push(entry + name)
}
}
out
}
///|
fn prune_packfiles(
fs : &@bit.FileSystem,
rfs : &@bit.RepoFileSystem,
git_dir : String,
keep_pack_id : @bit.ObjectId,
) -> Unit raise @bit.GitError {
let pack_dir = join_path(git_dir, "objects/pack")
if !rfs.is_dir(pack_dir) {
return
}
let base = "pack-" + keep_pack_id.to_hex()
let keep_pack = base + ".pack"
let keep_idx = base + ".idx"
let entries = rfs.readdir(pack_dir)
for entry in entries {
if entry == keep_pack || entry == keep_idx {
continue
}
if entry.has_suffix(".pack") || entry.has_suffix(".idx") {
fs.remove_file(join_path(pack_dir, entry))
}
}
}