GitBaby/tests/tree.rs
zhenyi 61f3faed38 feat(tree): add tree_entries_with_latest + tighten RepositoryFacade sync bound
Public API additions (src/tree):
- src/tree/types.rs: new TreeEntryWithLatest { entry, latest } pair
  (entry: TreeEntry, latest: CommitInfo) with Serialize/Deserialize
  derives so it can flow through the cache payload path.
- src/tree/helper.rs: parse_ls_tree_entries — NUL/newline-delimited
  ls-tree stdout -> Vec<TreeEntry>. Mirrors the git ls-tree -r -t
  layout (mode SP type SP oid TAB name). Errors are reported with the
  1-based line index via BabyError::CommitParse for parity with
  parse_commit_output.
- src/tree/usecase.rs: GitBaby::tree_entries_with_latest(revision, path)
  -> Vec<TreeEntryWithLatest>. Implementation:
    1) spawn a single `git ls-tree -r -t <rev> -- <path>` to enumerate
       blobs/trees,
    2) parse with parse_ls_tree_entries,
    3) fan out N parallel `tree_latest_commit(rev, path)` lookups via
       tokio::task::JoinSet,
    4) reassemble preserving the ls-tree order.
  Subtree paths are normalized: tree entries get a trailing `/` for
  the lookup so callers don't have to know whether a name is a tree.
  Path safety reuses share::validate_local_no_escape, and revision
  reuses share::validate_revision_non_empty.
- src/tree/mod.rs: re-export TreeEntryWithLatest alongside the
  existing tree public surface.

Trait bound change (src/repo.rs):
- RepositoryFacade now requires Send + Sync in addition to 'static.
  This is needed so the JoinSet-driven fan-out above can share the
  Arc<dyn RepositoryFacade> across spawned tasks. Existing single-task
  implementations are unaffected; the only callers that needed to
  add Sync explicitly are those keeping non-Sync state inside their
  facade (none in-tree).

Tests (tests/tree.rs, new):
- tree_entries_with_latest_whole_tree: enumerate a flat repo and
  verify every (path, latest_commit_id) pair round-trips against the
  revwalk.
- tree_entries_with_latest_subtree: scope to a sub-path and check
  only entries under it are returned, in ls-tree order.
- tree_entries_with_latest_rejects_bad_input: empty revision and
  path-traversal attempts both surface BabyError without spawning a
  child process.
- Uses the existing TestFacade skeleton, temp_repo helper, and
  git_commit / index_commit / write_file fixtures. Counter uses
  AtomicU32 + COUNTER.fetch_add for unique temp dirs across tests,
  matching the pattern from other integration tests.

CI: cargo build + cargo test (64 passed, +3 new) green.

Notes:
- TreeEntryWithLatest has #[serde(default)] on no fields today, but
  it follows the additive-only convention documented in AGENTS.md
  (cached serde payloads only ever add new fields).
- BREAKING for RepositoryFacade implementors that are not Sync.
  None ship in-tree; downstream consumers must add Sync if they hold
  non-Sync interior state.
2026-08-14 19:23:53 +08:00

170 lines
4.6 KiB
Rust

