use std::path::{Path, PathBuf}; use std::process::Command; use forge_git::browse::{ EntryKind, RefKind, default_branch_commit, list_refs, read_blob, read_commit, read_tree, resolve_commit, split_ref_and_path, validate_repo_path, }; struct Fixture { _dir: tempfile::TempDir, bare: PathBuf, head: String, first: String, } fn git(dir: &Path, args: &[&str]) -> String { let out = Command::new("git") .args(args) .current_dir(dir) .env("GIT_AUTHOR_NAME", "Ada Lovelace") .env("GIT_AUTHOR_EMAIL", "ada@example.com") .env("GIT_COMMITTER_NAME", "Grace Hopper") .env("GIT_COMMITTER_EMAIL", "grace@example.com") .env("GIT_AUTHOR_DATE", "1700000000 +0000") .env("GIT_COMMITTER_DATE", "1700000100 +0000") .env("GIT_CONFIG_NOSYSTEM", "1") .env("HOME", dir) .output() .expect("git must be installed"); assert!( out.status.success(), "git {args:?} failed: {}", String::from_utf8_lossy(&out.stderr) ); String::from_utf8_lossy(&out.stdout).trim().to_string() } /// Builds a work repo with the real git CLI, then clones it bare so tests read a bare repo. fn fixture() -> Fixture { let dir = tempfile::tempdir().unwrap(); let work = dir.path().join("work"); std::fs::create_dir(&work).unwrap(); git(&work, &["init", "-q", "-b", "main"]); std::fs::write(work.join("README.md"), "# Hello\nsecond line\n").unwrap(); std::fs::create_dir_all(work.join("dir/sub")).unwrap(); std::fs::write(work.join("dir/sub/nested.txt"), "nested\n").unwrap(); std::fs::write(work.join("dir/file.txt"), "file body\n").unwrap(); std::fs::create_dir(work.join("bin")).unwrap(); std::fs::write(work.join("bin/run.sh"), "#!/bin/sh\necho hi\n").unwrap(); { use std::os::unix::fs::PermissionsExt; std::fs::set_permissions( work.join("bin/run.sh"), std::fs::Permissions::from_mode(0o755), ) .unwrap(); } std::os::unix::fs::symlink("README.md", work.join("link")).unwrap(); std::fs::write(work.join("image.bin"), [0x89, b'P', b'N', b'G', 0, 1, 2, 3]).unwrap(); git(&work, &["add", "-A"]); git(&work, &["commit", "-q", "-m", "first commit\n\nbody text"]); let first = git(&work, &["rev-parse", "HEAD"]); std::fs::write(work.join("dir/file.txt"), "file body v2\n").unwrap(); git(&work, &["commit", "-q", "-am", "second commit"]); let head = git(&work, &["rev-parse", "HEAD"]); git(&work, &["tag", "-a", "v1.0", "-m", "release 1.0"]); git(&work, &["branch", "feature/x"]); let bare = dir.path().join("bare.git"); git( dir.path(), &[ "clone", "-q", "--bare", work.to_str().unwrap(), bare.to_str().unwrap(), ], ); Fixture { _dir: dir, bare, head, first, } } #[test] fn list_refs_has_branches_and_peeled_tags() { let f = fixture(); let refs = list_refs(&f.bare).unwrap(); let main = refs .iter() .find(|r| r.full_name == "refs/heads/main") .unwrap(); assert_eq!(main.name, "main"); assert_eq!(main.kind, RefKind::Branch); assert_eq!(main.target, f.head); let feature = refs .iter() .find(|r| r.name == "feature/x") .expect("feature/x listed"); assert_eq!(feature.full_name, "refs/heads/feature/x"); assert_eq!(feature.kind, RefKind::Branch); let tag = refs.iter().find(|r| r.name == "v1.0").unwrap(); assert_eq!(tag.full_name, "refs/tags/v1.0"); assert_eq!(tag.kind, RefKind::Tag); assert_eq!(tag.target, f.head, "annotated tag must peel to its commit"); } #[test] fn default_branch_commit_works() { let f = fixture(); assert_eq!( default_branch_commit(&f.bare, "main").unwrap(), Some(f.head.clone()) ); assert_eq!(default_branch_commit(&f.bare, "nope").unwrap(), None); assert_eq!(default_branch_commit(&f.bare, "../main").unwrap(), None); } #[test] fn resolve_commit_accepts_branch_tag_and_short_sha() { let f = fixture(); assert_eq!( resolve_commit(&f.bare, "main").unwrap(), Some(f.head.clone()) ); assert_eq!( resolve_commit(&f.bare, "feature/x").unwrap(), Some(f.head.clone()) ); assert_eq!( resolve_commit(&f.bare, "v1.0").unwrap(), Some(f.head.clone()), "tag peels to commit" ); assert_eq!( resolve_commit(&f.bare, &f.first[..8]).unwrap(), Some(f.first.clone()) ); assert_eq!( resolve_commit(&f.bare, &f.head.to_uppercase()).unwrap(), Some(f.head.clone()), "full uppercase hex resolves" ); } #[test] fn resolve_commit_rejects_revision_syntax_and_unknown() { let f = fixture(); for rev in [ "HEAD~1", "-x", "a..b", "@{0}", "HEAD", "main^", "main:README.md", "ma in", "", "zzzz", "nope", ] { assert_eq!(resolve_commit(&f.bare, rev).unwrap(), None, "rev {rev:?}"); } // 3 hex chars is below the minimum prefix length. assert_eq!(resolve_commit(&f.bare, &f.head[..3]).unwrap(), None); } #[test] fn split_ref_and_path_prefers_longest_ref() { let f = fixture(); let got = split_ref_and_path(&f.bare, "feature/x/dir/file.txt").unwrap(); assert_eq!( got, Some(( "feature/x".to_string(), f.head.clone(), "dir/file.txt".to_string() )) ); assert_eq!( split_ref_and_path(&f.bare, "main").unwrap(), Some(("main".to_string(), f.head.clone(), String::new())) ); let by_oid = split_ref_and_path(&f.bare, &format!("{}/README.md", &f.first[..10])).unwrap(); assert_eq!( by_oid, Some(( f.first[..10].to_string(), f.first.clone(), "README.md".to_string() )) ); assert_eq!(split_ref_and_path(&f.bare, "unknown/x").unwrap(), None); assert_eq!(split_ref_and_path(&f.bare, "main/../etc").unwrap(), None); } #[test] fn read_tree_orders_dirs_first_and_reports_kinds() { let f = fixture(); let root = read_tree(&f.bare, &f.head, "").unwrap().unwrap(); let names: Vec<(&str, EntryKind)> = root.iter().map(|e| (e.name.as_str(), e.kind)).collect(); assert_eq!( names, vec![ ("bin", EntryKind::Tree), ("dir", EntryKind::Tree), ("README.md", EntryKind::Blob), ("image.bin", EntryKind::Blob), ("link", EntryKind::Symlink), ] ); let dir = root.iter().find(|e| e.name == "dir").unwrap(); assert_eq!(dir.size, None); let readme = root.iter().find(|e| e.name == "README.md").unwrap(); assert_eq!(readme.size, Some("# Hello\nsecond line\n".len() as u64)); let link = root.iter().find(|e| e.name == "link").unwrap(); assert_eq!(link.size, Some("README.md".len() as u64)); let sub = read_tree(&f.bare, &f.head, "dir").unwrap().unwrap(); let names: Vec<&str> = sub.iter().map(|e| e.name.as_str()).collect(); assert_eq!(names, vec!["sub", "file.txt"]); let bin = read_tree(&f.bare, &f.head, "bin").unwrap().unwrap(); assert_eq!(bin[0].kind, EntryKind::Executable); assert_eq!(read_tree(&f.bare, &f.head, "README.md").unwrap(), None); assert_eq!(read_tree(&f.bare, &f.head, "missing").unwrap(), None); assert!(read_tree(&f.bare, &f.head, "../x").is_err()); } #[test] fn read_blob_truncates_and_detects_binary_and_symlink() { let f = fixture(); let readme = read_blob(&f.bare, &f.head, "README.md", 5) .unwrap() .unwrap(); assert_eq!(readme.kind, EntryKind::Blob); assert_eq!(readme.data, b"# Hel"); assert!(readme.truncated); assert!(!readme.is_binary); assert_eq!(readme.size, "# Hello\nsecond line\n".len() as u64); let full = read_blob(&f.bare, &f.head, "README.md", 1024) .unwrap() .unwrap(); assert!(!full.truncated); assert_eq!(full.data, b"# Hello\nsecond line\n"); let bin = read_blob(&f.bare, &f.head, "image.bin", 1024) .unwrap() .unwrap(); assert!(bin.is_binary); let exec = read_blob(&f.bare, &f.head, "bin/run.sh", 1024) .unwrap() .unwrap(); assert_eq!(exec.kind, EntryKind::Executable); let link = read_blob(&f.bare, &f.head, "link", 1024).unwrap().unwrap(); assert_eq!(link.kind, EntryKind::Symlink); assert_eq!(link.data, b"README.md"); assert_eq!(read_blob(&f.bare, &f.head, "dir", 1024).unwrap(), None); assert_eq!(read_blob(&f.bare, &f.head, "nope", 1024).unwrap(), None); } #[test] fn read_commit_fields() { let f = fixture(); let head = read_commit(&f.bare, &f.head).unwrap().unwrap(); assert_eq!(head.oid, f.head); assert_eq!(head.parents, vec![f.first.clone()]); assert_eq!(head.author_name, "Ada Lovelace"); assert_eq!(head.author_email, "ada@example.com"); assert_eq!(head.author_time, 1_700_000_000); assert_eq!(head.committer_name, "Grace Hopper"); assert_eq!(head.committer_email, "grace@example.com"); assert_eq!(head.committer_time, 1_700_000_100); assert_eq!(head.summary, "second commit"); let first = read_commit(&f.bare, &f.first).unwrap().unwrap(); assert!(first.parents.is_empty()); assert_eq!(first.summary, "first commit"); assert_eq!(first.message, "first commit\n\nbody text\n"); assert_eq!( read_commit(&f.bare, "0123456789012345678901234567890123456789").unwrap(), None ); } #[test] fn validate_repo_path_cases() { assert!(validate_repo_path("").is_ok()); assert!(validate_repo_path("a").is_ok()); assert!(validate_repo_path("a/b.txt").is_ok()); assert!(validate_repo_path(".hidden/x").is_ok()); assert!(validate_repo_path("a/".repeat(2047).trim_end_matches('/')).is_ok()); assert!(validate_repo_path("/a").is_err()); assert!(validate_repo_path("a/").is_err()); assert!(validate_repo_path("a//b").is_err()); assert!(validate_repo_path("a/./b").is_err()); assert!(validate_repo_path("a/../b").is_err()); assert!(validate_repo_path("..").is_err()); assert!(validate_repo_path("a\0b").is_err()); assert!(validate_repo_path(&"a".repeat(4097)).is_err()); }