← snapshot
9461 bytes
//! History reads: log, snapshot detail, tree browsing, blobs, diffs.
use std::collections::{BTreeSet, VecDeque};
use axum::Json;
use axum::extract::{Path, Query, State};
use jac_core::{SnapshotId, TreeId};
use jac_object::{ObjectStore as _, TreeNode};
use jac_repo::RefName;
use serde::Deserialize;
use serde_json::{Value, json};
use crate::dto::{self, FileEntryDto, SnapshotDto, TreeEntryDto};
use crate::error::ApiError;
use crate::routes::with_repo;
use crate::state::{RepoEntry, SharedState};
#[derive(Debug, Deserialize)]
pub(crate) struct LogQuery {
pub r#ref: String,
#[serde(default)]
pub limit: Option<usize>,
}
/// `GET /api/repos/{slug}/log?ref=&limit=` — history reachable from a ref's
/// head. Every entry carries its parents, so the caller can draw the DAG.
pub(crate) async fn log(
State(state): State<SharedState>,
Path(slug): Path<String>,
Query(query): Query<LogQuery>,
) -> Result<Json<Value>, ApiError> {
with_repo(&state, &slug, |entry| {
let name = RefName::parse(&query.r#ref)?;
let head = entry
.repo
.head(&name)
.ok_or_else(|| ApiError::not_found(format!("ref `{}` is unbound", name.as_str())))?;
let limit = query.limit.unwrap_or(100).min(1000);
let mut seen = BTreeSet::new();
let mut queue = VecDeque::from([head]);
let mut entries = Vec::new();
while let Some(id) = queue.pop_front() {
if !seen.insert(id) {
continue;
}
let snapshot = entry.repo.store().snapshot(id)?;
for parent in &snapshot.parents {
queue.push_back(*parent);
}
entries.push(SnapshotDto::of(id, snapshot, entry));
if entries.len() >= limit {
break;
}
}
// Newest first. The sort is stable and keyed on the timestamp alone,
// so commits sharing a millisecond keep BFS order — head before its
// parents — instead of an arbitrary id order.
entries.sort_by(|a, b| b.at.cmp(&a.at));
Ok(Json(json!({
"ref": name.as_str(),
"head": head.digest().to_hex().as_str(),
"entries": entries,
})))
})
}
/// `GET /api/repos/{slug}/snapshots/{hex}` — one snapshot, with its full
/// recursive file listing and the paths changed from its first parent.
pub(crate) async fn detail(
State(state): State<SharedState>,
Path((slug, hex)): Path<(String, String)>,
) -> Result<Json<Value>, ApiError> {
with_repo(&state, &slug, |entry| {
let id = dto::parse_snapshot_id(&hex)?;
let snapshot = entry.repo.store().snapshot(id)?.clone();
let mut files = Vec::new();
walk_files(entry, snapshot.tree, "", &mut files)?;
let changed_from_parent: Vec<String> = match snapshot.parents.first() {
Some(parent) => entry
.repo
.changed_between(*parent, id)?
.iter()
.map(|p| p.as_str().to_owned())
.collect(),
None => files.iter().map(|f| f.path.clone()).collect(),
};
Ok(Json(json!({
"snapshot": SnapshotDto::of(id, &snapshot, entry),
"files": files,
"changed_from_parent": changed_from_parent,
})))
})
}
/// Recursively lists every file under `tree`, building slash-joined paths.
fn walk_files(
entry: &RepoEntry,
tree: TreeId,
prefix: &str,
out: &mut Vec<FileEntryDto>,
) -> Result<(), ApiError> {
let tree = entry.repo.store().tree(tree)?.clone();
for item in tree.entries() {
let path = if prefix.is_empty() {
item.name.as_str().to_owned()
} else {
format!("{prefix}/{}", item.name.as_str())
};
match item.node {
TreeNode::Blob(id) => {
let size = entry.repo.store().blob(id)?.as_bytes().len();
out.push(FileEntryDto {
path,
blob: id.digest().to_hex().as_str().to_owned(),
size,
});
}
TreeNode::Tree(id) => walk_files(entry, id, &path, out)?,
// An unknown node kind is listed as nothing rather than failing
// the whole listing.
_ => {}
}
}
Ok(())
}
#[derive(Debug, Deserialize)]
pub(crate) struct TreeQuery {
#[serde(default)]
pub path: Option<String>,
}
/// `GET /api/repos/{slug}/snapshots/{hex}/tree?path=` — one directory level.
pub(crate) async fn tree(
State(state): State<SharedState>,
Path((slug, hex)): Path<(String, String)>,
Query(query): Query<TreeQuery>,
) -> Result<Json<Value>, ApiError> {
with_repo(&state, &slug, |entry| {
let id = dto::parse_snapshot_id(&hex)?;
let mut tree_id = entry.repo.store().snapshot(id)?.tree;
// Descend to the requested directory, segment by segment.
let path = query.path.as_deref().unwrap_or("");
if !path.is_empty() {
let repo_path = dto::parse_repo_path(path)?;
for segment in repo_path.segments() {
let tree = entry.repo.store().tree(tree_id)?;
let next = tree
.entries()
.iter()
.find(|e| e.name.as_str() == segment)
.ok_or_else(|| ApiError::not_found(format!("no entry `{segment}`")))?;
match next.node {
TreeNode::Tree(sub) => tree_id = sub,
_ => {
return Err(ApiError::invalid(format!("`{segment}` is not a directory")));
}
}
}
}
let tree = entry.repo.store().tree(tree_id)?.clone();
let mut entries = Vec::new();
for item in tree.entries() {
match item.node {
TreeNode::Blob(id) => {
let size = entry.repo.store().blob(id)?.as_bytes().len();
entries.push(TreeEntryDto {
name: item.name.as_str().to_owned(),
kind: "blob",
id: id.digest().to_hex().as_str().to_owned(),
size: Some(size),
});
}
TreeNode::Tree(id) => entries.push(TreeEntryDto {
name: item.name.as_str().to_owned(),
kind: "tree",
id: id.digest().to_hex().as_str().to_owned(),
size: None,
}),
_ => {}
}
}
Ok(Json(json!({ "path": path, "entries": entries })))
})
}
/// `GET /api/repos/{slug}/blobs/{hex}` — blob content.
pub(crate) async fn blob(
State(state): State<SharedState>,
Path((slug, hex)): Path<(String, String)>,
) -> Result<Json<Value>, ApiError> {
with_repo(&state, &slug, |entry| {
let id = dto::parse_blob_id(&hex)?;
let blob = entry.repo.store().blob(id)?;
let bytes = blob.as_bytes();
let (content, binary) = match core::str::from_utf8(bytes) {
Ok(text) => (Some(text.to_owned()), false),
Err(_) => (None, true),
};
Ok(Json(json!({
"id": id.digest().to_hex().as_str(),
"size": bytes.len(),
"content": content,
"binary": binary,
})))
})
}
#[derive(Debug, Deserialize)]
pub(crate) struct DiffQuery {
pub from: String,
pub to: String,
}
/// `GET /api/repos/{slug}/diff?from=&to=` — paths that differ between two
/// snapshots' trees.
pub(crate) async fn diff(
State(state): State<SharedState>,
Path(slug): Path<String>,
Query(query): Query<DiffQuery>,
) -> Result<Json<Value>, ApiError> {
with_repo(&state, &slug, |entry| {
let from = dto::parse_snapshot_id(&query.from)?;
let to = dto::parse_snapshot_id(&query.to)?;
let changed: Vec<String> = entry
.repo
.changed_between(from, to)?
.iter()
.map(|p| p.as_str().to_owned())
.collect();
Ok(Json(json!({ "changed": changed })))
})
}
/// Whether `descendant` reaches `ancestor` through parent edges. The engine
/// keeps its own copy private; this read-only walk serves the dry-run
/// preview.
pub(crate) fn descends_from(
entry: &RepoEntry,
descendant: SnapshotId,
ancestor: SnapshotId,
) -> Result<bool, ApiError> {
if descendant == ancestor {
return Ok(true);
}
let mut seen = BTreeSet::new();
let mut queue = VecDeque::from([descendant]);
while let Some(id) = queue.pop_front() {
if id == ancestor {
return Ok(true);
}
if !seen.insert(id) {
continue;
}
for parent in &entry.repo.store().snapshot(id)?.parents {
queue.push_back(*parent);
}
}
Ok(false)
}
/// Every file path in a snapshot, as a set — the blast radius of promoting
/// into an unbound ref.
pub(crate) fn all_paths(
entry: &RepoEntry,
id: SnapshotId,
) -> Result<BTreeSet<jac_core::RepoPath>, ApiError> {
let tree = entry.repo.store().snapshot(id)?.tree;
let mut files = Vec::new();
walk_files(entry, tree, "", &mut files)?;
files
.iter()
.map(|f| dto::parse_repo_path(&f.path))
.collect()
}