///|
/// Core of `git last-modified` (git 2.55): attribute each final-state path to
/// the commit that last modified it. Pure logic over `ObjectDb` +
/// `RepoFileSystem`; the CLI wrapper handles argument parsing and output.
///|
pub(all) struct LmPath {
path : String
}
///|
pub(all) struct LmResult {
oid : @bit.ObjectId
path : String
boundary : Bool
}
///|
pub(all) struct LmCommit {
id : @bit.ObjectId
tree : @bit.ObjectId
parents : Array[@bit.ObjectId]
timestamp : Int64
}
///|
/// Parse the committer timestamp (unix seconds) from a raw commit object.
pub fn last_modified_committer_ts(data : Bytes) -> Int64 {
let text = @utf8.decode_lossy(data[:])
for line_view in text.split("\n") {
let line = line_view.to_owned()
if line.length() == 0 {
break
}
if line.has_prefix("committer ") {
// "committer Name "
let parts = line.split(" ")
let toks : Array[String] = []
for p in parts {
toks.push(p.to_owned())
}
// timestamp is the second-to-last token.
if toks.length() >= 2 {
let ts_str = toks[toks.length() - 2]
return @string.parse_int64(ts_str) catch { _ => 0L }
}
}
}
0L
}
///|
/// Does any pathspec reach strictly into the subtree at `path` (i.e. name
/// something below it)? Such a subtree must be descended even at the depth
/// limit, so pathspecs like `a/file` or `a/*` can be found.
fn last_modified_reaches_into(path : String, pathspecs : Array[String]) -> Bool {
for spec in pathspecs {
if spec.has_prefix(path + "/") {
return true
}
}
false
}
///|
/// Collect the entries of `tree_id` honoring last-modified's depth model.
/// `max_depth < 0` = unlimited (`-r`); `0` (default) = top-level only; a tree
/// reached at exactly `max_depth` is emitted as a tree leaf; trees above it are
/// descended and only shown when `show_trees`. A subtree named by a pathspec is
/// always descended so the named paths can be reached.
pub fn last_modified_collect_entries(
db : ObjectDb,
fs : &@bit.RepoFileSystem,
tree_id : @bit.ObjectId,
prefix : String,
depth : Int,
max_depth : Int,
show_trees : Bool,
pathspecs : Array[String],
out : Array[LmPath],
) -> Unit raise @bit.GitError {
let obj = db.get(fs, tree_id)
guard obj is Some(tree_obj) else {
raise @bit.GitError::InvalidObject("Missing tree object")
}
let entries = @bit.parse_tree(tree_obj.data)
for entry in entries {
let path = if prefix.length() == 0 {
entry.name
} else {
prefix + "/" + entry.name
}
let is_tree = entry.mode == "40000" || entry.mode == "040000"
if is_tree {
let pathspec_descend = last_modified_reaches_into(path, pathspecs)
let can_descend = max_depth < 0 || depth < max_depth || pathspec_descend
if can_descend {
// A pathspec-forced descent does not surface the tree itself unless
// -t is set and the tree is within the normal depth budget.
if show_trees && (max_depth < 0 || depth < max_depth) {
out.push({ path, })
}
last_modified_collect_entries(
db,
fs,
entry.id,
path,
depth + 1,
max_depth,
show_trees,
pathspecs,
out,
)
} else {
out.push({ path, })
}
} else {
out.push({ path, })
}
}
}
///|
/// Resolve `path` (slash-separated) to its object id within `tree_id`, or None.
fn last_modified_path_oid(
db : ObjectDb,
fs : &@bit.RepoFileSystem,
tree_id : @bit.ObjectId,
path : String,
) -> @bit.ObjectId? raise @bit.GitError {
let comps : Array[String] = []
for c in path.split("/") {
comps.push(c.to_owned())
}
let mut cur = tree_id
for k in 0.. Bool {
if pathspecs.length() == 0 {
return true
}
for spec in pathspecs {
if last_modified_one_pathspec(path, spec) {
return true
}
}
false
}
///|
fn last_modified_one_pathspec(path : String, spec : String) -> Bool {
if spec.has_suffix("/*") {
let base = String::unsafe_substring(spec, start=0, end=spec.length() - 2)
// Match direct children of base (one extra segment).
if !path.has_prefix(base + "/") {
return false
}
let rest = String::unsafe_substring(
path,
start=base.length() + 1,
end=path.length(),
)
return !rest.contains("/")
}
if spec.has_suffix("*") {
let base = String::unsafe_substring(spec, start=0, end=spec.length() - 1)
return path.has_prefix(base)
}
path == spec || path.has_prefix(spec + "/")
}
///|
fn last_modified_load_commit(
db : ObjectDb,
fs : &@bit.RepoFileSystem,
id : @bit.ObjectId,
) -> LmCommit raise @bit.GitError {
let obj = db.get(fs, id)
guard obj is Some(o) else {
raise @bit.GitError::InvalidObject("bad commit \{id.to_hex()}")
}
if o.obj_type != @bit.ObjectType::Commit {
raise @bit.GitError::InvalidObject("not a commit \{id.to_hex()}")
}
let info = @bit.parse_commit(o.data)
let ts = last_modified_committer_ts(o.data)
{ id, tree: info.tree, parents: info.parents, timestamp: ts }
}
///|
fn last_modified_tag_target(data : Bytes) -> @bit.ObjectId raise @bit.GitError {
let text = @utf8.decode_lossy(data[:])
for line_view in text.split("\n") {
let line = line_view.to_owned()
if line.has_prefix("object ") {
let hex = String::unsafe_substring(line, start=7, end=line.length())
return @bit.ObjectId::from_hex(hex)
}
if line.length() == 0 {
break
}
}
raise @bit.GitError::InvalidObject("tag has no object header")
}
///|
/// Peel `id` (commit/tag) to a commit; error with git's message if it is a tree.
pub fn last_modified_resolve_commitish(
db : ObjectDb,
fs : &@bit.RepoFileSystem,
id : @bit.ObjectId,
spec : String,
) -> LmCommit raise @bit.GitError {
let obj = db.get(fs, id)
guard obj is Some(o) else {
raise @bit.GitError::InvalidObject("bad object \{id.to_hex()}")
}
match o.obj_type {
@bit.ObjectType::Commit => last_modified_load_commit(db, fs, id)
@bit.ObjectType::Tag => {
let target = last_modified_tag_target(o.data)
last_modified_resolve_commitish(db, fs, target, spec)
}
@bit.ObjectType::Tree =>
raise @bit.GitError::InvalidObject(
"revision argument '\{spec}' is a tree, not a commit-ish",
)
_ =>
raise @bit.GitError::InvalidObject(
"revision argument '\{spec}' is not a commit-ish",
)
}
}
///|
/// Compute the last-modified attribution for the tree of `start` commit.
/// `start_id` must already be resolved (e.g. via rev-parse) to a commit-ish.
pub fn last_modified_compute(
db : ObjectDb,
fs : &@bit.RepoFileSystem,
start_id : @bit.ObjectId,
spec : String,
max_depth : Int,
show_trees : Bool,
max_count : Int,
bottoms : Array[@bit.ObjectId],
pathspecs : Array[String],
) -> Array[LmResult] raise @bit.GitError {
let start = last_modified_resolve_commitish(db, fs, start_id, spec)
let all_entries : Array[LmPath] = []
last_modified_collect_entries(
db,
fs,
start.tree,
"",
0,
max_depth,
show_trees,
pathspecs,
all_entries,
)
let all_paths : Array[LmPath] = []
for e in all_entries {
if last_modified_pathspec_matches(e.path, pathspecs) {
all_paths.push(e)
}
}
// Commits reachable from any `bottom` (e.g. `A` in `A..B`) are excluded; the
// walk treats them as boundaries and emits remaining paths with a `^` prefix.
let bottom_set : Map[String, Bool] = Map([])
let bstack : Array[@bit.ObjectId] = []
for b in bottoms {
let peeled = last_modified_resolve_commitish(db, fs, b, spec)
bstack.push(peeled.id)
}
while bstack.length() > 0 {
let cur = bstack.unsafe_pop()
let hex = cur.to_hex()
if bottom_set.contains(hex) {
continue
}
bottom_set[hex] = true
let obj = db.get(fs, cur)
if obj is Some(o) && o.obj_type == @bit.ObjectType::Commit {
for p in @bit.parse_commit(o.data).parents {
bstack.push(p)
}
}
}
last_modified_walk(db, fs, start, all_paths, max_count, bottom_set)
}
///|
/// The walk: attribute each path via per-commit active-path bitmaps with
/// TREESAME propagation to parents.
fn last_modified_walk(
db : ObjectDb,
fs : &@bit.RepoFileSystem,
start : LmCommit,
all_paths : Array[LmPath],
max_count : Int,
bottom_set : Map[String, Bool],
) -> Array[LmResult] raise @bit.GitError {
let n = all_paths.length()
let results : Array[LmResult] = []
if n == 0 {
return results
}
let resolved = Array::make(n, false)
let mut resolved_count = 0
let commits : Map[String, LmCommit] = Map([])
let stack = [start.id]
commits[start.id.to_hex()] = start
while stack.length() > 0 {
let cur = stack.unsafe_pop()
let c = commits.get(cur.to_hex()).unwrap()
// Parents whose objects are absent (shallow-clone boundary) are dropped,
// so the commit is attributed like a root instead of aborting the walk.
let kept : Array[@bit.ObjectId] = []
for p in c.parents {
let hex = p.to_hex()
if commits.contains(hex) {
kept.push(p)
continue
}
match db.get(fs, p) {
Some(_) => {
commits[hex] = last_modified_load_commit(db, fs, p)
stack.push(p)
kept.push(p)
}
None => ()
}
}
if kept.length() != c.parents.length() {
c.parents.clear()
for k in kept {
c.parents.push(k)
}
}
}
let active : Map[String, Array[Bool]] = Map([])
active[start.id.to_hex()] = Array::make(n, true)
let processed : Map[String, Bool] = Map([])
let mut popped = 0
fn pick_next() -> String? {
let mut best : String? = None
let mut best_ts = 0L
let mut have = false
for hex, _ in active {
if processed.contains(hex) {
continue
}
let c = commits.get(hex).unwrap()
if !have || c.timestamp > best_ts {
best_ts = c.timestamp
best = Some(hex)
have = true
}
}
best
}
while resolved_count < n {
guard pick_next() is Some(chex) else { break }
processed[chex] = true
let c = commits.get(chex).unwrap()
let act = active.get(chex).unwrap()
popped += 1
if (max_count >= 0 && popped > max_count) || bottom_set.contains(chex) {
for idx in 0.. s
None => {
let s = Array::make(n, false)
active[phex] = s
s
}
}
for idx in 0.. a == b
(None, None) => true
_ => false
}
if same {
passed[idx] = true
pact[idx] = true
}
}
}
for idx in 0..