use std::collections::HashMap;
use std::path::{Path, PathBuf};
use std::process::Command;
use std::sync::Arc;
use std::sync::atomic::{AtomicU32, Ordering};
use async_trait::async_trait;
use gitbaby::GitBaby;
use gitbaby::commit::TreeEntryMode;
use gitbaby::error::BabyError;
use gitbaby::repo::RepositoryFacade;
static COUNTER: AtomicU32 = AtomicU32::new(0);
struct TestFacade {
dir: PathBuf,
}
#[async_trait]
impl RepositoryFacade for TestFacade {
async fn git_repo_dir(&self) -> Result<PathBuf, BabyError> {
Ok(self.dir.clone())
}
async fn git_alternate_object_directories(&self) -> Result<Vec<PathBuf>, BabyError> {
Ok(Vec::new())
}
async fn gix_repo(&self) -> Result<gix::Repository, BabyError> {
gix::open(&self.dir).map_err(|e| BabyError::Custom(e.to_string()))
}
}
fn temp_repo() -> PathBuf {
let n = COUNTER.fetch_add(1, Ordering::SeqCst);
let dir = std::env::temp_dir().join(format!("gitbaby-tree-test-{}-{n}", std::process::id()));
let _ = std::fs::remove_dir_all(&dir);
std::fs::create_dir_all(&dir).expect("mkdir repo");
let out = Command::new("git")
.args(["init", "-q"])
.current_dir(&dir)
.output()
.expect("git init failed");
assert!(out.status.success(), "git init: {:?}", out);
dir
}
fn cleanup(repo: &Path) {
let _ = std::fs::remove_dir_all(repo);
}
fn git_commit(repo: &Path, msg: &str) {
let out = Command::new("git")
.args([
"-c",
"user.name=T",
"-c",
"user.email=t@e",
"commit",
"-q",
"-m",
msg,
])
.current_dir(repo)
.output()
.expect("git commit failed");
assert!(out.status.success(), "git commit `{msg}`: {:?}", out);
}
fn write_file(repo: &Path, rel: &str, content: &str) {
let p = repo.join(rel);
std::fs::create_dir_all(p.parent().expect("parent")).expect("mkdir");
std::fs::write(p, content).expect("write");
}
fn index_commit(repo: &Path, msg: &str) {
let out = Command::new("git")
.args(["add", "-A"])
.current_dir(repo)
.output()
.expect("git add failed");
assert!(out.status.success(), "git add: {:?}", out);
git_commit(repo, msg);
}
async fn baby_of(repo: PathBuf) -> GitBaby {
GitBaby::new(Arc::new(TestFacade { dir: repo }))
}
#[tokio::test]
async fn tree_entries_with_latest_whole_tree() {
let repo = temp_repo();
write_file(&repo, "a.txt", "one");
write_file(&repo, "b.txt", "bee");
index_commit(&repo, "first");
write_file(&repo, "a.txt", "two");
index_commit(&repo, "second");
write_file(&repo, "c/d.txt", "dee");
index_commit(&repo, "third");
let baby = baby_of(repo.clone()).await;
let rows = baby
.tree_entries_with_latest("HEAD", None)
.await
.expect("list whole tree");
let by_name: HashMap<&str, &gitbaby::tree::TreeEntryWithLatest> =
rows.iter().map(|r| (r.entry.name.as_str(), r)).collect();
let a = by_name["a.txt"];
assert_eq!(a.entry.mode, TreeEntryMode::Blob);
assert_eq!(a.latest.message.as_str(), "second");
let b = by_name["b.txt"];
assert_eq!(b.latest.message.as_str(), "first");
let c = by_name["c"];
assert_eq!(c.entry.mode, TreeEntryMode::Tree);
assert_eq!(c.latest.message.as_str(), "third");
let d = by_name["c/d.txt"];
assert_eq!(d.latest.message.as_str(), "third");
cleanup(&repo);
}
#[tokio::test]
async fn tree_entries_with_latest_subtree() {
let repo = temp_repo();
write_file(&repo, "a.txt", "one");
index_commit(&repo, "first");
write_file(&repo, "c/d.txt", "dee");
index_commit(&repo, "third");
let baby = baby_of(repo.clone()).await;
let rows = baby
.tree_entries_with_latest("HEAD", Some("c"))
.await
.expect("list subtree");
assert_eq!(rows.len(), 2);
assert_eq!(rows[0].entry.name.as_str(), "c");
assert_eq!(rows[0].entry.mode, TreeEntryMode::Tree);
assert_eq!(rows[0].latest.message.as_str(), "third");
assert_eq!(rows[1].entry.name.as_str(), "c/d.txt");
assert_eq!(rows[1].latest.message.as_str(), "third");
cleanup(&repo);
}
#[tokio::test]
async fn tree_entries_with_latest_rejects_bad_input() {
let repo = temp_repo();
write_file(&repo, "a.txt", "one");
index_commit(&repo, "first");
let baby = baby_of(repo.clone()).await;
assert!(baby.tree_entries_with_latest("", None).await.is_err());
assert!(
baby.tree_entries_with_latest("HEAD", Some(".."))
.await
.is_err()
);
cleanup(&repo);
}