Tahinli/forge
public0 watching
★ 0
| 1 | use std::path::{Path, PathBuf}; |
| 2 | use std::process::Command; |
| 3 | |
| 4 | use forge_git::browse::{ |
| 5 | EntryKind, RefKind, default_branch_commit, list_refs, read_blob, read_commit, read_tree, |
| 6 | resolve_commit, split_ref_and_path, validate_repo_path, |
| 7 | }; |
| 8 | |
| 9 | struct Fixture { |
| 10 | _dir: tempfile::TempDir, |
| 11 | bare: PathBuf, |
| 12 | head: String, |
| 13 | first: String, |
| 14 | } |
| 15 | |
| 16 | fn git(dir: &Path, args: &[&str]) -> String { |
| 17 | let out = Command::new("git") |
| 18 | .args(args) |
| 19 | .current_dir(dir) |
| 20 | .env("GIT_AUTHOR_NAME", "Ada Lovelace") |
| 21 | .env("GIT_AUTHOR_EMAIL", "ada@example.com") |
| 22 | .env("GIT_COMMITTER_NAME", "Grace Hopper") |
| 23 | .env("GIT_COMMITTER_EMAIL", "grace@example.com") |
| 24 | .env("GIT_AUTHOR_DATE", "1700000000 +0000") |
| 25 | .env("GIT_COMMITTER_DATE", "1700000100 +0000") |
| 26 | .env("GIT_CONFIG_NOSYSTEM", "1") |
| 27 | .env("HOME", dir) |
| 28 | .output() |
| 29 | .expect("git must be installed"); |
| 30 | assert!( |
| 31 | out.status.success(), |
| 32 | "git {args:?} failed: {}", |
| 33 | String::from_utf8_lossy(&out.stderr) |
| 34 | ); |
| 35 | String::from_utf8_lossy(&out.stdout).trim().to_string() |
| 36 | } |
| 37 | |
| 38 | /// Builds a work repo with the real git CLI, then clones it bare so tests read a bare repo. |
| 39 | fn fixture() -> Fixture { |
| 40 | let dir = tempfile::tempdir().unwrap(); |
| 41 | let work = dir.path().join("work"); |
| 42 | std::fs::create_dir(&work).unwrap(); |
| 43 | git(&work, &["init", "-q", "-b", "main"]); |
| 44 | |
| 45 | std::fs::write(work.join("README.md"), "# Hello\nsecond line\n").unwrap(); |
| 46 | std::fs::create_dir_all(work.join("dir/sub")).unwrap(); |
| 47 | std::fs::write(work.join("dir/sub/nested.txt"), "nested\n").unwrap(); |
| 48 | std::fs::write(work.join("dir/file.txt"), "file body\n").unwrap(); |
| 49 | std::fs::create_dir(work.join("bin")).unwrap(); |
| 50 | std::fs::write(work.join("bin/run.sh"), "#!/bin/sh\necho hi\n").unwrap(); |
| 51 | { |
| 52 | use std::os::unix::fs::PermissionsExt; |
| 53 | std::fs::set_permissions( |
| 54 | work.join("bin/run.sh"), |
| 55 | std::fs::Permissions::from_mode(0o755), |
| 56 | ) |
| 57 | .unwrap(); |
| 58 | } |
| 59 | std::os::unix::fs::symlink("README.md", work.join("link")).unwrap(); |
| 60 | std::fs::write(work.join("image.bin"), [0x89, b'P', b'N', b'G', 0, 1, 2, 3]).unwrap(); |
| 61 | |
| 62 | git(&work, &["add", "-A"]); |
| 63 | git(&work, &["commit", "-q", "-m", "first commit\n\nbody text"]); |
| 64 | let first = git(&work, &["rev-parse", "HEAD"]); |
| 65 | |
| 66 | std::fs::write(work.join("dir/file.txt"), "file body v2\n").unwrap(); |
| 67 | git(&work, &["commit", "-q", "-am", "second commit"]); |
| 68 | let head = git(&work, &["rev-parse", "HEAD"]); |
| 69 | |
| 70 | git(&work, &["tag", "-a", "v1.0", "-m", "release 1.0"]); |
| 71 | git(&work, &["branch", "feature/x"]); |
| 72 | |
| 73 | let bare = dir.path().join("bare.git"); |
| 74 | git( |
| 75 | dir.path(), |
| 76 | &[ |
| 77 | "clone", |
| 78 | "-q", |
| 79 | "--bare", |
| 80 | work.to_str().unwrap(), |
| 81 | bare.to_str().unwrap(), |
| 82 | ], |
| 83 | ); |
| 84 | |
| 85 | Fixture { |
| 86 | _dir: dir, |
| 87 | bare, |
| 88 | head, |
| 89 | first, |
| 90 | } |
| 91 | } |
| 92 | |
| 93 | #[test] |
| 94 | fn list_refs_has_branches_and_peeled_tags() { |
| 95 | let f = fixture(); |
| 96 | let refs = list_refs(&f.bare).unwrap(); |
| 97 | |
| 98 | let main = refs |
| 99 | .iter() |
| 100 | .find(|r| r.full_name == "refs/heads/main") |
| 101 | .unwrap(); |
| 102 | assert_eq!(main.name, "main"); |
| 103 | assert_eq!(main.kind, RefKind::Branch); |
| 104 | assert_eq!(main.target, f.head); |
| 105 | |
| 106 | let feature = refs |
| 107 | .iter() |
| 108 | .find(|r| r.name == "feature/x") |
| 109 | .expect("feature/x listed"); |
| 110 | assert_eq!(feature.full_name, "refs/heads/feature/x"); |
| 111 | assert_eq!(feature.kind, RefKind::Branch); |
| 112 | |
| 113 | let tag = refs.iter().find(|r| r.name == "v1.0").unwrap(); |
| 114 | assert_eq!(tag.full_name, "refs/tags/v1.0"); |
| 115 | assert_eq!(tag.kind, RefKind::Tag); |
| 116 | assert_eq!(tag.target, f.head, "annotated tag must peel to its commit"); |
| 117 | } |
| 118 | |
| 119 | #[test] |
| 120 | fn default_branch_commit_works() { |
| 121 | let f = fixture(); |
| 122 | assert_eq!( |
| 123 | default_branch_commit(&f.bare, "main").unwrap(), |
| 124 | Some(f.head.clone()) |
| 125 | ); |
| 126 | assert_eq!(default_branch_commit(&f.bare, "nope").unwrap(), None); |
| 127 | assert_eq!(default_branch_commit(&f.bare, "../main").unwrap(), None); |
| 128 | } |
| 129 | |
| 130 | #[test] |
| 131 | fn resolve_commit_accepts_branch_tag_and_short_sha() { |
| 132 | let f = fixture(); |
| 133 | assert_eq!( |
| 134 | resolve_commit(&f.bare, "main").unwrap(), |
| 135 | Some(f.head.clone()) |
| 136 | ); |
| 137 | assert_eq!( |
| 138 | resolve_commit(&f.bare, "feature/x").unwrap(), |
| 139 | Some(f.head.clone()) |
| 140 | ); |
| 141 | assert_eq!( |
| 142 | resolve_commit(&f.bare, "v1.0").unwrap(), |
| 143 | Some(f.head.clone()), |
| 144 | "tag peels to commit" |
| 145 | ); |
| 146 | assert_eq!( |
| 147 | resolve_commit(&f.bare, &f.first[..8]).unwrap(), |
| 148 | Some(f.first.clone()) |
| 149 | ); |
| 150 | assert_eq!( |
| 151 | resolve_commit(&f.bare, &f.head.to_uppercase()).unwrap(), |
| 152 | Some(f.head.clone()), |
| 153 | "full uppercase hex resolves" |
| 154 | ); |
| 155 | } |
| 156 | |
| 157 | #[test] |
| 158 | fn resolve_commit_rejects_revision_syntax_and_unknown() { |
| 159 | let f = fixture(); |
| 160 | for rev in [ |
| 161 | "HEAD~1", |
| 162 | "-x", |
| 163 | "a..b", |
| 164 | "@{0}", |
| 165 | "HEAD", |
| 166 | "main^", |
| 167 | "main:README.md", |
| 168 | "ma in", |
| 169 | "", |
| 170 | "zzzz", |
| 171 | "nope", |
| 172 | ] { |
| 173 | assert_eq!(resolve_commit(&f.bare, rev).unwrap(), None, "rev {rev:?}"); |
| 174 | } |
| 175 | // 3 hex chars is below the minimum prefix length. |
| 176 | assert_eq!(resolve_commit(&f.bare, &f.head[..3]).unwrap(), None); |
| 177 | } |
| 178 | |
| 179 | #[test] |
| 180 | fn split_ref_and_path_prefers_longest_ref() { |
| 181 | let f = fixture(); |
| 182 | let got = split_ref_and_path(&f.bare, "feature/x/dir/file.txt").unwrap(); |
| 183 | assert_eq!( |
| 184 | got, |
| 185 | Some(( |
| 186 | "feature/x".to_string(), |
| 187 | f.head.clone(), |
| 188 | "dir/file.txt".to_string() |
| 189 | )) |
| 190 | ); |
| 191 | assert_eq!( |
| 192 | split_ref_and_path(&f.bare, "main").unwrap(), |
| 193 | Some(("main".to_string(), f.head.clone(), String::new())) |
| 194 | ); |
| 195 | let by_oid = split_ref_and_path(&f.bare, &format!("{}/README.md", &f.first[..10])).unwrap(); |
| 196 | assert_eq!( |
| 197 | by_oid, |
| 198 | Some(( |
| 199 | f.first[..10].to_string(), |
| 200 | f.first.clone(), |
| 201 | "README.md".to_string() |
| 202 | )) |
| 203 | ); |
| 204 | assert_eq!(split_ref_and_path(&f.bare, "unknown/x").unwrap(), None); |
| 205 | assert_eq!(split_ref_and_path(&f.bare, "main/../etc").unwrap(), None); |
| 206 | } |
| 207 | |
| 208 | #[test] |
| 209 | fn read_tree_orders_dirs_first_and_reports_kinds() { |
| 210 | let f = fixture(); |
| 211 | let root = read_tree(&f.bare, &f.head, "").unwrap().unwrap(); |
| 212 | let names: Vec<(&str, EntryKind)> = root.iter().map(|e| (e.name.as_str(), e.kind)).collect(); |
| 213 | assert_eq!( |
| 214 | names, |
| 215 | vec![ |
| 216 | ("bin", EntryKind::Tree), |
| 217 | ("dir", EntryKind::Tree), |
| 218 | ("README.md", EntryKind::Blob), |
| 219 | ("image.bin", EntryKind::Blob), |
| 220 | ("link", EntryKind::Symlink), |
| 221 | ] |
| 222 | ); |
| 223 | let dir = root.iter().find(|e| e.name == "dir").unwrap(); |
| 224 | assert_eq!(dir.size, None); |
| 225 | let readme = root.iter().find(|e| e.name == "README.md").unwrap(); |
| 226 | assert_eq!(readme.size, Some("# Hello\nsecond line\n".len() as u64)); |
| 227 | let link = root.iter().find(|e| e.name == "link").unwrap(); |
| 228 | assert_eq!(link.size, Some("README.md".len() as u64)); |
| 229 | |
| 230 | let sub = read_tree(&f.bare, &f.head, "dir").unwrap().unwrap(); |
| 231 | let names: Vec<&str> = sub.iter().map(|e| e.name.as_str()).collect(); |
| 232 | assert_eq!(names, vec!["sub", "file.txt"]); |
| 233 | |
| 234 | let bin = read_tree(&f.bare, &f.head, "bin").unwrap().unwrap(); |
| 235 | assert_eq!(bin[0].kind, EntryKind::Executable); |
| 236 | |
| 237 | assert_eq!(read_tree(&f.bare, &f.head, "README.md").unwrap(), None); |
| 238 | assert_eq!(read_tree(&f.bare, &f.head, "missing").unwrap(), None); |
| 239 | assert!(read_tree(&f.bare, &f.head, "../x").is_err()); |
| 240 | } |
| 241 | |
| 242 | #[test] |
| 243 | fn read_blob_truncates_and_detects_binary_and_symlink() { |
| 244 | let f = fixture(); |
| 245 | let readme = read_blob(&f.bare, &f.head, "README.md", 5) |
| 246 | .unwrap() |
| 247 | .unwrap(); |
| 248 | assert_eq!(readme.kind, EntryKind::Blob); |
| 249 | assert_eq!(readme.data, b"# Hel"); |
| 250 | assert!(readme.truncated); |
| 251 | assert!(!readme.is_binary); |
| 252 | assert_eq!(readme.size, "# Hello\nsecond line\n".len() as u64); |
| 253 | |
| 254 | let full = read_blob(&f.bare, &f.head, "README.md", 1024) |
| 255 | .unwrap() |
| 256 | .unwrap(); |
| 257 | assert!(!full.truncated); |
| 258 | assert_eq!(full.data, b"# Hello\nsecond line\n"); |
| 259 | |
| 260 | let bin = read_blob(&f.bare, &f.head, "image.bin", 1024) |
| 261 | .unwrap() |
| 262 | .unwrap(); |
| 263 | assert!(bin.is_binary); |
| 264 | |
| 265 | let exec = read_blob(&f.bare, &f.head, "bin/run.sh", 1024) |
| 266 | .unwrap() |
| 267 | .unwrap(); |
| 268 | assert_eq!(exec.kind, EntryKind::Executable); |
| 269 | |
| 270 | let link = read_blob(&f.bare, &f.head, "link", 1024).unwrap().unwrap(); |
| 271 | assert_eq!(link.kind, EntryKind::Symlink); |
| 272 | assert_eq!(link.data, b"README.md"); |
| 273 | |
| 274 | assert_eq!(read_blob(&f.bare, &f.head, "dir", 1024).unwrap(), None); |
| 275 | assert_eq!(read_blob(&f.bare, &f.head, "nope", 1024).unwrap(), None); |
| 276 | } |
| 277 | |
| 278 | #[test] |
| 279 | fn read_commit_fields() { |
| 280 | let f = fixture(); |
| 281 | let head = read_commit(&f.bare, &f.head).unwrap().unwrap(); |
| 282 | assert_eq!(head.oid, f.head); |
| 283 | assert_eq!(head.parents, vec![f.first.clone()]); |
| 284 | assert_eq!(head.author_name, "Ada Lovelace"); |
| 285 | assert_eq!(head.author_email, "ada@example.com"); |
| 286 | assert_eq!(head.author_time, 1_700_000_000); |
| 287 | assert_eq!(head.committer_name, "Grace Hopper"); |
| 288 | assert_eq!(head.committer_email, "grace@example.com"); |
| 289 | assert_eq!(head.committer_time, 1_700_000_100); |
| 290 | assert_eq!(head.summary, "second commit"); |
| 291 | |
| 292 | let first = read_commit(&f.bare, &f.first).unwrap().unwrap(); |
| 293 | assert!(first.parents.is_empty()); |
| 294 | assert_eq!(first.summary, "first commit"); |
| 295 | assert_eq!(first.message, "first commit\n\nbody text\n"); |
| 296 | |
| 297 | assert_eq!( |
| 298 | read_commit(&f.bare, "0123456789012345678901234567890123456789").unwrap(), |
| 299 | None |
| 300 | ); |
| 301 | } |
| 302 | |
| 303 | #[test] |
| 304 | fn validate_repo_path_cases() { |
| 305 | assert!(validate_repo_path("").is_ok()); |
| 306 | assert!(validate_repo_path("a").is_ok()); |
| 307 | assert!(validate_repo_path("a/b.txt").is_ok()); |
| 308 | assert!(validate_repo_path(".hidden/x").is_ok()); |
| 309 | assert!(validate_repo_path("a/".repeat(2047).trim_end_matches('/')).is_ok()); |
| 310 | assert!(validate_repo_path("/a").is_err()); |
| 311 | assert!(validate_repo_path("a/").is_err()); |
| 312 | assert!(validate_repo_path("a//b").is_err()); |
| 313 | assert!(validate_repo_path("a/./b").is_err()); |
| 314 | assert!(validate_repo_path("a/../b").is_err()); |
| 315 | assert!(validate_repo_path("..").is_err()); |
| 316 | assert!(validate_repo_path("a\0b").is_err()); |
| 317 | assert!(validate_repo_path(&"a".repeat(4097)).is_err()); |
| 318 | } |